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
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:
37
packages/network/README.md
Normal file
37
packages/network/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @univerjs/network
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/network)
|
||||
[](https://npmjs.com/package/@univerjs/network)
|
||||
[](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)
|
||||
89
packages/network/package.json
Normal file
89
packages/network/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
packages/network/src/config/config.ts
Normal file
44
packages/network/src/config/config.ts
Normal 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 = {};
|
||||
161
packages/network/src/facade/__tests__/f-network.spec.ts
Normal file
161
packages/network/src/facade/__tests__/f-network.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
103
packages/network/src/facade/f-network.ts
Normal file
103
packages/network/src/facade/f-network.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
104
packages/network/src/facade/f-univer.ts
Normal file
104
packages/network/src/facade/f-univer.ts
Normal 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 { }
|
||||
}
|
||||
20
packages/network/src/facade/index.ts
Normal file
20
packages/network/src/facade/index.ts
Normal 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';
|
||||
39
packages/network/src/index.ts
Normal file
39
packages/network/src/index.ts
Normal 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';
|
||||
73
packages/network/src/plugin.ts
Normal file
73
packages/network/src/plugin.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
68
packages/network/src/services/http/__tests__/headers.spec.ts
Normal file
68
packages/network/src/services/http/__tests__/headers.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
200
packages/network/src/services/http/__tests__/request.spec.ts
Normal file
200
packages/network/src/services/http/__tests__/request.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
110
packages/network/src/services/http/headers.ts
Normal file
110
packages/network/src/services/http/headers.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
211
packages/network/src/services/http/http.service.ts
Normal file
211
packages/network/src/services/http/http.service.ts
Normal 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));
|
||||
}
|
||||
96
packages/network/src/services/http/http.ts
Normal file
96
packages/network/src/services/http/http.ts
Normal 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,
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
202
packages/network/src/services/http/implementations/fetch.ts
Normal file
202
packages/network/src/services/http/implementations/fetch.ts
Normal 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}.`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
28
packages/network/src/services/http/implementations/util.ts
Normal file
28
packages/network/src/services/http/implementations/util.ts
Normal 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;
|
||||
}
|
||||
167
packages/network/src/services/http/implementations/xhr.ts
Normal file
167
packages/network/src/services/http/implementations/xhr.ts
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
47
packages/network/src/services/http/interceptor.ts
Normal file
47
packages/network/src/services/http/interceptor.ts
Normal 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;
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
};
|
||||
42
packages/network/src/services/http/params.ts
Normal file
42
packages/network/src/services/http/params.ts
Normal 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('&');
|
||||
}
|
||||
}
|
||||
84
packages/network/src/services/http/request.ts
Normal file
84
packages/network/src/services/http/request.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
129
packages/network/src/services/http/response.ts
Normal file
129
packages/network/src/services/http/response.ts
Normal 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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/network/tsconfig.json
Normal file
8
packages/network/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/network/tsconfig.node.json
Normal file
18
packages/network/tsconfig.node.json
Normal 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"
|
||||
]
|
||||
}
|
||||
3
packages/network/vitest.config.ts
Normal file
3
packages/network/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
Reference in New Issue
Block a user