snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
# @univerjs/network
[![npm version](https://img.shields.io/npm/v/@univerjs/network?style=flat-square)](https://npmjs.com/package/@univerjs/network)
[![license](https://img.shields.io/npm/l/@univerjs/network?style=flat-square)](https://npmjs.com/package/@univerjs/network)
[![downloads](https://img.shields.io/npm/dm/@univerjs/network?style=flat-square)](https://npmjs.com/package/@univerjs/network)
`@univerjs/network` provides basic network service abstractions for Univer runtimes, especially collaboration-oriented integrations.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/network` | `UniverNetwork` | No | No | Yes |
## Installation
```sh
pnpm add @univerjs/network
# or
npm install @univerjs/network
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { UniverNetworkPlugin } from '@univerjs/network';
univer.registerPlugin(UniverNetworkPlugin);
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/network)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,89 @@
{
"name": "@univerjs/network",
"version": "1.0.0-rc.0",
"private": false,
"description": "Network service abstractions for Univer runtime integrations.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"network",
"http",
"collaboration",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./facade": "./src/facade/index.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DependencyOverride } from '@univerjs/core';
export const NETWORK_PLUGIN_CONFIG_KEY = 'network.config';
export const configSymbol = Symbol(NETWORK_PLUGIN_CONFIG_KEY);
export interface IUniverNetworkConfig {
/**
* Use fetch instead of XMLHttpRequest. By default, Univer will use fetch on Node.js and XMLHttpRequest in browser.
*/
useFetchImpl?: boolean;
/**
* Build in dependencies that can be overridden:
*
* - {@link HTTPService}
* - {@link IHTTPImplementation}
*/
override?: DependencyOverride;
/**
* Force to use a new instance of {@link HTTPService} and {@link IHTTPImplementation} even if
* an ancestor injector already has them registered.
*/
forceUseNewInstance?: boolean;
}
export const defaultPluginConfig: IUniverNetworkConfig = {};

View File

@@ -0,0 +1,161 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HTTPRequest, ISocket, SocketBodyType } from '@univerjs/network';
import { ILogService, LogLevel, Univer } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import {
HTTPHeaders,
HTTPResponse,
HTTPService,
IHTTPImplementation,
ISocketService,
WebSocketService,
} from '@univerjs/network';
import { firstValueFrom } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MockHTTPImplementation } from '../../services/http/__tests__/http-testing-utils';
import '@univerjs/network/facade';
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
readonly send = vi.fn();
readonly close = vi.fn();
constructor(readonly url: string) {
FakeWebSocket.instances.push(this);
}
addEventListener(): void {}
removeEventListener(): void {}
}
describe('network facade', () => {
const originalWebSocket = globalThis.WebSocket;
let univer: Univer;
let univerAPI: FUniver;
beforeEach(() => {
FakeWebSocket.instances = [];
vi.stubGlobal('WebSocket', FakeWebSocket as unknown as typeof WebSocket);
univer = new Univer();
const injector = univer.__getInjector();
injector.add([HTTPService]);
injector.add([IHTTPImplementation, { useClass: MockHTTPImplementation }]);
injector.add([ISocketService, { useClass: WebSocketService }]);
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
univerAPI = FUniver.newAPI(injector);
});
afterEach(() => {
univer.dispose();
vi.unstubAllGlobals();
globalThis.WebSocket = originalWebSocket;
});
it('sends requests through the network facade with the configured HTTP service', async () => {
const injector = univer.__getInjector();
const httpImplementation = injector.get(IHTTPImplementation) as MockHTTPImplementation;
const requests: HTTPRequest[] = [];
const subscription = httpImplementation.newRequest$.subscribe((request) => {
requests.push(request);
httpImplementation.getHandler(request.uid).emitResponse(new HTTPResponse({
body: { ok: true },
headers: new HTTPHeaders({ 'content-type': 'application/json' }),
status: 200,
statusText: 'OK',
}));
});
const response = await univerAPI.getNetwork().post<{ ok: boolean }>('https://example.com/api', {
body: { name: 'Univer' },
headers: { authorization: 'Bearer token' },
params: { page: 1 },
});
expect(response.body).toEqual({ ok: true });
expect(requests).toHaveLength(1);
expect(requests[0].method).toBe('POST');
expect(requests[0].getUrlWithParams()).toBe('https://example.com/api?page=1');
expect(requests[0].getBody()).toBe(JSON.stringify({ name: 'Univer' }));
expect(requests[0].headers.get('authorization')).toEqual(['Bearer token']);
subscription.unsubscribe();
});
it('supports item lifecycle requests and server-sent event streams', async () => {
const injector = univer.__getInjector();
const httpImplementation = injector.get(IHTTPImplementation) as MockHTTPImplementation;
const subscription = httpImplementation.newRequest$.subscribe((request) => {
const url = request.getUrlWithParams();
const body = request.getBody();
const parseBody = () => JSON.parse(typeof body === 'string' ? body : '{}');
const responseBody = (() => {
if (request.method === 'GET' && url === '/items') {
return { items: ['item-1'] };
}
if (request.method === 'PUT' && url === '/items/1') {
return { replaced: parseBody().name };
}
if (request.method === 'DELETE' && url === '/items/1') {
return { deleted: 'item-1' };
}
if (request.method === 'PATCH' && url === '/items/1') {
return { patched: parseBody().name };
}
if (request.method === 'POST' && url === '/events') {
return { cursor: parseBody().cursor, stream: 'opened' };
}
return { error: 'unexpected request' };
})();
httpImplementation.getHandler(request.uid).emitResponse(new HTTPResponse({
body: responseBody,
headers: new HTTPHeaders(),
status: 200,
statusText: 'OK',
}));
});
const network = univerAPI.getNetwork();
await expect(network.get('/items')).resolves.toMatchObject({ body: { items: ['item-1'] } });
await expect(network.put('/items/1', { body: { name: 'Item' } })).resolves.toMatchObject({ body: { replaced: 'Item' } });
await expect(network.delete('/items/1')).resolves.toMatchObject({ body: { deleted: 'item-1' } });
await expect(network.patch('/items/1', { body: { name: 'Patched' } })).resolves.toMatchObject({ body: { patched: 'Patched' } });
await expect(firstValueFrom(network.getSSE('POST', '/events', { body: { cursor: 1 } }))).resolves.toMatchObject({ body: { cursor: 1, stream: 'opened' } });
subscription.unsubscribe();
});
it('creates sockets through the WebSocket service registered in DI', () => {
const socket = univerAPI.createSocket('wss://example.com/socket');
const payload: SocketBodyType = 'hello';
expect(socket.URL).toBe('wss://example.com/socket');
socket.send(payload);
socket.close(1000, 'done');
const connection = FakeWebSocket.instances[0] as FakeWebSocket & ISocket;
expect(connection.send).toHaveBeenCalledWith(payload);
expect(connection.close).toHaveBeenCalledWith(1000, 'done');
});
});

View File

@@ -0,0 +1,103 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HTTPEvent, HTTPRequestMethod, HTTPResponse, IPostRequestParams, IRequestParams } from '@univerjs/network';
import type { Observable } from 'rxjs';
import { Inject, Injector } from '@univerjs/core';
import { FBase } from '@univerjs/core/facade';
import { HTTPService } from '@univerjs/network';
/**
* This Facade provides a set of methods to make HTTP requests. You should not
* create an instance of this class directly, instead, use `getNetwork` of
* {@link FUniver} instead.
*
* @hideconstructor
*/
export class FNetwork extends FBase {
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@Inject(HTTPService) protected readonly _httpService: HTTPService
) {
super();
}
/**
* Send a GET request to the server.
* @param {string} url - The requested URL.
* @param {IRequestParams} [params] - Request options, including query parameters, headers, and response type.
* @returns {Promise<HTTPResponse<T>>} A promise resolving to the HTTP response with a body of type `T`.
*/
get<T>(url: string, params?: IRequestParams): Promise<HTTPResponse<T>> {
return this._httpService.get(url, params) as Promise<HTTPResponse<T>>; ;
}
/**
* Send a POST request to the server.
* @param {string} url - The requested URL.
* @param {IPostRequestParams} [params] - Request options, including body, query parameters, headers, and response type.
* @returns {Promise<HTTPResponse<T>>} A promise resolving to the HTTP response with a body of type `T`.
*/
post<T>(url: string, params?: IPostRequestParams): Promise<HTTPResponse<T>> {
return this._httpService.post(url, params) as Promise<HTTPResponse<T>>; ;
}
/**
* Send a PUT request to the server.
* @param {string} url - The requested URL
* @param {IPostRequestParams} [params] - Request options, including body, query parameters, headers, and response type
* @returns {Promise<HTTPResponse<T>>} A promise resolving to the HTTP response with a body of type `T`.
*/
put<T>(url: string, params?: IPostRequestParams): Promise<HTTPResponse<T>> {
return this._httpService.put(url, params) as Promise<HTTPResponse<T>>; ;
}
/**
* Send DELETE request to the server.
* @param {string} url - The requested URL
* @param {IRequestParams} [params] - Request options, including query parameters, headers, and response type
* @returns {Promise<HTTPResponse<T>>} A promise resolving to the HTTP response with a body of type `T`.
*/
delete<T>(url: string, params?: IRequestParams): Promise<HTTPResponse<T>> {
return this._httpService.delete(url, params) as Promise<HTTPResponse<T>>; ;
}
/**
* Send PATCH request to the server.
* @param {string} url - The requested URL
* @param {IPostRequestParams} [params] - Request options, including body, query parameters, headers, and response type
* @returns {Promise<HTTPResponse<T>>} A promise resolving to the HTTP response with a body of type `T`.
*/
patch<T>(url: string, params?: IPostRequestParams): Promise<HTTPResponse<T>> {
return this._httpService.patch(url, params) as Promise<HTTPResponse<T>>;
}
/**
* Request for a stream of server-sent events. Instead of a single response, the server sends a stream of responses,
* Univer wraps the stream in an [`Observable`](https://rxjs.dev/guide/observable) which you can call `subscribe` on.
* @param {HTTPRequestMethod} method - HTTP request method
* @param {string} url - The requested URL
* @param {IPostRequestParams} [params] - params Request options, including body, query parameters, headers, and response type
* @returns {Observable<HTTPEvent<T>>} An observable of HTTP events with response data of type `T`.
*/
getSSE<T>(
method: HTTPRequestMethod,
url: string,
params?: IPostRequestParams
): Observable<HTTPEvent<T>> {
return this._httpService.stream(method, url, params);
}
}

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ISocket } from '@univerjs/network';
import { FUniver } from '@univerjs/core/facade';
import { WebSocketService } from '@univerjs/network';
import { FNetwork } from './f-network';
/**
* @ignore
*/
interface IFUniverNetworkMixin {
/**
* Get the network API of Univer, with the help of which you can send HTTP requests.
*/
getNetwork(): FNetwork;
/**
* Set WebSocket URL for WebSocketService
*
* @param {string} url WebSocket URL
* @returns {ISocket} WebSocket instance
* @example
* ```typescript
* // Replace the URL with the address of your own WebSocket service
* const ws = univerAPI.createSocket('wss://47.100.177.253:8449/ws');
*
* ws.open$.subscribe(() => {
* console.log('websocket opened');
* ws.send('hello');
* });
*
* ws.message$.subscribe((message) => {
* console.log('websocket message', message);
* const content = JSON.parse(message.data).content;
* if (!content.includes('command')) {
* return;
* }
*
* const commandInfo = JSON.parse(content);
* const { command, options } = commandInfo;
* const { id, params } = command;
*
* // Upon receiving collaborative data, it is locally saved
* univerAPI.executeCommand(id, params, options);
* });
*
* ws.close$.subscribe(() => {
* console.log('websocket closed');
* });
*
* ws.error$.subscribe((error) => {
* console.log('websocket error', error);
* });
*
* univerAPI.addEvent(univerAPI.Event.CommandExecuted, ({ id, type, params, options }) => {
* // Only synchronize local mutations
* if (type !== univerAPI.Enum.CommandType.MUTATION || options?.fromCollab || options?.onlyLocal || id === 'doc.mutation.rich-text-editing') {
* return;
* }
*
* const commandInfo = JSON.stringify({ command: { id, type, params }, options: { fromCollab: true } });
* ws.send(commandInfo);
* });
* ```
*/
createSocket(url: string): ISocket;
}
export class FUniverNetworkMixin extends FUniver implements IFUniverNetworkMixin {
override getNetwork(): FNetwork {
return this._injector.createInstance(FNetwork);
}
override createSocket(url: string): ISocket {
const wsService = this._injector.createInstance(WebSocketService);
const ws = wsService.createSocket(url);
if (!ws) {
throw new Error('[WebSocketService]: failed to create socket!');
}
return ws;
}
}
FUniver.extend(FUniverNetworkMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FUniver extends IFUniverNetworkMixin { }
}

