From 6108100370b7ff08a3691a98801da9b6a5543344 Mon Sep 17 00:00:00 2001 From: Wenzhao Hu Date: Sat, 18 Nov 2023 17:18:45 +0800 Subject: [PATCH 1/2] feat(rpc): init network package --- packages/network/README.md | 17 +++ packages/network/package.json | 11 ++ packages/network/src/index.ts | 6 + packages/network/src/services/http/headers.ts | 43 ++++++ .../network/src/services/http/http.service.ts | 130 ++++++++++++++++++ packages/network/src/services/http/http.ts | 82 +++++++++++ .../services/http/implementations/fetch.ts | 1 + .../http/implementations/implementation.ts | 16 +++ .../src/services/http/implementations/xhr.ts | 123 +++++++++++++++++ packages/network/src/services/http/params.ts | 13 ++ packages/network/src/services/http/request.ts | 52 +++++++ .../network/src/services/http/response.ts | 63 +++++++++ .../services/web-socket/web-socket.service.ts | 80 +++++++++++ 13 files changed, 637 insertions(+) create mode 100644 packages/network/README.md create mode 100644 packages/network/package.json create mode 100644 packages/network/src/index.ts create mode 100644 packages/network/src/services/http/headers.ts create mode 100644 packages/network/src/services/http/http.service.ts create mode 100644 packages/network/src/services/http/http.ts create mode 100644 packages/network/src/services/http/implementations/fetch.ts create mode 100644 packages/network/src/services/http/implementations/implementation.ts create mode 100644 packages/network/src/services/http/implementations/xhr.ts create mode 100644 packages/network/src/services/http/params.ts create mode 100644 packages/network/src/services/http/request.ts create mode 100644 packages/network/src/services/http/response.ts create mode 100644 packages/network/src/services/web-socket/web-socket.service.ts diff --git a/packages/network/README.md b/packages/network/README.md new file mode 100644 index 00000000000..3e3fa96594f --- /dev/null +++ b/packages/network/README.md @@ -0,0 +1,17 @@ +# @univerjs/network + +## Introduction + +This plugin provides network services to other modules of Univer. + +## Usage + +### Installation + +```shell +npm i @univerjs/network +``` + +### API + +Check [Univer](https://github.com/dream-num/univer/) diff --git a/packages/network/package.json b/packages/network/package.json new file mode 100644 index 00000000000..6dde2e57f52 --- /dev/null +++ b/packages/network/package.json @@ -0,0 +1,11 @@ +{ + "name": "@univerjs/network", + "private": true, + "main": "./src/index.ts", + "module": "./src/index.ts", + "dependencies": { + "@univerjs/core": "workspace:*", + "@wendellhu/redi": "^0.12.10", + "rxjs": "^7.8.1" + } +} diff --git a/packages/network/src/index.ts b/packages/network/src/index.ts new file mode 100644 index 00000000000..6b2133a7fcf --- /dev/null +++ b/packages/network/src/index.ts @@ -0,0 +1,6 @@ +export { HTTPHeaders } from './services/http/headers'; +export { HTTPService } from './services/http/http.service'; +export { XHRHTTPImplementation } from './services/http/implementations/xhr'; +export { HTTPRequest } from './services/http/request'; +export { HTTPResponse } from './services/http/response'; +export { type ISocket, ISocketService, WebSocketService } from './services/web-socket/web-socket.service'; diff --git a/packages/network/src/services/http/headers.ts b/packages/network/src/services/http/headers.ts new file mode 100644 index 00000000000..fb1bec799be --- /dev/null +++ b/packages/network/src/services/http/headers.ts @@ -0,0 +1,43 @@ +interface IHeadersConstructorProps { + [key: string]: string | number | boolean; +} + +/** + * It wraps headers of HTTP requests' and responses' headers. + */ +export class HTTPHeaders { + private readonly _headers: { [key: string]: string[] } = {}; + + constructor(headers?: IHeadersConstructorProps | string) { + if (typeof headers === 'string') { + // split header text and serialize them to HTTPHeaders + headers.split('\n').forEach((header) => { + const [name, value] = header.split(':'); + if (name && value) { + this._setHeader(name, value); + } + }); + } else { + if (headers) { + Object.keys(headers).forEach(([name, value]) => { + this._setHeader(name, value); + }); + } + } + } + + forEach(callback: (name: string, value: string[]) => void): void { + Object.keys(this._headers).forEach((name) => { + callback(name, this._headers[name]); + }); + } + + private _setHeader(name: string, value: string | number | boolean): void { + const lowerCase = name.toLowerCase(); + if (this._headers[lowerCase]) { + this._headers[lowerCase].push(value.toString()); + } else { + this._headers[lowerCase] = [value.toString()]; + } + } +} diff --git a/packages/network/src/services/http/http.service.ts b/packages/network/src/services/http/http.service.ts new file mode 100644 index 00000000000..748467feaa7 --- /dev/null +++ b/packages/network/src/services/http/http.service.ts @@ -0,0 +1,130 @@ +import { Disposable, Nullable, remove, toDisposable } from '@univerjs/core'; +import { IDisposable } from '@wendellhu/redi'; +import { firstValueFrom, Observable, of } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; + +import { HTTPHeaders } from './headers'; +import { HTTPResponseType } from './http'; +import { IHTTPImplementation } from './implementations/implementation'; +import { HTTPParams } from './params'; +import { HTTPRequest, HTTPRequestMethod } from './request'; +import { HTTPEvent, HTTPResponse, HTTPResponseError } from './response'; + +// TODO: error handling of HTTPService should be strengthened. + +export interface IRequestParams { + /** Query params. These params would be append to the url before the request is sent. */ + params?: { [param: string]: string | number | boolean }; + headers?: { [key: string]: string | number | boolean }; + responseType?: HTTPResponseType; + withCredentials?: boolean; +} + +export interface IPostRequestParams extends IRequestParams { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body?: any; +} + +type HTTPHandlerFn = (request: HTTPRequest) => Observable>; +type HTTPInterceptorFn = (request: HTTPRequest, next: HTTPHandlerFn) => Observable>; +type RequestPipe = (req: HTTPRequest, finalHandlerFn: HTTPHandlerFn) => Observable>; + +/** + * Register an HTTP interceptor. Interceptor could modify requests, responses, headers or errors. + */ +export interface IHTTPInterceptor { + priority?: number; + interceptor: HTTPInterceptorFn; +} + +export class HTTPService extends Disposable { + private _interceptors: IHTTPInterceptor[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _pipe: Nullable>; + + constructor(@IHTTPImplementation private readonly _http: IHTTPImplementation) { + super(); + } + + 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(url: string, options?: IRequestParams): Promise> { + return this._request('GET', url, options); + } + + post(url: string, options?: IPostRequestParams): Promise> { + return this._request('POST', url, options); + } + + put(url: string, options?: IPostRequestParams): Promise> { + return this._request('PUT', url, options); + } + + delete(url: string, options?: IRequestParams): Promise> { + return this._request('DELETE', url, options); + } + + /** The HTTP request implementations */ + private async _request( + method: HTTPRequestMethod, + url: string, + options?: IRequestParams + ): Promise> { + // 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', + }); + + const events$: Observable> = 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$); + if (result instanceof HTTPResponse) { + return result; + } + + throw new Error(`${(result as HTTPResponseError).error}`); + } + + private _runInterceptorsAndImplementation(request: HTTPRequest): Observable> { + // 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)); +} diff --git a/packages/network/src/services/http/http.ts b/packages/network/src/services/http/http.ts new file mode 100644 index 00000000000..1fc693780c2 --- /dev/null +++ b/packages/network/src/services/http/http.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-magic-numbers */ + +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, +} diff --git a/packages/network/src/services/http/implementations/fetch.ts b/packages/network/src/services/http/implementations/fetch.ts new file mode 100644 index 00000000000..70b786d12ed --- /dev/null +++ b/packages/network/src/services/http/implementations/fetch.ts @@ -0,0 +1 @@ +// TODO diff --git a/packages/network/src/services/http/implementations/implementation.ts b/packages/network/src/services/http/implementations/implementation.ts new file mode 100644 index 00000000000..e94871a49e4 --- /dev/null +++ b/packages/network/src/services/http/implementations/implementation.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createIdentifier } from '@wendellhu/redi'; +import { Observable } from 'rxjs'; + +import { HTTPRequest } from '../request'; +import { HTTPEvent } from '../response'; + +/** + * HTTP service could be implemented differently on platforms. + */ +export interface IHTTPImplementation { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // There may be stream response so the return value is an observable. + send(request: HTTPRequest): Observable>; +} +export const IHTTPImplementation = createIdentifier('univer-pro.network.http-implementation'); diff --git a/packages/network/src/services/http/implementations/xhr.ts b/packages/network/src/services/http/implementations/xhr.ts new file mode 100644 index 00000000000..b3848f32e8e --- /dev/null +++ b/packages/network/src/services/http/implementations/xhr.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { Nullable } from '@univerjs/core'; +import { Observable, Observer } from 'rxjs'; + +import { HTTPHeaders } from '../headers'; +import { ErrorStatusCodeLowerBound, HTTPStatusCode, SuccessStatusCodeLowerBound } from '../http'; +import { HTTPRequest } from '../request'; +import { HTTPEvent, HTTPResponse, HTTPResponseError, ResponseHeader } from '../response'; +import { IHTTPImplementation } from './implementation'; + +/** + * A HTTP implementation using XHR. XHR could only be async. + */ +export class XHRHTTPImplementation implements IHTTPImplementation { + send(request: HTTPRequest): Observable> { + return new Observable((observer: Observer>) => { + const xhr = new XMLHttpRequest(); + xhr.open(request.method, request.getUrlWithParams()); + if (request.withCredentials) { + xhr.withCredentials = true; + } + + request.headers.forEach((key, value) => xhr.setRequestHeader(key, value.join(','))); + + let responseHeader: Nullable; + + 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(); + const { responseType } = request; + + 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 (success) { + observer.next( + new HTTPResponse({ + body, + headers, + status, + statusText, + }) + ); + } else { + // next http error here + observer.next( + new HTTPResponseError({ + error, + headers, + status, + statusText, + }) + ); + // Handler server logic error here + } + }; + + const onErrorHandler = (error: ProgressEvent) => { + const res = new HTTPResponseError({ + error, + status: xhr.status || 0, + statusText: xhr.statusText || 'Unknown Error', + headers: buildResponseHeader().headers, + }); + + observer.next(res); + }; + + xhr.addEventListener('load', onLoadHandler); + xhr.addEventListener('error', onErrorHandler); + xhr.addEventListener('abort', onErrorHandler); + xhr.addEventListener('timeout', onErrorHandler); + + const body = request.getBody(); + xhr.send(body); + + // 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); + }; + }); + } +} diff --git a/packages/network/src/services/http/params.ts b/packages/network/src/services/http/params.ts new file mode 100644 index 00000000000..d09e4944bcf --- /dev/null +++ b/packages/network/src/services/http/params.ts @@ -0,0 +1,13 @@ +export class HTTPParams { + constructor(readonly params?: { [key: string]: string | number | boolean }) {} + + toString(): string { + if (!this.params) { + return ''; + } + + return Object.keys(this.params) + .map((key) => `${key}=${this.params![key]}`) + .join('&'); + } +} diff --git a/packages/network/src/services/http/request.ts b/packages/network/src/services/http/request.ts new file mode 100644 index 00000000000..a25189a4a4b --- /dev/null +++ b/packages/network/src/services/http/request.ts @@ -0,0 +1,52 @@ +import { HTTPHeaders } from './headers'; +import { HTTPResponseType } from './http'; +import { HTTPParams } from './params'; + +export type HTTPRequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +export interface IHTTPRequestParams { + body?: any; + headers: HTTPHeaders; + params?: HTTPParams; + responseType: HTTPResponseType; + withCredentials: boolean; +} + +export class HTTPRequest { + get headers(): HTTPHeaders { + return this.requestParams!.headers; + } + + get withCredentials(): boolean { + return this.requestParams!.withCredentials; + } + + get responseType(): string { + return this.requestParams!.responseType; + } + + constructor( + readonly method: HTTPRequestMethod, + readonly url: string, + readonly requestParams?: IHTTPRequestParams + ) { + // TODO@wzhudev: deal with `requestParams` is empty. + } + + getUrlWithParams(): string { + const params = this.requestParams?.params?.toString(); + if (!params) { + return this.url; + } + + return `${this.url}${this.url.includes('?') ? '&' : '?'}${params}`; + } + + getBody(): string | null { + if (!this.requestParams?.body) { + return null; + } + + return ''; + } +} diff --git a/packages/network/src/services/http/response.ts b/packages/network/src/services/http/response.ts new file mode 100644 index 00000000000..61240996374 --- /dev/null +++ b/packages/network/src/services/http/response.ts @@ -0,0 +1,63 @@ +import { HTTPHeaders } from './headers'; + +/** + * There are multiple events could be resolved from the HTTP server. + */ +export type HTTPEvent = HTTPResponse | HTTPResponseError; + +/** Wraps (success) response info. */ +export class HTTPResponse { + 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; + } +} + +export class HTTPResponseError { + readonly headers: HTTPHeaders; + readonly status: number; + readonly statusText: string; + readonly error: any; + + constructor({ + headers, + status, + statusText, + error, + }: { + headers: HTTPHeaders; + status: number; + statusText: string; + error: any; + }) { + this.headers = headers; + this.status = status; + this.statusText = statusText; + this.error = error; + } +} + +export class ResponseHeader { + constructor( + readonly headers: HTTPHeaders, + readonly status: number, + readonly statusText: string + ) {} +} diff --git a/packages/network/src/services/web-socket/web-socket.service.ts b/packages/network/src/services/web-socket/web-socket.service.ts new file mode 100644 index 00000000000..9e9bc7aa13f --- /dev/null +++ b/packages/network/src/services/web-socket/web-socket.service.ts @@ -0,0 +1,80 @@ +import { Disposable, DisposableCollection, Nullable, toDisposable } from '@univerjs/core'; +import { createIdentifier } from '@wendellhu/redi'; +import { Observable } from 'rxjs'; + +/** + * This service is responsible for establishing bidi-directional connection to a remote server. + */ +export const ISocketService = createIdentifier('univer.socket'); +export interface ISocketService { + createSocket(url: string): Nullable; +} + +/** + * An interface that represents a socket connection. + */ +export interface ISocket { + URL: string; + + close(code?: number, reason?: string): void; + + // TODO: Data type can support plain object as a type. + + /** + * Send a message to the remote server. + */ + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void; + + close$: Observable<[ISocket, CloseEvent]>; + error$: Observable<[ISocket, Event]>; + message$: Observable<[ISocket, MessageEvent]>; + open$: Observable<[ISocket, Event]>; +} + +/** + * This service create a WebSocket connection to a remote server. + */ +export class WebSocketService extends Disposable implements ISocketService { + createSocket(URL: string): Nullable { + 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: string | ArrayBufferLike | Blob | ArrayBufferView) => { + connection.send(data); + }, + open$: new Observable((subscriber) => { + const callback = (event: Event) => subscriber.next([webSocket, event]); + connection.addEventListener('open', callback); + disposables.add(toDisposable(() => connection.removeEventListener('open', callback))); + }), + close$: new Observable((subscriber) => { + const callback = (event: CloseEvent) => subscriber.next([webSocket, event]); + connection.addEventListener('close', callback); + disposables.add(toDisposable(() => connection.removeEventListener('close', callback))); + }), + error$: new Observable((subscriber) => { + const callback = (event: Event) => subscriber.next([webSocket, event]); + connection.addEventListener('error', callback); + disposables.add(toDisposable(() => connection.removeEventListener('error', callback))); + }), + message$: new Observable((subscriber) => { + const callback = (event: MessageEvent) => subscriber.next([webSocket, event]); + connection.addEventListener('message', callback); + disposables.add(toDisposable(() => connection.removeEventListener('message', callback))); + }), + }; + + return webSocket; + } catch (e) { + console.error(e); + return null; + } + } +} From 8662fa003effa3c95c46168a7b06d8a5cc451888 Mon Sep 17 00:00:00 2001 From: Wenzhao Hu Date: Sat, 18 Nov 2023 17:22:39 +0800 Subject: [PATCH 2/2] chore: update pnpm lock --- pnpm-lock.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 606ae56d66c..2623913d3b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -806,6 +806,18 @@ importers: specifier: ^0.34.6 version: 0.34.6(happy-dom@12.10.3)(less@4.2.0) + packages/network: + dependencies: + '@univerjs/core': + specifier: workspace:* + version: link:../core + '@wendellhu/redi': + specifier: ^0.12.10 + version: 0.12.11 + rxjs: + specifier: ^7.8.1 + version: 7.8.1 + packages/rpc: dependencies: '@univerjs/core':