feat(rpc): init network package (#485)

* feat(rpc): init network package

* chore: update pnpm lock
This commit is contained in:
Wenzhao Hu
2023-11-18 03:27:26 -06:00
committed by GitHub
parent e990a4edcd
commit 5a024f1098
14 changed files with 649 additions and 0 deletions
+17
View File
@@ -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/)
+11
View File
@@ -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"
}
}
+6
View File
@@ -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';
@@ -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()];
}
}
}
@@ -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<HTTPEvent<unknown>>;
type HTTPInterceptorFn = (request: HTTPRequest, next: HTTPHandlerFn) => Observable<HTTPEvent<unknown>>;
type RequestPipe<T> = (req: HTTPRequest, finalHandlerFn: HTTPHandlerFn) => Observable<HTTPEvent<T>>;
/**
* 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<RequestPipe<any>>;
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<T>(url: string, options?: IRequestParams): Promise<HTTPResponse<T>> {
return this._request<T>('GET', url, options);
}
post<T>(url: string, options?: IPostRequestParams): Promise<HTTPResponse<T>> {
return this._request<T>('POST', url, options);
}
put<T>(url: string, options?: IPostRequestParams): Promise<HTTPResponse<T>> {
return this._request<T>('PUT', url, options);
}
delete<T>(url: string, options?: IRequestParams): Promise<HTTPResponse<T>> {
return this._request<T>('DELETE', url, options);
}
/** The HTTP request implementations */
private 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',
});
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$);
if (result instanceof HTTPResponse) {
return result;
}
throw new Error(`${(result as HTTPResponseError).error}`);
}
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));
}
@@ -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,
}
@@ -0,0 +1 @@
// TODO
@@ -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<HTTPEvent<any>>;
}
export const IHTTPImplementation = createIdentifier<IHTTPImplementation>('univer-pro.network.http-implementation');
@@ -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<HTTPEvent<any>> {
return new Observable((observer: Observer<HTTPEvent<any>>) => {
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<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();
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);
};
});
}
}
@@ -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('&');
}
}
@@ -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 '';
}
}
@@ -0,0 +1,63 @@
import { HTTPHeaders } from './headers';
/**
* There are multiple events could be resolved from the HTTP server.
*/
export type HTTPEvent<T> = HTTPResponse<T> | HTTPResponseError;
/** Wraps (success) response info. */
export class HTTPResponse<T> {
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
) {}
}
@@ -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<ISocketService>('univer.socket');
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;
// 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<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: 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;
}
}
}
+12
View File
@@ -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':