View File

@@ -0,0 +1,20 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './f-univer';
import './f-network';
export type { FNetwork } from './f-network';

View File

@@ -0,0 +1,39 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type { IUniverNetworkConfig } from './config/config';
export { UniverNetworkPlugin } from './plugin';
export { HTTPHeaders } from './services/http/headers';
export { HTTPStatusCode } from './services/http/http';
export type { HTTPResponseType } from './services/http/http';
export { HTTPService } from './services/http/http.service';
export type { IPostRequestParams, IRequestParams } from './services/http/http.service';
export { FetchHTTPImplementation } from './services/http/implementations/fetch';
export { IHTTPImplementation } from './services/http/implementations/implementation';
export { XHRHTTPImplementation } from './services/http/implementations/xhr';
export type { HTTPHandlerFn, HTTPInterceptorFn, HTTPInterceptorFnFactory } from './services/http/interceptor';
export { AuthInterceptorFactory } from './services/http/interceptors/auth-interceptor';
export type { IAuthInterceptorParams } from './services/http/interceptors/auth-interceptor';
export { MergeInterceptorFactory } from './services/http/interceptors/merge-interceptor';
export { RetryInterceptorFactory } from './services/http/interceptors/retry-interceptor';
export type { IRetryInterceptorFactoryParams } from './services/http/interceptors/retry-interceptor';
export { ThresholdInterceptorFactory } from './services/http/interceptors/threshold-interceptor';
export { HTTPRequest } from './services/http/request';
export type { HTTPRequestMethod } from './services/http/request';
export { HTTPEventType, HTTPProgress, HTTPResponse, HTTPResponseError, ResponseHeader } from './services/http/response';
export type { HTTPEvent, HTTPResponseBody } from './services/http/response';
export { ISocketService, WebSocketService } from './services/web-socket/web-socket.service';
export type { ISocket, SocketBodyType } from './services/web-socket/web-socket.service';

View File

