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:
@@ -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);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user