UNPKG

@opra/client

Version:

Opra Client package

310 lines (309 loc) 13.9 kB
import { Readable } from 'node:stream'; import { HTTPParser } from '@browsery/http-parser'; import typeIs from '@browsery/type-is'; import { omit } from '@jsopen/objects'; import { MimeTypes } from '@opra/common'; import MultipartStream from 'multipart-stream'; import * as MP from 'multipasta/node'; import { lastValueFrom, Observable } from 'rxjs'; import { ClientError } from './client-error.js'; import { kBackend, kContext } from './constants.js'; import { HttpBackend } from './http-backend.js'; import { HttpInterceptorHandler } from './http-interceptor-handler.js'; import { HttpRequestObservable } from './http-request-observable.js'; import { HttpResponse } from './http-response.js'; import { serializeHttpRequest } from './http-utils.js'; import { HttpEventType } from './interfaces/http-event.js'; /** * * @class HttpBundleObservable */ export class HttpBundleObservable extends Observable { constructor(backend, requests, init) { /* Bind every request to this bundle *before* the caller has a chance to subscribe to one directly (e.g. `req.getBody()` right after `client.bundle([...])`) — otherwise it would fire its own network call */ for (const req of requests) { // @ts-ignore req._bindBundle(); } super(subscriber => { if (!requests.length) { subscriber.next([]); subscriber.complete(); return; } /* Build the multipart body from sub-requests */ const multipartStream = new MultipartStream(); for (const req of requests) { multipartStream.addPart({ headers: { 'Content-Type': 'application/http', 'Content-Transfer-Encoding': 'binary', 'X-Request-Id': req.requestId, }, body: serializeHttpRequest(req[kContext]), }); } /* Convert Node.js Readable → web ReadableStream so FetchBackend recognises it as a stream (checks for getReader()) and sets duplex */ this[kContext].body = Readable.toWeb(multipartStream); this[kContext].headers.set('Content-Type', `multipart/mixed; boundary=${multipartStream.boundary}`); const _this = this; /* Parsing the multipart response is asynchronous, but the source observable completes synchronously right after emitting the Response event — without this guard, that completion would race ahead of our parsing and reach `subscriber.complete()` before `subscriber.next()` is ever called (surfacing as "no elements in sequence" downstream) */ let handled = false; new HttpInterceptorHandler(backend.interceptors || [], this[kBackend]) .handle(this[kContext]) .subscribe({ next(event) { if (event.type === HttpEventType.Response) { handled = true; const { response } = event; if (response.status >= 400 && response.status < 600) { const error = new ClientError({ message: response.status + ' ' + response.statusText, status: response.status, }); for (const req of requests) req._finalizeError(error); subscriber.error(error); subscriber.complete(); return; } if (!typeIs.is(response.contentType || '', [ MimeTypes.multipart_mixed, ])) { const error = new ClientError({ message: 'Server did not return a multipart/mixed response', status: 500, }); for (const req of requests) req._finalizeError(error); subscriber.error(error); subscriber.complete(); return; } /* Parse multipart/mixed response */ const rawBody = response.body; if (!rawBody?.byteLength) { const fallback = new HttpResponse({ status: 0, statusText: 'No response', }); for (const req of requests) { // @ts-ignore req._finalize(fallback); } subscriber.next([]); subscriber.complete(); return; } /* Match each request by its unique requestId so it can be finalized as soon as its part is parsed, without waiting for the rest of the bundle */ const requestQueue = new Map(requests.map(req => [req.requestId, req])); const responseMap = new Map(); /* Must include the boundary parameter, so use the raw header rather than HttpResponse#contentType (which strips it) */ const contentTypeHeader = response.headers.get('content-type') || ''; /* Parse parts via multipasta; each part is matched and its request finalized as soon as it becomes available */ _this ._parseMultipartParts(Buffer.from(rawBody), contentTypeHeader, part => { const requestId = part.headers['x-request-id']; if (!requestId) return; const partResponse = _this._parseRawHttpResponse(part.body); responseMap.set(requestId, partResponse); // @ts-ignore requestQueue.get(requestId)?._finalize(partResponse); requestQueue.delete(requestId); }) .then(() => { /* Finalize any request whose part never arrived so its subscribers/promises don't hang forever */ for (const req of requestQueue.values()) { const fallback = new HttpResponse({ status: 0, statusText: 'No response', }); responseMap.set(req.requestId, fallback); // @ts-ignore req._finalize(fallback); } /* Emit responses in original request order */ const responses = requests.map(req => responseMap.get(req.requestId)); subscriber.next(responses); subscriber.complete(); }) .catch(err => { for (const req of requests) req._finalizeError(err); subscriber.error(err); }); } }, error(error) { handled = true; for (const req of requests) req._finalizeError(error); subscriber.error(error); }, complete() { /* The Response branch (sync or async) always terminates the subscriber itself; only act as a fallback if it never ran */ if (!handled) subscriber.complete(); }, }); }); Object.defineProperty(this, kBackend, { enumerable: false, value: backend, }); Object.defineProperty(this, kContext, { enumerable: false, value: { ...init, method: 'POST', url: '$bundle', requests, headers: new Headers(init?.headers), }, }); } options(options) { Object.assign(this[kContext], omit(options, ['requests', 'headers'])); return this; } header(arg0, value) { const target = this[kContext].headers; if (typeof arg0 === 'object') { const h = arg0 instanceof Headers ? arg0 : new Headers(arg0); h.forEach((v, k) => { if (k.toLowerCase() === 'set-cookie') { target.append(k, v); } else target.set(k, v); }); return this; } if (value == null || value === '') target.delete(arg0); else target.append(arg0, String(value)); return this; } param(arg0, value) { if (value && typeof value === 'object') { value = JSON.stringify(value); } const target = this[kContext].url.searchParams; if (typeof arg0 === 'object') { if (typeof arg0.forEach === 'function') { arg0.forEach((v, k) => target.set(String(k), String(v))); } else { Object.entries(arg0).forEach(entry => target.set(String(entry[0]), String(entry[1]))); } return this; } if (value == null) target.delete(arg0); else target.set(arg0, String(value)); return this; } getResponses() { return lastValueFrom(this); } /** * Parses a multipart/mixed body buffer into individual parts using multipasta, * invoking `onPart` as soon as each part is fully read rather than buffering * the whole set. Each part contains its headers (lowercased keys) and raw * body buffer. The stream-based approach handles large payloads without * buffering the entire response in memory at once. */ async _parseMultipartParts(buf, contentTypeHeader, onPart) { const stream = MP.make({ headers: { 'content-type': contentTypeHeader }, isFile: () => true, // treat all parts as file streams (no Content-Disposition) }); /* Pipe the buffer into the multipasta Duplex and start it flowing */ Readable.from(buf).pipe(stream); stream.resume(); for await (const part of stream) { /* Flatten headers to lowercase string keys */ const headers = {}; for (const [k, v] of Object.entries(part.info.headers)) { headers[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v ?? ''); } let body; if (part._tag === 'Field') { body = Buffer.from(part.value); } else { /* FileStream — collect chunks via async iteration */ const chunks = []; for await (const chunk of part) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } body = Buffer.concat(chunks); } onPart({ headers, body }); } } /** * Parses a raw HTTP/1.1 response buffer into an {@link HttpResponse} using * `@browsery/http-parser`, mirroring the server-side request parsing in * `@opra/http`'s `IncomingMessageHost.from()`. */ _parseRawHttpResponse(buf) { const parser = new HTTPParser(HTTPParser.RESPONSE); let status = 200; let statusText = 'OK'; const headers = new Headers(); const bodyChunks = []; parser[HTTPParser.kOnHeadersComplete] = (info) => { status = info.statusCode; statusText = info.statusMessage; for (let i = 0; i < info.headers.length; i += 2) { headers.append(info.headers[i], info.headers[i + 1]); } }; parser[HTTPParser.kOnBody] = (chunk, offset, length) => { bodyChunks.push(chunk.subarray(offset, offset + length)); }; parser.execute(buf); parser.finish(); const bodyBuf = Buffer.concat(bodyChunks); let body; if (bodyBuf.length > 0) { const contentType = headers.get('content-type') || ''; if (typeIs.is(contentType, ['json', 'application/*+json'])) { try { body = JSON.parse(bodyBuf.toString('utf-8')); if (typeof body === 'string') body = JSON.parse(body); } catch { body = bodyBuf.toString('utf-8'); } } else if (typeIs.is(contentType, ['text'])) { body = bodyBuf.toString('utf-8'); } else { body = bodyBuf.buffer.slice(bodyBuf.byteOffset, bodyBuf.byteOffset + bodyBuf.byteLength); } } return new HttpResponse({ status, statusText, headers, body }); } }