@@ -0,0 +1,73 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IUniverNetworkConfig } from './config/config';
import { IConfigService, ILogService, Inject, Injector, LookUp, merge, mergeOverrideWithDependencies, Plugin, Quantity, registerDependencies } from '@univerjs/core';
import pkg from '../package.json';
import { defaultPluginConfig, NETWORK_PLUGIN_CONFIG_KEY } from './config/config';
import { HTTPService } from './services/http/http.service';
import { FetchHTTPImplementation } from './services/http/implementations/fetch';
import { IHTTPImplementation } from './services/http/implementations/implementation';
import { XHRHTTPImplementation } from './services/http/implementations/xhr';
/**
* This plugin add network services to the Univer instance.
*/
export class UniverNetworkPlugin extends Plugin {
static override pluginName = 'UNIVER_NETWORK_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverNetworkConfig> = defaultPluginConfig,
@ILogService private readonly _logger: ILogService,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(NETWORK_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const parent = this._injector.get(HTTPService, Quantity.OPTIONAL, LookUp.SKIP_SELF);
if (parent && !this._config?.forceUseNewInstance) {
this._logger.warn(
'[UniverNetworkPlugin]',
'HTTPService is already registered in an ancestor interceptor. Skipping registration. ' +
'If you want to force a new instance, set "forceUseNewInstance" to true in the plugin configuration.'
);
return;
}
const impl = this._config?.useFetchImpl
? FetchHTTPImplementation
: typeof window !== 'undefined'
? XHRHTTPImplementation
: FetchHTTPImplementation;
registerDependencies(this._injector, mergeOverrideWithDependencies([
[HTTPService],
[IHTTPImplementation, { useClass: impl }],
], this._config?.override));
}
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { HTTPHeaders, isApplicationJSONType } from '../headers';
describe('HTTPHeaders', () => {
it('normalizes constructor headers and keeps repeated values', () => {
const headers = new HTTPHeaders({
Accept: 'text/plain',
'X-Retry': 1,
'X-Enabled': true,
});
headers.set('x-retry', 2);
expect(headers.has('ACCEPT')).toBe(true);
expect(headers.get('accept')).toEqual(['text/plain']);
expect(headers.get('x-retry')).toEqual(['1', '2']);
expect(headers.get('missing')).toBeNull();
});
it('parses browser Headers and raw header strings into fetch init values', () => {
const browserHeaders = new Headers();
browserHeaders.set('X-Token', 'abc');
const fromBrowser = new HTTPHeaders(browserHeaders);
const fromString = new HTTPHeaders('Content-Type: text/plain\nX-Trace: trace-1\nBroken');
const seen: Array<[string, string[]]> = [];
fromBrowser.forEach((name, value) => seen.push([name, value]));
expect(seen).toEqual([['x-token', ['abc']]]);
expect(fromString.toHeadersInit()).toEqual({
'content-type': ' text/plain',
'x-trace': ' trace-1',
accept: 'application/json, text/plain, */*',
});
});
it('adds default JSON headers except for FormData bodies', () => {
expect(new HTTPHeaders().toHeadersInit()).toEqual({
accept: 'application/json, text/plain, */*',
'content-type': 'application/json;charset=UTF-8',
});
expect(new HTTPHeaders().toHeadersInit(new FormData())).toEqual({
accept: 'application/json, text/plain, */*',
});
});
it('detects JSON content types in string or array forms', () => {
expect(isApplicationJSONType('application/json; charset=utf-8')).toBe(true);
expect(isApplicationJSONType(['text/plain', 'application/json'])).toBe(true);
expect(isApplicationJSONType('text/plain')).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { HTTPParams } from '../params';
describe('test class HTTPParams', () => {
it('should support empty params', () => {
const params = new HTTPParams();
expect(params.toString()).toBe('');
});
it('should support single params', () => {
const params = new HTTPParams({ key: 'value' });
expect(params.toString()).toBe('key=value');
});
it('should support multiple params', () => {
const params = new HTTPParams({ key1: 'value1', key2: 'value2' });
expect(params.toString()).toBe('key1=value1&key2=value2');
});
it('should support array params', () => {
const params = new HTTPParams({ key: ['value1', 'value2'] });
expect(params.toString()).toBe('key=value1&key=value2');
});
});

View File

@@ -0,0 +1,100 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable ts/no-explicit-any */
/* eslint-disable ts/no-this-alias */
import type { Observer } from 'rxjs';
import type { HTTPRequest } from '../request';
import type { HTTPEvent, HTTPResponseError } from '../response';
import { Disposable, Injector } from '@univerjs/core';
import { Observable, Subject } from 'rxjs';
import { HTTPService } from '../http.service';
import { IHTTPImplementation } from '../implementations/implementation';
/**
* A mocked HTTP implementation service for testing purposes. Besides methods in the interface, it
* provides several public methods to control the process of http request.
*/
export class MockHTTPImplementation extends Disposable implements IHTTPImplementation {
private readonly _newRequest$ = new Subject<HTTPRequest>();
readonly newRequest$ = this._newRequest$.asObservable();
private readonly _handlers: Map<number, IMockHTTPHandler> = new Map();
override dispose(): void {
super.dispose();
this._newRequest$.complete();
}
send(request: HTTPRequest): Observable<HTTPEvent<any>> {
return new Observable((observer: Observer<HTTPEvent<any>>) => {
const service = this;
const handler: IMockHTTPHandler = {
emitResponse: (response) => {
observer.next(response);
removeHandler();
},
emitError: (error) => {
observer.error(error);
removeHandler();
},
};
function removeHandler() {
service._handlers.delete(request.uid);
}
this._handlers.set(request.uid, handler);
this._newRequest$.next(request);
});
}
/**
* Get a handler to interact with the request.
* @param uid the request's unique identifier
* @returns the handler for the request
*/
getHandler(uid: number): IMockHTTPHandler {
const handler = this._handlers.get(uid);
if (!handler) {
throw new Error(`[MockHTTPImplementation]: no handler found for ${uid}!`);
}
return handler;
}
}
export interface IMockHTTPHandler {
/**
* Emit a response event to the observer.
*/
emitResponse<T>(response: HTTPEvent<T>): void;
/**
* Emit an error event to the observer.
*/
emitError(error: HTTPResponseError): void;
}
export function createHTTPTestBed() {
const injector = new Injector();
injector.add([HTTPService]);
injector.add([IHTTPImplementation, { useClass: MockHTTPImplementation }]);
return { injector };
}

View File

@@ -0,0 +1,145 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector } from '@univerjs/core';
import type { MockHTTPImplementation } from './http-testing-utils';
import { firstValueFrom } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { HTTPHeaders } from '../headers';
import { HTTPService } from '../http.service';
import { IHTTPImplementation } from '../implementations/implementation';
import { __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION } from '../request';
import { HTTPProgress, HTTPResponse } from '../response';
import { createHTTPTestBed } from './http-testing-utils';
describe('test "HTTPService"', () => {
let httpService: HTTPService;
let httpImplementation: MockHTTPImplementation;
let injector: Injector;
beforeEach(() => {
injector = createHTTPTestBed().injector;
httpService = injector.get(HTTPService);
httpImplementation = injector.get(IHTTPImplementation) as MockHTTPImplementation;
});
afterEach(() => {
injector.dispose();
__TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION();
});
it('runs interceptors in ascending priority order under the current implementation', async () => {
const callOrder: string[] = [];
httpService.registerHTTPInterceptor({
priority: 10,
interceptor: (request, next) => {
callOrder.push('priority-10:request');
request.headers.set('X-Chain', 'first');
return next(request);
},
});
httpService.registerHTTPInterceptor({
priority: 100,
interceptor: (request, next) => {
callOrder.push('priority-100:request');
request.headers.set('X-Chain', 'second');
return next(request);
},
});
const sentRequestPromise = firstValueFrom(httpImplementation.newRequest$);
const request = httpService.get<{ ok: boolean }>('http://example.com');
const sentRequest = await sentRequestPromise;
expect(callOrder).toEqual(['priority-10:request', 'priority-100:request']);
expect(sentRequest.headers.get('x-chain')).toEqual(['first', 'second']);
httpImplementation.getHandler(sentRequest.uid).emitResponse(new HTTPResponse({
headers: new HTTPHeaders(),
status: 200,
statusText: 'OK',
body: { ok: true },
}));
await expect(request).resolves.toMatchObject({ body: { ok: true } });
});
it('rebuilds the request pipe after a new interceptor is registered', async () => {
const firstInterceptorCalls: string[] = [];
const secondInterceptorCalls: string[] = [];
httpService.registerHTTPInterceptor({
priority: 0,
interceptor: (request, next) => {
firstInterceptorCalls.push(request.url);
return next(request);
},
});
const firstSentRequestPromise = firstValueFrom(httpImplementation.newRequest$);
const firstRequest = httpService.get<{ step: number }>('http://example.com/first');
const firstSentRequest = await firstSentRequestPromise;
httpImplementation.getHandler(firstSentRequest.uid).emitResponse(new HTTPResponse({
headers: new HTTPHeaders(),
status: 200,
statusText: 'OK',
body: { step: 1 },
}));
await expect(firstRequest).resolves.toMatchObject({ body: { step: 1 } });
httpService.registerHTTPInterceptor({
priority: 50,
interceptor: (request, next) => {
secondInterceptorCalls.push(request.url);
request.headers.set('X-Second', 'enabled');
return next(request);
},
});
const secondSentRequestPromise = firstValueFrom(httpImplementation.newRequest$);
const secondRequest = httpService.get<{ step: number }>('http://example.com/second');
const secondSentRequest = await secondSentRequestPromise;
expect(firstInterceptorCalls).toEqual(['http://example.com/first', 'http://example.com/second']);
expect(secondInterceptorCalls).toEqual(['http://example.com/second']);
expect(secondSentRequest.headers.get('x-second')).toEqual(['enabled']);
httpImplementation.getHandler(secondSentRequest.uid).emitResponse(new HTTPResponse({
headers: new HTTPHeaders(),
status: 200,
statusText: 'OK',
body: { step: 2 },
}));
await expect(secondRequest).resolves.toMatchObject({ body: { step: 2 } });
});
it('streams progress events before the final response', async () => {
const sentRequestPromise = firstValueFrom(httpImplementation.newRequest$);
const eventsPromise = firstValueFrom(httpService.stream<string>('GET', 'http://example.com/sse'));
const sentRequest = await sentRequestPromise;
expect(sentRequest.responseType).toBe('json');
expect(sentRequest.requestParams?.reportProgress).toBe(true);
httpImplementation.getHandler(sentRequest.uid).emitResponse(new HTTPProgress(100, 40, 'partial'));
await expect(eventsPromise).resolves.toEqual(new HTTPProgress(100, 40, 'partial'));
});
});

View File

@@ -0,0 +1,200 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { ApplicationJSONType, HTTPHeaders } from '../headers';
import { HTTPParams } from '../params';
import { __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION, HTTPRequest } from '../request';
describe('test class HTTPRequest', () => {
beforeEach(() => {
__TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION();
});
it('should have incremental UIDs for multiple requests', () => {
const request1 = new HTTPRequest('GET', 'https://example.com');
const request2 = new HTTPRequest('GET', 'https://example.com');
expect(request1.uid).toBe(0);
expect(request2.uid).toBe(1);
});
describe('getUrlWithParams', () => {
it('should return original URL when no params', () => {
const request = new HTTPRequest('GET', 'https://example.com');
expect(request.getUrlWithParams()).toBe('https://example.com');
});
it('should append params with ? when URL has no query params', () => {
const params = new HTTPParams({ key: 'value' });
const request = new HTTPRequest('GET', 'https://example.com', {
headers: new HTTPHeaders(),
params,
responseType: 'json',
withCredentials: false,
});
expect(request.getUrlWithParams()).toBe('https://example.com?key=value');
});
it('should append params with & when URL already has query params', () => {
const params = new HTTPParams({ key2: 'value2' });
const request = new HTTPRequest('GET', 'https://example.com?key1=value1', {
headers: new HTTPHeaders(),
params,
responseType: 'json',
withCredentials: false,
});
expect(request.getUrlWithParams()).toBe('https://example.com?key1=value1&key2=value2');
});
});
describe('getBody', () => {
it('should return null for empty body', () => {
const request = new HTTPRequest('GET', 'https://example.com', {
headers: new HTTPHeaders(),
responseType: 'json',
withCredentials: false,
});
expect(request.getBody()).toBeNull();
});
it('should return FormData as is', () => {
const formData = new FormData();
const request = new HTTPRequest('POST', 'https://example.com', {
body: formData,
headers: new HTTPHeaders(),
responseType: 'json',
withCredentials: false,
});
expect(request.getBody()).toBe(formData);
});
it('should stringify object for JSON content type', () => {
const body = { name: 'test', value: 123 };
const headers = new HTTPHeaders({
'Content-Type': ApplicationJSONType,
});
const request = new HTTPRequest('POST', 'https://example.com', {
body,
headers,
responseType: 'json',
withCredentials: false,
});
expect(request.getBody()).toBe(JSON.stringify(body));
});
it('should convert non-object body to string', () => {
const request = new HTTPRequest('POST', 'https://example.com', {
body: 123,
headers: new HTTPHeaders(),
responseType: 'text',
withCredentials: false,
});
expect(request.getBody()).toBe('123');
});
it('should stringify array bodies with the default JSON content type', () => {
const body = [1, 2, 3];
const request = new HTTPRequest('POST', 'https://example.com', {
body,
headers: new HTTPHeaders(),
responseType: 'json',
withCredentials: false,
});
expect(request.getBody()).toBe(JSON.stringify(body));
});
it('should treat falsy primitive bodies as empty according to current behavior', () => {
const zeroRequest = new HTTPRequest('POST', 'https://example.com', {
body: 0,
headers: new HTTPHeaders(),
responseType: 'text',
withCredentials: false,
});
const falseRequest = new HTTPRequest('POST', 'https://example.com', {
body: false,
headers: new HTTPHeaders(),
responseType: 'text',
withCredentials: false,
});
const emptyRequest = new HTTPRequest('POST', 'https://example.com', {
body: '',
headers: new HTTPHeaders(),
responseType: 'text',
withCredentials: false,
});
expect(zeroRequest.getBody()).toBeNull();
expect(falseRequest.getBody()).toBeNull();
expect(emptyRequest.getBody()).toBeNull();
});
});
describe('getHeadersInit', () => {
it('should return headers from HTTPHeaders object', () => {
const headers = new HTTPHeaders({
'X-Custom-Header': 'test',
Authorization: 'Bearer token',
});
const request = new HTTPRequest('GET', 'https://example.com', {
headers,
responseType: 'json',
withCredentials: false,
});
const headersInit = request.getHeadersInit();
expect(headersInit).toHaveProperty('X-Custom-Header'.toLowerCase(), 'test');
expect(headersInit).toHaveProperty('Authorization'.toLowerCase(), 'Bearer token');
});
it('should set Content-Type to application/json for JSON body', () => {
const headers = new HTTPHeaders();
const request = new HTTPRequest('POST', 'https://example.com', {
headers,
body: { name: 'test' },
responseType: 'json',
withCredentials: false,
});
const headersInit = request.getHeadersInit();
expect(headersInit).toHaveProperty('content-type', 'application/json;charset=UTF-8');
});
it('should not set Content-Type for FormData body', () => {
const formData = new FormData();
const headers = new HTTPHeaders();
const request = new HTTPRequest('POST', 'https://example.com', {
headers,
body: formData,
responseType: 'json',
withCredentials: false,
});
const headersInit = request.getHeadersInit();
expect(headersInit).not.toHaveProperty('content-type');
});
});
});

View File

@@ -0,0 +1,110 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface IHeadersConstructorProps {
[key: string]: string | number | boolean;
}
// Header keys in `HTTPHeaders` should be in lower case.
export const ApplicationJSONType = 'application/json';
/**
* Check if the content type is application/json
* "application/json" or "application/json; charset=utf-8" or ["application/json"]
* @param contentType
*/
export function isApplicationJSONType(contentType: string | string[]): boolean {
if (Array.isArray(contentType)) {
return contentType.some((type) => type.includes(ApplicationJSONType));
}
return contentType.includes(ApplicationJSONType);
}
/**
* It wraps headers of HTTP requests' and responses' headers.
*/
export class HTTPHeaders {
private readonly _headers: Map<string, string[]> = new Map();
constructor(headers?: IHeadersConstructorProps | Headers | string) {
if (typeof headers === 'string') {
this._handleHeadersString(headers);
} else if (headers instanceof Headers) {
this._handleHeaders(headers);
} else if (headers) {
this._handleHeadersConstructorProps(headers);
}
}
forEach(callback: (name: string, value: string[]) => void): void {
this._headers.forEach((v, key) => callback(key, v));
}
has(key: string): boolean {
return !!this._headers.has(key.toLowerCase());
}
get(key: string): string[] | null {
const k = key.toLowerCase();
return this._headers.has(k) ? this._headers.get(k)! : null;
}
set(key: string, value: string | number | boolean): void {
this._setHeader(key, value);
}
toHeadersInit(body?: any): HeadersInit {
const headers: HeadersInit = {};
this._headers.forEach((values, key) => {
headers[key] = values.join(',');
});
headers.accept ??= 'application/json, text/plain, */*';
// If the request body is FormData, the browser will automatically generate Content Type with boundary
if (!(body instanceof FormData)) {
headers['content-type'] ??= 'application/json;charset=UTF-8';
}
return headers;
}
private _setHeader(name: string, value: string | number | boolean): void {
const lowerCase = name.toLowerCase();
if (this._headers.has(lowerCase)) {
this._headers.get(lowerCase)!.push(value.toString());
} else {
this._headers.set(lowerCase, [value.toString()]);
}
}
private _handleHeadersString(headers: string): void {
headers.split('\n').forEach((header) => {
const [name, value] = header.split(':');
if (name && value) {
this._setHeader(name, value);
}
});
}
private _handleHeadersConstructorProps(headers: IHeadersConstructorProps): void {
Object.entries(headers).forEach(([name, value]) => this._setHeader(name, value));
}
private _handleHeaders(headers: Headers): void {
headers.forEach((value, name) => this._setHeader(name, value));
}
}

View File

@@ -0,0 +1,211 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, Nullable } from '@univerjs/core';
import type { Observable } from 'rxjs';
import type { HTTPResponseType } from './http';
import type { HTTPHandlerFn, HTTPInterceptorFn, RequestPipe } from './interceptor';
import type { HTTPRequestMethod } from './request';
import type { HTTPEvent, HTTPResponse } from './response';
import { Disposable, remove, toDisposable } from '@univerjs/core';
import { firstValueFrom, of } from 'rxjs';
import { concatMap } from 'rxjs/operators';
import { HTTPHeaders } from './headers';
import { IHTTPImplementation } from './implementations/implementation';
import { HTTPParams } from './params';
import { HTTPRequest } from './request';
export interface IRequestParams {
/** Query params. These params would be append to the url before the request is sent. */
params?: { [param: string]: string | number | boolean };
/** Query headers. */
headers?: { [key: string]: string | number | boolean };
/** Expected types of the response data. */
responseType?: HTTPResponseType;
withCredentials?: boolean;
/**
* Should report progress.
*/
reportProgress?: boolean;
}
export interface IPostRequestParams extends IRequestParams {
body?: unknown;
}
/**
* Register an HTTP interceptor. Interceptor could modify requests, responses, headers or errors.
*/
export interface IHTTPInterceptor {
/** The priority of the interceptor. The higher the value, the earlier the interceptor is called. */
priority?: number;
/** The interceptor function. */
interceptor: HTTPInterceptorFn;
}
/**
* This service provides http request methods and allows to register http interceptors.
*
* You can use interceptors to:
*
* 1. modify requests (headers included) before they are sent, or modify responses
* before they are returned to the caller.
* 2. threshold, logging, caching, etc.
* 3. authentication, authorization, etc.
*/
export class HTTPService extends Disposable {
private _interceptors: IHTTPInterceptor[] = [];
// eslint-disable-next-line ts/no-explicit-any
private _pipe: Nullable<RequestPipe<any>>;
constructor(@IHTTPImplementation private readonly _http: IHTTPImplementation) {
super();
}
/**
* Register an HTTP interceptor.
*
* @param interceptor the http interceptor
* @returns a disposable handler to remove the interceptor
*/
registerHTTPInterceptor(interceptor: IHTTPInterceptor): IDisposable {
if (this._interceptors.indexOf(interceptor) !== -1) {
throw new Error('[HTTPService]: The interceptor has already been registered!');
}
this._interceptors.push(interceptor);
this._interceptors = this._interceptors.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
this._pipe = null;
return toDisposable(() => remove(this._interceptors, interceptor));
}
get<T>(url: string, params?: IRequestParams): Promise<HTTPResponse<T>> {
return this.request<T>('GET', url, params);
}
post<T>(url: string, params?: IPostRequestParams): Promise<HTTPResponse<T>> {
return this.request<T>('POST', url, params);
}
put<T>(url: string, params?: IPostRequestParams): Promise<HTTPResponse<T>> {
return this.request<T>('PUT', url, params);
}
delete<T>(url: string, params?: IRequestParams): Promise<HTTPResponse<T>> {
return this.request<T>('DELETE', url, params);
}
patch<T>(url: string, params?: IPostRequestParams): Promise<HTTPResponse<T>> {
return this.request<T>('PATCH', url, params);
}
/**
* The HTTP request implementations. Normally you should use the `get`, `post`, `put`, `delete`,
* `patch` methods instead.
* @param method HTTP request method, e.g. GET, POST, PUT, DELETE, etc.
* @param url The URL to send the request to.
* @param options Optional parameters for the request.
* @returns A promise that resolves to the HTTP response.
*/
async request<T>(
method: HTTPRequestMethod,
url: string,
options?: IRequestParams
): Promise<HTTPResponse<T>> {
// Things to do when sending a HTTP request:
// 1. Generate HTTPRequest/HTTPHeader object
// 2. Call interceptors and finally the HTTP implementation.
const headers = new HTTPHeaders(options?.headers);
const params = new HTTPParams(options?.params);
const request = new HTTPRequest(method, url, {
headers,
params,
withCredentials: options?.withCredentials ?? false, // default value for withCredentials is false by MDN
responseType: options?.responseType ?? 'json',
body: (['GET', 'DELETE'].includes(method)) ? undefined : (options as IPostRequestParams)?.body,
});
// eslint-disable-next-line ts/no-explicit-any
const events$: Observable<HTTPEvent<any>> = of(request).pipe(
concatMap((request) => this._runInterceptorsAndImplementation(request))
);
// The event$ may emit multiple values, but we only care about the first one.
// We may need to care about other events (especially progress events) in the future.
const result = await firstValueFrom(events$);
return result as HTTPResponse<T>;
}
/**
* Send an HTTP request. It returns an observable that emits HTTP events. For example, it can be used to
* send Server-Sent Events (SSE) requests.
* @param method HTTP request method, e.g. GET, POST, PUT, DELETE, etc.
* @param url The URL to send the request to.
* @param _params Optional parameters for the request.
* @returns An observable of the HTTP event.
*/
stream<T>(
method: HTTPRequestMethod,
url: string,
_params?: IPostRequestParams
): Observable<HTTPEvent<T>> {
// Things to do when sending a HTTP request:
// 1. Generate HTTPRequest/HTTPHeader object
// 2. Call interceptors and finally the HTTP implementation.
const headers = new HTTPHeaders(_params?.headers);
const params = new HTTPParams(_params?.params);
const request = new HTTPRequest(method, url, {
headers,
params,
withCredentials: _params?.withCredentials ?? false,
reportProgress: true,
responseType: _params?.responseType ?? 'json',
body: (['GET', 'DELETE'].includes(method)) ? undefined : _params?.body,
});
return of(request).pipe(concatMap((request) => this._runInterceptorsAndImplementation(request)));
}
// eslint-disable-next-line ts/no-explicit-any
private _runInterceptorsAndImplementation(request: HTTPRequest): Observable<HTTPEvent<any>> {
// In this method we first call all interceptors and finally the HTTP implementation.
// And the HTTP response will be passed back through the interceptor chain.
if (!this._pipe) {
this._pipe = this._interceptors
.map((handler) => handler.interceptor)
.reduceRight(
(nextHandlerFunction, interceptorFunction: HTTPInterceptorFn) =>
chainInterceptorFn(nextHandlerFunction, interceptorFunction),
(requestFromPrevInterceptor, finalHandler) => finalHandler(requestFromPrevInterceptor)
);
}
return this._pipe!(request, (requestToNext) => this._http.send(requestToNext) /* final handler */);
}
}
function chainInterceptorFn(afterInterceptorChain: HTTPInterceptorFn, currentInterceptorFn: HTTPInterceptorFn) {
return (prevRequest: HTTPRequest, nextHandlerFn: HTTPHandlerFn) =>
currentInterceptorFn(prevRequest, (nextRequest) => afterInterceptorChain(nextRequest, nextHandlerFn));
}

View File

@@ -0,0 +1,96 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type HTTPResponseType = 'arraybuffer' | 'blob' | 'json' | 'text';
export const SuccessStatusCodeLowerBound = 200;
export const ErrorStatusCodeLowerBound = 300;
/**
* Http status codes.
*
* https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
*/
export enum HTTPStatusCode {
Continue = 100,
SwitchingProtocols = 101,
Processing = 102,
EarlyHints = 103,
Ok = 200,
Created = 201,
Accepted = 202,
NonAuthoritativeInformation = 203,
NoContent = 204,
ResetContent = 205,
PartialContent = 206,
MultiStatus = 207,
AlreadyReported = 208,
ImUsed = 226,
MultipleChoices = 300,
MovedPermanently = 301,
Found = 302,
SeeOther = 303,
NotModified = 304,
UseProxy = 305,
Unused = 306,
TemporaryRedirect = 307,
PermanentRedirect = 308,
BadRequest = 400,
Unauthorized = 401,
PaymentRequired = 402,
Forbidden = 403,
NotFound = 404,
MethodNotAllowed = 405,
NotAcceptable = 406,
ProxyAuthenticationRequired = 407,
RequestTimeout = 408,
Conflict = 409,
Gone = 410,
LengthRequired = 411,
PreconditionFailed = 412,
PayloadTooLarge = 413,
UriTooLong = 414,
UnsupportedMediaType = 415,
RangeNotSatisfiable = 416,
ExpectationFailed = 417,
ImATeapot = 418,
MisdirectedRequest = 421,
UnprocessableEntity = 422,
Locked = 423,
FailedDependency = 424,
TooEarly = 425,
UpgradeRequired = 426,
PreconditionRequired = 428,
TooManyRequests = 429,
RequestHeaderFieldsTooLarge = 431,
UnavailableForLegalReasons = 451,
InternalServerError = 500,
NotImplemented = 501,
BadGateway = 502,
ServiceUnavailable = 503,
GatewayTimeout = 504,
HttpVersionNotSupported = 505,
VariantAlsoNegotiates = 506,
InsufficientStorage = 507,
LoopDetected = 508,
NotExtended = 510,
NetworkAuthenticationRequired = 511,
}

View File

@@ -0,0 +1,200 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HTTPHeaders } from '../../headers';
import { HTTPRequest } from '../../request';
import { FetchHTTPImplementation } from '../fetch';
afterEach(() => {
vi.restoreAllMocks();
});
describe('FetchHTTPImplementation', () => {
function createRequest(responseType: string, extra: Record<string, unknown> = {}) {
return new HTTPRequest('GET', 'https://example.com', {
responseType,
headers: new HTTPHeaders(),
withCredentials: false,
...extra,
} as any);
}
it('should emit HTTPResponse for json response', async () => {
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(JSON.stringify({ ok: true })));
controller.close();
},
});
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, {
status: 200,
headers: {
'content-type': 'application/json',
'content-length': '12',
},
})));
const impl = new FetchHTTPImplementation({ debug: vi.fn(), error: vi.fn() } as any);
const req = new HTTPRequest('GET', 'https://example.com', {
responseType: 'json',
headers: new HTTPHeaders(),
withCredentials: false,
} as any);
const result = await new Promise<any>((resolve, reject) => {
impl.send(req).subscribe({
next: (e) => resolve(e),
error: reject,
});
});
expect(result.status).toBe(200);
expect(result.body).toEqual({ ok: true });
});
it('should emit HTTPProgress when reportProgress enabled for text', async () => {
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('a'));
controller.enqueue(new TextEncoder().encode('b'));
controller.close();
},
});
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, {
status: 200,
headers: { 'content-type': 'text/plain', 'content-length': '2' },
})));
const impl = new FetchHTTPImplementation({ debug: vi.fn(), error: vi.fn() } as any);
const req = new HTTPRequest('GET', 'https://example.com', {
responseType: 'text',
reportProgress: true,
headers: new HTTPHeaders(),
withCredentials: false,
} as any);
const events: any[] = [];
await new Promise<void>((resolve, reject) => {
impl.send(req).subscribe({
next: (e) => events.push(e),
error: reject,
complete: () => resolve(),
});
});
expect(events.some((e) => e.type === 0)).toBe(true);
expect(events.at(-1).body).toBe('ab');
});
it('should deserialize text, blob, and arraybuffer bodies', async () => {
const impl = new FetchHTTPImplementation({ debug: vi.fn(), error: vi.fn() } as any);
vi.stubGlobal('fetch', vi.fn(async () => new Response('plain', {
status: 200,
headers: { 'content-type': 'text/plain' },
})));
await expect(new Promise<any>((resolve, reject) => {
impl.send(createRequest('text')).subscribe({ next: resolve, error: reject });
})).resolves.toMatchObject({ body: 'plain' });
vi.stubGlobal('fetch', vi.fn(async () => new Response('blob-body', {
status: 200,
headers: { 'content-type': 'text/plain' },
})));
const blobResponse = await new Promise<any>((resolve, reject) => {
impl.send(createRequest('blob')).subscribe({ next: resolve, error: reject });
});
expect(blobResponse.body).toBeInstanceOf(Blob);
expect(await blobResponse.body.text()).toBe('blob-body');
vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { 'content-type': 'application/octet-stream' },
})));
const arrayBufferResponse = await new Promise<any>((resolve, reject) => {
impl.send(createRequest('arraybuffer')).subscribe({ next: resolve, error: reject });
});
expect(Array.from(new Uint8Array(arrayBufferResponse.body))).toEqual([1, 2, 3]);
});
it('should emit a null body when the response has no stream', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204, statusText: 'No Content' })));
const impl = new FetchHTTPImplementation({ debug: vi.fn(), error: vi.fn() } as any);
await expect(new Promise<any>((resolve, reject) => {
impl.send(createRequest('json')).subscribe({ next: resolve, error: reject });
})).resolves.toMatchObject({ status: 204, statusText: 'No Content', body: null });
});
it('should emit HTTPResponseError for failed status, bad JSON, unknown response type, and fetch rejection', async () => {
const logService = { debug: vi.fn(), error: vi.fn() };
const impl = new FetchHTTPImplementation(logService as any);
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ message: 'bad' }), {
status: 500,
statusText: 'Server Error',
headers: { 'content-type': 'application/json' },
})));
await expect(new Promise((resolve, reject) => {
impl.send(createRequest('json')).subscribe({ next: resolve, error: reject });
})).rejects.toMatchObject({ status: 500, statusText: 'Server Error' });
vi.stubGlobal('fetch', vi.fn(async () => new Response('{bad', {
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/json' },
})));
await expect(new Promise((resolve, reject) => {
impl.send(createRequest('json')).subscribe({ next: resolve, error: reject });
})).rejects.toMatchObject({ status: 200, statusText: 'OK' });
vi.stubGlobal('fetch', vi.fn(async () => new Response('body', {
status: 200,
statusText: 'OK',
})));
await expect(new Promise((resolve, reject) => {
impl.send(createRequest('document')).subscribe({ next: resolve, error: reject });
})).rejects.toMatchObject({ status: 200, statusText: 'OK' });
vi.stubGlobal('fetch', vi.fn(async () => {
throw Object.assign(new Error('offline'), { status: 0, statusText: 'Offline' });
}));
await expect(new Promise((resolve, reject) => {
impl.send(createRequest('json')).subscribe({ next: resolve, error: reject });
})).rejects.toMatchObject({ status: 0, statusText: 'Offline' });
expect(logService.error).toHaveBeenCalled();
});
it('should abort the fetch request when unsubscribed', () => {
let capturedSignal: AbortSignal | undefined;
vi.stubGlobal('fetch', vi.fn((_url: string, init: RequestInit) => {
capturedSignal = init.signal as AbortSignal;
return new Promise(() => {});
}));
const impl = new FetchHTTPImplementation({ debug: vi.fn(), error: vi.fn() } as any);
const subscription = impl.send(createRequest('json')).subscribe();
expect(capturedSignal?.aborted).toBe(false);
subscription.unsubscribe();
expect(capturedSignal?.aborted).toBe(true);
});
});

View File

@@ -0,0 +1,201 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HTTPHeaders } from '../../headers';
import { HTTPParams } from '../../params';
import { HTTPRequest } from '../../request';
import { XHRHTTPImplementation } from '../xhr';
type Listener = (event: ProgressEvent) => void;
const xhrInstances: FakeXMLHttpRequest[] = [];
class FakeXMLHttpRequest {
DONE = 4;
readyState = 1;
status = 200;
statusText = 'OK';
responseType = '';
response: unknown;
responseText = '';
withCredentials = false;
method = '';
url = '';
sentBody: unknown;
aborted = false;
headers = new Map<string, string>();
removed: Array<[string, Listener]> = [];
private _listeners = new Map<string, Listener[]>();
constructor() {
xhrInstances.push(this);
}
open(method: string, url: string): void {
this.method = method;
this.url = url;
}
setRequestHeader(key: string, value: string): void {
this.headers.set(key, value);
}
getAllResponseHeaders(): string {
return 'Content-Type: application/json\nX-Trace: trace-1';
}
addEventListener(type: string, listener: Listener): void {
const listeners = this._listeners.get(type) ?? [];
listeners.push(listener);
this._listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener): void {
this.removed.push([type, listener]);
this._listeners.set(type, (this._listeners.get(type) ?? []).filter((item) => item !== listener));
}
send(body: unknown): void {
this.sentBody = body;
}
abort(): void {
this.aborted = true;
this.readyState = this.DONE;
this.emit('abort');
}
emit(type: string): void {
(this._listeners.get(type) ?? []).forEach((listener) => listener(new ProgressEvent(type)));
}
}
function createRequest(responseType: string, extra: Record<string, unknown> = {}) {
return new HTTPRequest('POST', 'https://example.com/api', {
responseType,
withCredentials: true,
headers: new HTTPHeaders({ 'X-Test': 'yes' }),
params: new HTTPParams({ q: 'search' }),
body: { ok: true },
...extra,
} as any);
}
afterEach(() => {
vi.restoreAllMocks();
xhrInstances.length = 0;
});
describe('XHRHTTPImplementation', () => {
it('sends requests with headers, credentials and JSON body, then parses JSON responses', async () => {
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest as never);
const impl = new XHRHTTPImplementation({ debug: vi.fn(), error: vi.fn() } as any);
const events: any[] = [];
const promise = new Promise<void>((resolve, reject) => {
impl.send(createRequest('json')).subscribe({
next: (event) => {
events.push(event);
resolve();
},
error: reject,
});
});
const xhr = xhrInstances[0];
xhr.responseText = '{"ok":true}';
xhr.emit('load');
await promise;
expect(xhr.method).toBe('POST');
expect(xhr.url).toBe('https://example.com/api?q=search');
expect(xhr.withCredentials).toBe(true);
expect(xhr.headers.get('x-test')).toBe('yes');
expect(xhr.headers.get('content-type')).toBe('application/json;charset=UTF-8');
expect(xhr.sentBody).toBe('{"ok":true}');
expect(events.at(-1).body).toEqual({ ok: true });
});
it('returns null for no-content responses and raw text for text responses', async () => {
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest as never);
const impl = new XHRHTTPImplementation({ debug: vi.fn(), error: vi.fn() } as any);
const noContentPromise = new Promise<any>((resolve, reject) => {
impl.send(createRequest('json')).subscribe({ next: resolve, error: reject });
});
xhrInstances[0].status = 204;
xhrInstances[0].responseText = '{"ignored":true}';
xhrInstances[0].emit('load');
await expect(noContentPromise).resolves.toMatchObject({ body: null });
const textPromise = new Promise<any>((resolve, reject) => {
impl.send(createRequest('text')).subscribe({ next: resolve, error: reject });
});
xhrInstances[1].responseText = 'plain';
xhrInstances[1].emit('load');
await expect(textPromise).resolves.toMatchObject({ body: 'plain' });
});
it('emits response errors for status errors, invalid JSON, blob mismatches, and network failures', async () => {
const logService = { debug: vi.fn(), error: vi.fn() };
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest as never);
const impl = new XHRHTTPImplementation(logService as any);
const statusError = new Promise((resolve, reject) => {
impl.send(createRequest('json')).subscribe({ next: resolve, error: reject });
});
xhrInstances[0].status = 500;
xhrInstances[0].statusText = 'Server Error';
xhrInstances[0].responseText = '{"ok":false}';
xhrInstances[0].emit('load');
await expect(statusError).rejects.toMatchObject({ status: 500, statusText: 'Server Error' });
const jsonError = new Promise((resolve, reject) => {
impl.send(createRequest('json')).subscribe({ next: resolve, error: reject });
});
xhrInstances[1].responseText = '{bad';
xhrInstances[1].emit('load');
await expect(jsonError).rejects.toMatchObject({ status: 200, statusText: 'OK' });
const blobError = new Promise((resolve, reject) => {
impl.send(createRequest('blob')).subscribe({ next: resolve, error: reject });
});
xhrInstances[2].response = 'not-a-blob';
xhrInstances[2].emit('load');
await expect(blobError).rejects.toMatchObject({ status: 200, statusText: 'OK' });
const networkError = new Promise((resolve, reject) => {
impl.send(createRequest('json')).subscribe({ next: resolve, error: reject });
});
xhrInstances[3].status = 0;
xhrInstances[3].statusText = '';
xhrInstances[3].emit('error');
await expect(networkError).rejects.toMatchObject({ status: 0, statusText: 'Unknown Error' });
expect(logService.error).toHaveBeenCalled();
});
it('aborts pending requests and removes event listeners when unsubscribed', () => {
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest as never);
const impl = new XHRHTTPImplementation({ debug: vi.fn(), error: vi.fn() } as any);
const subscription = impl.send(createRequest('json')).subscribe({ error: () => undefined });
const xhr = xhrInstances[0];
subscription.unsubscribe();
expect(xhr.aborted).toBe(true);
expect(xhr.removed.map(([type]) => type)).toEqual(['load', 'error', 'abort', 'timeout']);
});
});

View File

@@ -0,0 +1,202 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable ts/no-explicit-any */
/* eslint-disable no-case-declarations */
import type { Subscriber } from 'rxjs';
import type { HTTPRequest } from '../request';
import type { HTTPEvent, HTTPResponseBody } from '../response';
import type { IHTTPImplementation } from './implementation';
import { ILogService } from '@univerjs/core';
import { Observable } from 'rxjs';
import { HTTPHeaders } from '../headers';
import { HTTPStatusCode } from '../http';
import { HTTPProgress, HTTPResponse, HTTPResponseError } from '../response';
import { parseFetchParamsFromRequest } from './util';
/**
* An HTTP implementation using Fetch API. This implementation can both run in browser and Node.js.
*
* It does not support streaming response yet (May 12, 2024).
*/
export class FetchHTTPImplementation implements IHTTPImplementation {
constructor(
@ILogService private readonly _logService: ILogService
) { }
send(request: HTTPRequest): Observable<HTTPEvent<any>> {
return new Observable((subscriber) => {
const abortController = new AbortController();
this._send(request, subscriber, abortController).catch((error) => {
subscriber.error(new HTTPResponseError({
error,
request,
}));
});
return () => abortController.abort();
});
}
private async _send(request: HTTPRequest, subscriber: Subscriber<HTTPEvent<any>>, abortController: AbortController) {
let response: Response;
try {
const fetchParams = parseFetchParamsFromRequest(request);
const urlWithParams = request.getUrlWithParams();
const fetchPromise = fetch(urlWithParams, {
signal: abortController.signal,
...fetchParams,
});
this._logService.debug(`[FetchHTTPImplementation]: sending request to url ${urlWithParams} with params ${fetchParams}`);
response = await fetchPromise;
} catch (error: any) {
const e = new HTTPResponseError({
request,
error,
status: error.status ?? 0,
statusText: error.statusText ?? 'Unknown Error',
headers: error.headers,
});
this._logService.error('[FetchHTTPImplementation]: network error', e);
subscriber.error(e);
return;
}
const responseHeaders = new HTTPHeaders(response.headers);
const status = response.status;
const statusText = response.statusText;
let body: HTTPResponseBody = null;
if (response.body) {
body = await this._readBody(request, response, subscriber);
}
const ok = status >= HTTPStatusCode.Ok && status < HTTPStatusCode.MultipleChoices;
if (ok) {
subscriber.next(new HTTPResponse({
body,
headers: responseHeaders,
status,
statusText,
}));
} else {
const e = new HTTPResponseError({
request,
error: body,
status,
statusText,
headers: responseHeaders,
});
this._logService.error('[FetchHTTPImplementation]: network error', e);
subscriber.error(e);
}
subscriber.complete();
}
private async _readBody(
request: HTTPRequest,
response: Response,
subscriber: Subscriber<HTTPEvent<any>>
): Promise<HTTPResponseBody> {
const chunks: Uint8Array[] = [];
const reader = response.body!.getReader();
const contentLength = response.headers.get('content-length');
let receivedLength = 0;
const reportProgress = request.requestParams?.reportProgress;
const responseType = request.responseType;
let partialText: string | undefined;
let decoder: TextDecoder;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
if (reportProgress && responseType === 'text') {
partialText = (partialText ?? '') + (decoder ??= new TextDecoder()).decode(value, { stream: true });
subscriber.next(new HTTPProgress(
contentLength ? Number.parseInt(contentLength, 10) : undefined,
receivedLength,
partialText
));
}
}
const all = mergeChunks(chunks, receivedLength);
try {
const contentType = response.headers.get('content-type') ?? '';
const body = deserialize(request, all, contentType);
return body;
} catch (error) {
const e = new HTTPResponseError({
request,
error,
status: response.status,
statusText: response.statusText,
headers: new HTTPHeaders(response.headers),
});
this._logService.error('[FetchHTTPImplementation]: network error', e);
subscriber.error(e);
return null;
}
}
}
function mergeChunks(chunks: Uint8Array[], totalLength: number): Uint8Array {
const all = new Uint8Array(totalLength);
let position = 0;
for (const chunk of chunks) {
all.set(chunk, position);
position += chunk.length;
}
return all;
}
const XSSI_PREFIX = /^\)\]\}',?\n/;
function deserialize(request: HTTPRequest, bin: Uint8Array, contentType: string): HTTPResponseBody {
switch (request.responseType) {
case 'json':
const text = new TextDecoder().decode(bin).replace(XSSI_PREFIX, '');
return text === '' ? null : JSON.parse(text);
case 'text':
return new TextDecoder().decode(bin);
case 'blob':
return new Blob([bin.buffer as ArrayBuffer], { type: contentType });
case 'arraybuffer':
return bin.buffer;
default:
throw new Error(`[FetchHTTPImplementation]: unknown response type: ${request.responseType}.`);
}
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Observable } from 'rxjs';
import type { HTTPRequest } from '../request';
import type { HTTPEvent } from '../response';
import { createIdentifier } from '@univerjs/core';
/**
* HTTP service could be implemented differently on platforms.
*/
export interface IHTTPImplementation {
/**
* Send a request. The result would be returned in an observable for possible stream response.
* @param request the request to be sent
*/
// eslint-disable-next-line ts/no-explicit-any
send(request: HTTPRequest): Observable<HTTPEvent<any>>;
}
export const IHTTPImplementation = createIdentifier<IHTTPImplementation>('network.http-implementation');

View File

@@ -0,0 +1,28 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HTTPRequest } from '../request';
export function parseFetchParamsFromRequest(request: HTTPRequest): RequestInit {
const fetchParams: RequestInit = {
method: request.method,
headers: request.getHeadersInit(),
body: request.getBody(),
credentials: request.withCredentials ? 'include' : undefined,
};
return fetchParams;
}

View File

@@ -0,0 +1,167 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-lines-per-function */
/* eslint-disable ts/no-explicit-any */
import type { Nullable } from '@univerjs/core';
import type { Observer } from 'rxjs';
import type { HTTPRequest } from '../request';
import type { HTTPEvent } from '../response';
import type { IHTTPImplementation } from './implementation';
import { ILogService } from '@univerjs/core';
import { Observable } from 'rxjs';
import { HTTPHeaders } from '../headers';
import { ErrorStatusCodeLowerBound, HTTPStatusCode, SuccessStatusCodeLowerBound } from '../http';
import { HTTPResponse, HTTPResponseError, ResponseHeader } from '../response';
import { parseFetchParamsFromRequest } from './util';
/**
* An HTTP implementation using XHR. HTTP service provided by this service could only be async (we do not support sync XHR now).
*/
export class XHRHTTPImplementation implements IHTTPImplementation {
constructor(
@ILogService private readonly _logService: ILogService
) { }
send(request: HTTPRequest): Observable<HTTPEvent<any>> {
return new Observable((observer: Observer<HTTPEvent<any>>) => {
const xhr = new XMLHttpRequest();
const urlWithParams = request.getUrlWithParams();
const fetchParams = parseFetchParamsFromRequest(request);
const { responseType } = request;
xhr.open(request.method, urlWithParams);
if (request.withCredentials) {
xhr.withCredentials = true;
}
if (fetchParams.headers) {
Object.entries(fetchParams.headers).forEach(([key, value]) => xhr.setRequestHeader(key, value));
}
let responseHeader: Nullable<ResponseHeader>;
const buildResponseHeader = () => {
if (responseHeader) {
return responseHeader;
}
const statusText = xhr.statusText || 'OK';
const headers = new HTTPHeaders(xhr.getAllResponseHeaders());
return new ResponseHeader(headers, xhr.status, statusText);
};
const onLoadHandler = () => {
const { headers, statusText, status } = buildResponseHeader();
let body: any = null;
let error: any = null;
if (status !== HTTPStatusCode.NoContent) {
body = typeof xhr.response === 'undefined' ? xhr.responseText : xhr.response;
}
let success = status >= SuccessStatusCodeLowerBound && status < ErrorStatusCodeLowerBound;
// Parse response body according to request's `responseType`.
// However we will not verify whether the response type is
// the same as the request's `responseType`. We will do that
// in the `HTTPService` class because the verification should be
// the same across different implementations.
if (responseType === 'json' && typeof body === 'string') {
const originalBody = body;
try {
body = body ? JSON.parse(body) : null;
} catch (e) {
// make success as false to emit a HTTPResponseError
success = false;
// revert our change to the original body
body = originalBody;
error = e;
}
}
if (responseType === 'blob' && !(body instanceof Blob)) {
// If the responseType is blob, we should convert the response to a Blob object.
// If the response is not a Blob object, we will make success as false to emit a HTTPResponseError.
success = false;
error = new Error('Response is not a Blob object');
}
if (success) {
observer.next(
new HTTPResponse({
body,
headers,
status,
statusText,
})
);
} else {
const e = new HTTPResponseError({
request,
error,
headers,
status,
statusText,
});
this._logService.error('[XHRHTTPImplementation]: network error', e);
observer.error(e);
}
};
const onErrorHandler = (error: ProgressEvent) => {
const e = new HTTPResponseError({
request,
error,
status: xhr.status || 0,
statusText: xhr.statusText || 'Unknown Error',
headers: buildResponseHeader().headers,
});
this._logService.error('[XHRHTTPImplementation]: network error', e);
observer.error(e);
};
xhr.responseType = responseType || '';
xhr.addEventListener('load', onLoadHandler);
xhr.addEventListener('error', onErrorHandler);
xhr.addEventListener('abort', onErrorHandler);
xhr.addEventListener('timeout', onErrorHandler);
const body = request.getBody();
xhr.send(body);
this._logService.debug('[XHRHTTPImplementation]', `sending request to url ${urlWithParams} with params ${fetchParams}`);
// Abort the request if the subscription is disposed before the request completes.
return () => {
if (xhr.readyState !== xhr.DONE) {
xhr.abort();
}
xhr.removeEventListener('load', onLoadHandler);
xhr.removeEventListener('error', onErrorHandler);
xhr.removeEventListener('abort', onErrorHandler);
xhr.removeEventListener('timeout', onErrorHandler);
};
});
}
}

View File

@@ -0,0 +1,47 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Observable } from 'rxjs';
import type { HTTPRequest } from './request';
import type { HTTPEvent } from './response';
export type HTTPHandlerFn = (request: HTTPRequest) => Observable<HTTPEvent<unknown>>;
/**
* HTTP interceptor function. When the interceptor is called, it would receive a request object and a next function.
*
* @param request The `request` could be been modified by interceptors whose priority is higher than the current interceptor.
*
* @param next When the interceptor decides to pass the request to the next interceptor, it should call the `next` function with
* the request.
* The `next` function would return an `Observable`. The current interceptor should subscribe to it and emit the messages
* from next interceptor to the previous interceptor.
*
* @returns An `Observable` that emits the response of the request. It would be subscribed by the previous interceptor.
*
* An interceptor the logs the request and response would look like this:
* @example
* function logInterceptor(request: HTTPRequest, next: HTTPHandlerFn): Observable<HTTPEvent<unknown>> {
* console.log('Request:', request);
* return next(request).pipe(cat(response => console.log('Response:', response)));
* }
*/
export type HTTPInterceptorFn = (request: HTTPRequest, next: HTTPHandlerFn) => Observable<HTTPEvent<unknown>>;
export type RequestPipe<T> = (req: HTTPRequest, finalHandlerFn: HTTPHandlerFn) => Observable<HTTPEvent<T>>;
export type HTTPInterceptorFnFactory<T extends unknown[] = []> = (...args: T) => HTTPInterceptorFn;

View File

@@ -0,0 +1,136 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector } from '@univerjs/core';
import type { MockHTTPImplementation } from '../../__tests__/http-testing-utils';
import { afterEach, beforeEach, describe, expect, it, vitest } from 'vitest';
import { createHTTPTestBed } from '../../__tests__/http-testing-utils';
import { HTTPHeaders } from '../../headers';
import { HTTPService } from '../../http.service';
import { IHTTPImplementation } from '../../implementations/implementation';
import { __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION } from '../../request';
import { HTTPResponse, HTTPResponseError } from '../../response';
import { AuthInterceptorFactory } from '../auth-interceptor';
describe('test "HTTPAuthInterceptor"', () => {
let httpService: HTTPService;
let httpImplementation: MockHTTPImplementation;
let injector: Injector;
beforeEach(() => {
injector = createHTTPTestBed().injector;
httpService = injector.get(HTTPService);
httpImplementation = injector.get(IHTTPImplementation) as MockHTTPImplementation;
vitest.useFakeTimers();
});
afterEach(() => {
injector.dispose();
__TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION();
vitest.useRealTimers();
});
function emitError(uid: number, errorCode: number) {
httpImplementation.getHandler(uid).emitError(new HTTPResponseError({
// eslint-disable-next-line ts/no-explicit-any
request: {} as any,
headers: new HTTPHeaders(),
status: errorCode,
statusText: 'Internal Server Error',
error: new Error('mocked error'),
}));
}
function emitSuccess(uid: number) {
httpImplementation.getHandler(uid).emitResponse(new HTTPResponse({
headers: new HTTPHeaders(),
status: 200,
statusText: 'Request Succeeded',
body: {
text: 'Succeeded',
},
}));
}
it('should pass though on success', () => new Promise<void>((done) => {
let error = false;
httpService.registerHTTPInterceptor({
priority: 10,
interceptor: AuthInterceptorFactory({
errorStatusCodes: [401, 403],
onAuthError: () => {
error = true;
},
}),
});
const request = httpService.get('http://example.com');
request.then(() => {
expect(error).toBeFalsy();
done();
});
emitSuccess(0);
}));
it('should pass though on other kind of errors', () => new Promise<void>((done) => {
let error = false;
httpService.registerHTTPInterceptor({
priority: 10,
interceptor: AuthInterceptorFactory({
errorStatusCodes: [401, 403],
onAuthError: () => {
error = true;
},
}),
});
const request = httpService.get('http://example.com');
request.catch(() => {
expect(error).toBeFalsy();
done();
});
emitError(0, 500);
}));
it('should call "onAuthError" on error status codes', () => new Promise<void>((done) => {
let error = false;
httpService.registerHTTPInterceptor({
priority: 10,
interceptor: AuthInterceptorFactory({
errorStatusCodes: [401, 403],
onAuthError: () => {
error = true;
},
}),
});
const request = httpService.get('http://example.com');
request.catch(() => {
expect(error).toBeTruthy();
done();
});
emitError(0, 401);
}));
});

View File

@@ -0,0 +1,177 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector } from '@univerjs/core';
import type { MockHTTPImplementation } from '../../__tests__/http-testing-utils';
import type { HTTPHandlerFn } from '../../interceptor';
import type { HTTPEvent } from '../../response';
import { Observable } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vitest } from 'vitest';
import { createHTTPTestBed } from '../../__tests__/http-testing-utils';
import { HTTPHeaders } from '../../headers';
import { HTTPService } from '../../http.service';
import { IHTTPImplementation } from '../../implementations/implementation';
import { __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION, HTTPRequest } from '../../request';
import { HTTPResponse } from '../../response';
import { MergeInterceptorFactory } from '../merge-interceptor';
describe('test "HTTPMergeInterceptor"', () => {
let httpService: HTTPService;
let httpImplementation: MockHTTPImplementation;
let injector: Injector;
beforeEach(() => {
injector = createHTTPTestBed().injector;
httpService = injector.get(HTTPService);
httpImplementation = injector.get(IHTTPImplementation) as MockHTTPImplementation;
vitest.useFakeTimers();
});
afterEach(() => {
injector.dispose();
__TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION();
vitest.useRealTimers();
});
function emitSuccess(uid: number, body: any) {
httpImplementation.getHandler(uid).emitResponse(new HTTPResponse({
headers: new HTTPHeaders(),
status: 200,
statusText: 'Request Succeeded',
body,
}));
}
it('two requests were created, but only one was a real request', async () => {
const path = 'http://example.com';
interface IRequest { ids: string[] };
interface IResponse { list: number[] };
const response: IResponse = { list: [1, 2] };
httpService.registerHTTPInterceptor({
priority: 999,
interceptor: MergeInterceptorFactory<IRequest, IResponse>({
isMatch(config) {
return config.url === path;
},
getParamsFromRequest(config) {
const body = config.requestParams?.body as { ids: string[] };
return body;
},
mergeParamsToRequest(list, currentConfig) {
const ids = list.reduce((a, b) => {
a.push(...b.ids);
return a;
}, [] as string[]);
return new HTTPRequest(currentConfig.method, currentConfig.url, {
headers: currentConfig.headers,
responseType: currentConfig.responseType,
withCredentials: currentConfig.withCredentials,
body: { ids },
});
},
}),
});
const request1 = httpService.post<IResponse>(path, { body: { ids: [1] } });
const request2 = httpService.post<IResponse>(path, { body: { ids: [2] } });
request1.then((e) => {
expect(e.body.list).toEqual(response.list);
});
request2.then((e) => {
expect(e.body.list).toEqual(response.list);
});
await vitest.advanceTimersByTimeAsync(1200);
// The first request created does not result in a real request
expect(() => httpImplementation.getHandler(0)).toThrowError();
expect(() => httpImplementation.getHandler(1)).toThrowError();
// The first two create requests and the last merge result in a new request, so the sequence number is 2
emitSuccess(2, response);
});
it('does not send a queued request after its subscriber unsubscribes', async () => {
let resolveFetch!: (value: boolean) => void;
const next: HTTPHandlerFn = vitest.fn(() => new Observable<HTTPEvent<unknown>>());
const interceptor = MergeInterceptorFactory<string, unknown>({
isMatch: () => true,
getParamsFromRequest: (request) => request.url,
mergeParamsToRequest: (_list, request) => request,
}, {
fetchCheck: () => new Promise((resolve) => {
resolveFetch = resolve;
}),
});
const request = new HTTPRequest('GET', 'http://example.com');
const subscription = interceptor(request, next).subscribe();
subscription.unsubscribe();
resolveFetch(true);
await Promise.resolve();
expect(next).not.toHaveBeenCalled();
});
it('cancels an active merged request when its subscriber unsubscribes', async () => {
const teardown = vitest.fn();
const next: HTTPHandlerFn = vitest.fn(() => new Observable<HTTPEvent<unknown>>(() => teardown));
const interceptor = MergeInterceptorFactory<string, unknown>({
isMatch: () => true,
getParamsFromRequest: (request) => request.url,
mergeParamsToRequest: (_list, request) => request,
}, {
fetchCheck: () => Promise.resolve(true),
});
const request = new HTTPRequest('GET', 'http://example.com');
const subscription = interceptor(request, next).subscribe();
await Promise.resolve();
expect(next).toHaveBeenCalledOnce();
subscription.unsubscribe();
expect(teardown).toHaveBeenCalledOnce();
});
it('keeps an active merged request until every subscriber unsubscribes', async () => {
const teardown = vitest.fn();
const next: HTTPHandlerFn = vitest.fn(() => new Observable<HTTPEvent<unknown>>(() => teardown));
const fetchCheck = vitest.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const interceptor = MergeInterceptorFactory<string, unknown>({
isMatch: () => true,
getParamsFromRequest: (request) => request.url,
mergeParamsToRequest: (_list, request) => request,
}, { fetchCheck });
const firstSubscription = interceptor(new HTTPRequest('GET', 'http://example.com/first'), next).subscribe();
const secondSubscription = interceptor(new HTTPRequest('GET', 'http://example.com/second'), next).subscribe();
await Promise.resolve();
expect(next).toHaveBeenCalledOnce();
firstSubscription.unsubscribe();
expect(teardown).not.toHaveBeenCalled();
secondSubscription.unsubscribe();
expect(teardown).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,118 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector } from '@univerjs/core';
import type { MockHTTPImplementation } from '../../__tests__/http-testing-utils';
import { afterEach, beforeEach, describe, expect, it, vitest } from 'vitest';
import { createHTTPTestBed } from '../../__tests__/http-testing-utils';
import { HTTPHeaders } from '../../headers';
import { HTTPService } from '../../http.service';
import { IHTTPImplementation } from '../../implementations/implementation';
import { __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION } from '../../request';
import { HTTPResponse, HTTPResponseError } from '../../response';
import { RetryInterceptorFactory } from '../retry-interceptor';
describe('test "HTTPRetryInterceptor"', () => {
let httpService: HTTPService;
let httpImplementation: MockHTTPImplementation;
let injector: Injector;
beforeEach(() => {
injector = createHTTPTestBed().injector;
httpService = injector.get(HTTPService);
httpImplementation = injector.get(IHTTPImplementation) as MockHTTPImplementation;
vitest.useFakeTimers();
});
afterEach(() => {
injector.dispose();
__TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION();
vitest.useRealTimers();
});
function emitError(uid: number) {
httpImplementation.getHandler(uid).emitError(new HTTPResponseError({
// eslint-disable-next-line ts/no-explicit-any
request: {} as any,
headers: new HTTPHeaders(),
status: 500,
statusText: 'Internal Server Error',
error: new Error('mocked error'),
}));
}
function emitSuccess(uid: number) {
httpImplementation.getHandler(uid).emitResponse(new HTTPResponse({
headers: new HTTPHeaders(),
status: 200,
statusText: 'Request Succeeded',
body: {
text: 'Succeeded',
},
}));
}
it('should retry the request for 3 times until success', () => new Promise<void>((done) => {
httpService.registerHTTPInterceptor({
priority: 0,
interceptor: RetryInterceptorFactory(),
});
const request = httpService.get('http://example.com');
request.then((response) => {
expect(response.body).toEqual({ text: 'Succeeded' });
done();
});
emitError(0);
vitest.advanceTimersByTime(1200);
emitError(0);
vitest.advanceTimersByTime(1200);
emitError(0);
vitest.advanceTimersByTime(1200);
emitSuccess(0);
}));
it('should throw error after attempt for 3 times', () => new Promise<void>((done) => {
httpService.registerHTTPInterceptor({
priority: 0,
interceptor: RetryInterceptorFactory(),
});
const request = httpService.get('http://example.com');
request.then(() => {
// done();
}).catch((error: HTTPResponseError) => {
expect(error.status).toBe(500);
done();
});
emitError(0);
vitest.advanceTimersByTime(1200);
emitError(0);
vitest.advanceTimersByTime(1200);
emitError(0);
vitest.advanceTimersByTime(1200);
emitError(0);
vitest.advanceTimersByTime(1200);
}));
});

View File

@@ -0,0 +1,150 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector } from '@univerjs/core';
import type { MockHTTPImplementation } from '../../__tests__/http-testing-utils';
import { awaitTime } from '@univerjs/core';
import { Observable } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createHTTPTestBed } from '../../__tests__/http-testing-utils';
import { HTTPHeaders } from '../../headers';
import { HTTPService } from '../../http.service';
import { IHTTPImplementation } from '../../implementations/implementation';
import { __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION, HTTPRequest } from '../../request';
import { HTTPResponse, HTTPResponseError } from '../../response';
import { ThresholdInterceptorFactory } from '../threshold-interceptor';
describe('test "HTTPThresholdInterceptor"', () => {
let httpService: HTTPService;
let httpImplementation: MockHTTPImplementation;
let injector: Injector;
beforeEach(() => {
injector = createHTTPTestBed().injector;
httpService = injector.get(HTTPService);
httpImplementation = injector.get(IHTTPImplementation) as MockHTTPImplementation;
});
afterEach(() => {
injector.dispose();
__TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION();
});
function emitSuccess(uid: number) {
httpImplementation.getHandler(uid).emitResponse(new HTTPResponse({
headers: new HTTPHeaders(),
status: 200,
statusText: 'Request Succeeded',
body: {
text: 'Succeeded',
},
}));
}
function emitError(uid: number) {
httpImplementation.getHandler(uid).emitError(new HTTPResponseError({
// eslint-disable-next-line ts/no-explicit-any
request: {} as any,
headers: new HTTPHeaders(),
status: 400,
statusText: 'Request Failed',
error: 'Failed',
}));
}
it('should control parallel requests', async () => {
httpService.registerHTTPInterceptor({
priority: 20,
interceptor: ThresholdInterceptorFactory(),
});
const request0 = httpService.get<{ text: string }>('http://example.com');
const request1 = httpService.get<{ text: string }>('http://example.com');
expect(httpImplementation.getHandler(0)).toBeDefined();
expect(() => httpImplementation.getHandler(1)).toThrowError(); // due to threshold, the second request should not be sent
emitSuccess(0); // when the first request is completed, the second request should be sent
expect((await request0).body.text).toBe('Succeeded');
expect(httpImplementation.getHandler(1)).toBeDefined();
emitSuccess(1); // let the second request get completed
expect((await request1).body.text).toBe('Succeeded');
const request2 = httpService.get<{ text: string }>('http://example.com'); // when the third request is sent, it should not be threshold
expect(httpImplementation.getHandler(2)).toBeDefined();
emitSuccess(2);
expect((await request2).body.text).toBe('Succeeded');
});
it('should support threshold params', async () => {
httpService.registerHTTPInterceptor({
priority: 20,
interceptor: ThresholdInterceptorFactory({ maxParallel: 2 }),
});
const request0 = httpService.get<{ text: string }>('http://example.com');
const request1 = httpService.get<{ text: string }>('http://example.com');
// since the threshold is 2, the second should be sent simultaneously
const handler0 = httpImplementation.getHandler(0);
const handler1 = httpImplementation.getHandler(1);
expect(handler0).toBeDefined();
expect(handler1).toBeDefined();
emitSuccess(0);
emitSuccess(1);
expect((await request0).body.text).toBe('Succeeded');
expect((await request1).body.text).toBe('Succeeded');
});
it('should support threshold params with errors', async () => {
httpService.registerHTTPInterceptor({
priority: 20,
interceptor: ThresholdInterceptorFactory({ maxParallel: 2 }),
});
let errored = false;
const _request0 = httpService.get('http://example.com').catch(() => {
errored = true;
});
const request1 = httpService.get('http://example.com');
const handler0 = httpImplementation.getHandler(0);
const handler1 = httpImplementation.getHandler(1);
expect(handler0).toBeDefined();
expect(handler1).toBeDefined();
emitError(0);
await awaitTime(20);
expect(errored).toBe(true);
emitSuccess(1);
expect(await request1).toBeDefined();
});
it('should cancel an active request when its subscriber unsubscribes', () => {
const teardown = vi.fn();
const request = new HTTPRequest('GET', 'http://example.com');
const request$ = ThresholdInterceptorFactory()(request, () => new Observable(() => teardown));
const subscription = request$.subscribe();
subscription.unsubscribe();
expect(teardown).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,42 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HTTPInterceptorFn, HTTPInterceptorFnFactory } from '../interceptor';
import { catchError, throwError } from 'rxjs';
import { HTTPResponseError } from '../response';
export interface IAuthInterceptorParams {
errorStatusCodes: number[];
onAuthError: () => void;
}
export const AuthInterceptorFactory: HTTPInterceptorFnFactory<[IAuthInterceptorParams]> = (params) => {
const { errorStatusCodes, onAuthError } = params;
const authInterceptor: HTTPInterceptorFn = (request, next) => {
return next(request).pipe(
catchError((error) => {
if ((error instanceof HTTPResponseError) && errorStatusCodes.some((c) => c === error.status)) {
onAuthError();
}
return throwError(() => error);
})
);
};
return authInterceptor;
};

View File

@@ -0,0 +1,153 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Subscription } from 'rxjs';
import type { HTTPHandlerFn } from '../interceptor';
import type { HTTPRequest } from '../request';
import { noop, remove } from '@univerjs/core';
import { Observable } from 'rxjs';
import { HTTPEventType, HTTPResponse } from '../response';
const createDefaultFetchCheck = (time = 300) => {
let cancel = noop;
return (_currentConfig: HTTPRequest) => {
return new Promise<boolean>((res) => {
cancel();
const t = setTimeout(() => {
res(true);
}, time);
cancel = () => {
clearTimeout(t);
res(false);
};
});
};
};
const createDistributeResult = <T, C>() => {
return (result: C, list: T[]) => {
return list.map((config) => ({ config, result }));
};
};
export const MergeInterceptorFactory = <T, C>(config: {
/**
* Filter requests that need to be merged
*/
isMatch: (requestConfig: HTTPRequest) => boolean;
/**
* Pre-process request parameters, the return value will be used as input parameters for subsequent operations
* The result is used as an index key
*/
getParamsFromRequest: (requestConfig: HTTPRequest) => T;
/**
* The request parameters are merged to initiate the request
*/
mergeParamsToRequest: (list: T[], requestConfig: HTTPRequest) => HTTPRequest;
}, options: {
/**
* Determine when to initiate a request
* By default, requests up to 300ms are automatically aggregated
*/
fetchCheck?: (currentConfig: HTTPRequest) => Promise<boolean>;
/**
* The result of the request is dispatched based on the request parameters.
* By default each request gets the full result of the batch request
*/
distributeResult?: (result: C, list: T[]) => { config: T; result: C }[];
} = {}) => {
interface IBatch { hooks: IHook[]; subscription?: Subscription };
interface IHook { next: (v: HTTPResponse<C>) => void; config: T; error: (error: string) => void; active: boolean; batch?: IBatch };
const { isMatch, getParamsFromRequest, mergeParamsToRequest } = config;
const { fetchCheck = createDefaultFetchCheck(300), distributeResult = createDistributeResult() } = options;
const hookList: IHook[] = [];
const getPlainList = (_list: IHook[]) => _list.map((item) => item.config);
return (requestConfig: HTTPRequest, next: HTTPHandlerFn) => {
if (!isMatch(requestConfig)) {
return next(requestConfig);
}
return new Observable<HTTPResponse<C>>((observer) => {
const params = getParamsFromRequest(requestConfig);
const hook: IHook = {
next: (v) => observer.next(v),
error: (error) => observer.error(error),
config: params,
active: true,
};
hookList.push(hook);
const list = getPlainList(hookList);
fetchCheck(requestConfig).then((isFetch) => {
if (isFetch) {
// Pin down the queue that currently needs a request.
const currentHookList: IHook[] = [];
list.forEach((config) => {
const index = hookList.findIndex((item) => item.config === config);
if (index >= 0) {
const [hook] = hookList.splice(index, 1);
currentHookList.push(hook);
} else {
// There's no corresponding callback here. It could be that other operations have been handled and do not need to be handled.
}
});
const currentList = getPlainList(currentHookList);
if (!currentList.length) {
return;
}
const batch: IBatch = { hooks: currentHookList };
currentHookList.forEach((hookItem) => hookItem.batch = batch);
batch.subscription = next(mergeParamsToRequest(currentList, requestConfig)).subscribe({
next: (e) => {
if (e.type === HTTPEventType.Response) {
const body = e.body as C;
const configList = distributeResult(body, currentList);
currentHookList.forEach((hookItem) => {
const res = configList.find((item) => item.config === hookItem.config);
if (res) {
const response = new HTTPResponse({
body: res.result,
headers: e.headers,
status: e.status,
statusText: e.statusText,
});
hookItem.next(response);
} else {
hookItem.error('batch error');
}
});
}
},
complete: () => observer.complete(),
error: (e) => observer.error(e),
});
}
});
return () => {
hook.active = false;
remove(hookList, hook);
if (hook.batch?.hooks.every((hookItem) => !hookItem.active)) {
hook.batch.subscription?.unsubscribe();
}
};
});
};
};

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import type { HTTPInterceptorFnFactory } from '../interceptor';
import { retry } from 'rxjs/operators';
const DEFAULT_MAX_RETRY_ATTEMPTS = 3;
const DELAY_INTERVAL = 1000;
export interface IRetryInterceptorFactoryParams {
maxRetryAttempts?: number;
delayInterval?: number;
}
export const RetryInterceptorFactory: HTTPInterceptorFnFactory<[Nullable<IRetryInterceptorFactoryParams>]> = (params) => {
const maxRetryAttempts = params?.maxRetryAttempts ?? DEFAULT_MAX_RETRY_ATTEMPTS;
const delayInterval = params?.delayInterval ?? DELAY_INTERVAL;
return (request, next) => next(request).pipe(retry({ delay: delayInterval, count: maxRetryAttempts }));
};

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import type { Subscription } from 'rxjs';
import type { HTTPInterceptorFnFactory } from '../interceptor';
import { remove } from '@univerjs/core';
import { Observable } from 'rxjs';
export interface IThresholdInterceptorFactoryParams {
maxParallel?: number;
}
type HandlerFn = () => void;
export const ThresholdInterceptorFactory: HTTPInterceptorFnFactory<[Nullable<IThresholdInterceptorFactoryParams>]> = (params) => {
/**
* The local variable to store handles.
*/
const handlers: HandlerFn[] = [];
const ongoingHandlers = new Set<HandlerFn>();
const tick = (): void => {
while (ongoingHandlers.size < (params?.maxParallel ?? 1) && handlers.length > 0) {
const handler = handlers.shift()!;
ongoingHandlers.add(handler);
handler();
}
};
return (request, next) => {
return new Observable((observer) => {
let requestSubscription: Subscription | undefined;
const handler = () => {
requestSubscription = next(request).subscribe({
next: (event) => observer.next(event),
error: (err) => observer.error(err),
complete: () => observer.complete(),
});
};
const teardown = () => {
requestSubscription?.unsubscribe();
ongoingHandlers.delete(handler);
remove(handlers, handler);
tick();
};
handlers.push(handler);
tick();
return teardown;
});
};
};

View File

@@ -0,0 +1,42 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
type ValidParamType = string | number | boolean;
export class HTTPParams {
constructor(readonly params?: { [key: string]: ValidParamType | ValidParamType[] }) {
// empty
}
toString(): string {
if (!this.params) {
return '';
}
return Object.keys(this.params)
.map((key) => {
const value = this.params![key];
if (Array.isArray(value)) {
return value
.map((v) => `${key}=${v}`)
.join('&');
}
return `${key}=${value}`;
})
.join('&');
}
}

View File

@@ -0,0 +1,84 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HTTPHeaders } from './headers';
import type { HTTPResponseType } from './http';
import type { HTTPParams } from './params';
import { ApplicationJSONType, isApplicationJSONType } from './headers';
export type HTTPRequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
/**
* @internal
*/
export interface IHTTPRequestParams {
// eslint-disable-next-line ts/no-explicit-any
body?: any;
headers: HTTPHeaders;
params?: HTTPParams;
responseType: HTTPResponseType;
withCredentials: boolean;
reportProgress?: boolean;
}
let HTTPRequestUID = 0;
export function __TEST_ONLY_RESET_REQUEST_UID_DO_NOT_USE_IN_PRODUCTION() {
HTTPRequestUID = 0;
}
export class HTTPRequest {
get headers(): HTTPHeaders { return this.requestParams!.headers; }
get withCredentials(): boolean { return this.requestParams!.withCredentials; }
get responseType(): HTTPResponseType { return this.requestParams!.responseType; }
readonly uid = HTTPRequestUID++;
constructor(
readonly method: HTTPRequestMethod,
readonly url: string,
readonly requestParams?: IHTTPRequestParams
) { }
getUrlWithParams(): string {
const params = this.requestParams?.params?.toString();
if (!params) {
return this.url;
}
return `${this.url}${this.url.includes('?') ? '&' : '?'}${params}`;
}
getBody(): string | FormData | null {
const contentType = this.headers.get('Content-Type') ?? ApplicationJSONType;
const body = this.requestParams?.body;
if (body instanceof FormData) {
return body;
}
if (isApplicationJSONType(contentType) && body && typeof body === 'object') {
return JSON.stringify(body);
}
return body ? `${body}` : null;
}
getHeadersInit(): HeadersInit {
const headersInit = this.headers.toHeadersInit(this.requestParams?.body);
return headersInit;
}
}

View File

@@ -0,0 +1,129 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HTTPHeaders } from './headers';
import type { HTTPRequest } from './request';
export type HTTPEvent<T> = HTTPResponse<T> | HTTPProgress;
export enum HTTPEventType {
DownloadProgress,
Response,
}
interface IHTTPEvent {
type: HTTPEventType;
}
export type HTTPResponseBody = string | ArrayBuffer | Blob | object | null;
/**
* Wraps success response info.
*/
export class HTTPResponse<T> implements IHTTPEvent {
readonly type = HTTPEventType.Response;
readonly body: T;
readonly headers: HTTPHeaders;
readonly status: number;
readonly statusText: string;
constructor({
body,
headers,
status,
statusText,
}: {
body: T;
headers: HTTPHeaders;
status: number;
statusText: string;
}) {
this.body = body;
this.headers = headers;
this.status = status;
this.statusText = statusText;
}
}
/**
* Progress event for HTTP request. Usually used for reporting download/upload progress or SSE streaming.
*/
export class HTTPProgress implements IHTTPEvent {
readonly type = HTTPEventType.DownloadProgress;
constructor(
/**
* Total number of bytes to download. Depending on the request or
* response, this may not be computable and thus may not be present.
*/
public readonly total: number | undefined,
/**
* Number of bytes downloaded.
*/
public readonly loaded: number,
/**
* The partial response body as downloaded so far.
*
* Only present if the responseType was `text`.
*/
public readonly partialText?: string | undefined
) {
// empty
}
}
export class ResponseHeader {
constructor(
readonly headers: HTTPHeaders,
readonly status: number,
readonly statusText: string
) {
// empty
}
}
// #region error
export class HTTPResponseError {
readonly request: HTTPRequest;
readonly headers?: HTTPHeaders;
readonly status?: number;
readonly statusText?: string;
readonly error: any;
constructor({
request,
headers,
status,
statusText,
error,
}: {
request: HTTPRequest;
headers?: HTTPHeaders;
status?: number;
statusText?: string;
error: any;
}) {
this.request = request;
this.headers = headers;
this.status = status;
this.statusText = statusText;
this.error = error;
}
}
// #endregion

View File

@@ -0,0 +1,146 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { SocketBodyType } from '../web-socket.service';
import { Injector } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ISocketService, WebSocketService } from '../web-socket.service';
type SocketEventName = 'open' | 'close' | 'error' | 'message';
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
readonly listeners: Record<SocketEventName, Set<(event: Event | MessageEvent) => void>> = {
open: new Set(),
close: new Set(),
error: new Set(),
message: new Set(),
};
readonly send = vi.fn();
readonly close = vi.fn((code?: number, reason?: string) => {
this.closedWith = { code, reason };
});
closedWith?: { code?: number; reason?: string };
constructor(readonly url: string) {
FakeWebSocket.instances.push(this);
}
addEventListener(type: SocketEventName, callback: (event: Event | MessageEvent) => void) {
this.listeners[type].add(callback);
}
removeEventListener(type: SocketEventName, callback: (event: Event | MessageEvent) => void) {
this.listeners[type].delete(callback);
}
emit(type: SocketEventName, event: Event | MessageEvent) {
this.listeners[type].forEach((callback) => callback(event));
}
}
describe('WebSocketService', () => {
const originalWebSocket = globalThis.WebSocket;
function createService() {
const injector = new Injector();
injector.add([ISocketService, { useClass: WebSocketService }]);
return injector.get(ISocketService) as WebSocketService;
}
beforeEach(() => {
FakeWebSocket.instances = [];
vi.stubGlobal('WebSocket', FakeWebSocket as unknown as typeof WebSocket);
});
afterEach(() => {
vi.unstubAllGlobals();
globalThis.WebSocket = originalWebSocket;
});
it('creates sockets, forwards events, and removes listeners on close', () => {
const service = createService();
const socket = service.createSocket('wss://example.com/socket');
expect(socket).not.toBeNull();
expect(socket?.URL).toBe('wss://example.com/socket');
const createdSocket = FakeWebSocket.instances[0];
const openEvents: Event[] = [];
const messageEvents: MessageEvent[] = [];
const errorEvents: Event[] = [];
const closeEvents: Event[] = [];
const openSubscription = socket!.open$.subscribe((event) => openEvents.push(event));
const messageSubscription = socket!.message$.subscribe((event) => messageEvents.push(event));
const errorSubscription = socket!.error$.subscribe((event) => errorEvents.push(event));
const closeSubscription = socket!.close$.subscribe((event) => closeEvents.push(event));
const openEvent = new Event('open');
const messageEvent = new MessageEvent('message', { data: 'payload' });
const errorEvent = new Event('error');
const closeEvent = new Event('close');
createdSocket.emit('open', openEvent);
createdSocket.emit('message', messageEvent);
createdSocket.emit('error', errorEvent);
createdSocket.emit('close', closeEvent);
expect(openEvents).toEqual([openEvent]);
expect(messageEvents).toEqual([messageEvent]);
expect(errorEvents).toEqual([errorEvent]);
expect(closeEvents).toEqual([closeEvent]);
const payload: SocketBodyType = 'hello';
socket!.send(payload);
expect(createdSocket.send).toHaveBeenCalledWith(payload);
socket!.close(1000, 'done');
expect(createdSocket.close).toHaveBeenCalledWith(1000, 'done');
expect(createdSocket.listeners.open.size).toBe(0);
expect(createdSocket.listeners.message.size).toBe(0);
expect(createdSocket.listeners.error.size).toBe(0);
expect(createdSocket.listeners.close.size).toBe(0);
openSubscription.unsubscribe();
messageSubscription.unsubscribe();
errorSubscription.unsubscribe();
closeSubscription.unsubscribe();
service.dispose();
});
it('returns null when the platform WebSocket constructor throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('WebSocket', class ThrowingWebSocket {
constructor() {
throw new Error('unsupported');
}
} as unknown as typeof WebSocket);
const service = createService();
expect(service.createSocket('wss://example.com/fail')).toBeNull();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
service.dispose();
});
});

View File

@@ -0,0 +1,97 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import { createIdentifier, Disposable, DisposableCollection, toDisposable } from '@univerjs/core';
import { Observable } from 'rxjs';
import { share } from 'rxjs/operators';
export type SocketBodyType = Parameters<WebSocket['send']>[0];
/**
* This service is responsible for establishing bidi-directional connection to a remote server.
*/
export const ISocketService = createIdentifier<ISocketService>('univer.network.socket.service');
export interface ISocketService {
createSocket(url: string): Nullable<ISocket>;
}
/**
* An interface that represents a socket connection.
*/
export interface ISocket {
URL: string;
close(code?: number, reason?: string): void;
/**
* Send a message to the remote server.
*/
send(data: SocketBodyType): void;
close$: Observable<Event>;
error$: Observable<Event>;
message$: Observable<MessageEvent>;
open$: Observable<Event>;
}
/**
* This service create a WebSocket connection to a remote server.
*/
export class WebSocketService extends Disposable implements ISocketService {
createSocket(URL: string): Nullable<ISocket> {
try {
const connection = new WebSocket(URL);
const disposables = new DisposableCollection();
const webSocket: ISocket = {
URL,
close: (code?: number, reason?: string) => {
connection.close(code, reason);
disposables.dispose();
},
send: (data: SocketBodyType) => {
connection.send(data);
},
open$: new Observable<Event>((subscriber) => {
const callback = (event: Event) => subscriber.next(event);
connection.addEventListener('open', callback);
disposables.add(toDisposable(() => connection.removeEventListener('open', callback)));
}).pipe(share()),
close$: new Observable<Event>((subscriber) => {
const callback = (event: Event) => subscriber.next(event);
connection.addEventListener('close', callback);
disposables.add(toDisposable(() => connection.removeEventListener('close', callback)));
}).pipe(share()),
error$: new Observable<Event>((subscriber) => {
const callback = (event: Event) => subscriber.next(event);
connection.addEventListener('error', callback);
disposables.add(toDisposable(() => connection.removeEventListener('error', callback)));
}).pipe(share()),
message$: new Observable<MessageEvent>((subscriber) => {
const callback = (event: MessageEvent) => subscriber.next(event);
connection.addEventListener('message', callback);
disposables.add(toDisposable(() => connection.removeEventListener('message', callback)));
}).pipe(share()),
};
return webSocket;
} catch (e) {
console.error(e);
return null;
}
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();