UNPKG

@whatwg-node/server

Version:

Fetch API compliant HTTP Server adapter

350 lines (349 loc) • 11.9 kB
import { fakePromise } from '@whatwg-node/promise-helpers'; export function isUWSResponse(res) { return !!res.onData; } const uwsDrainUnreadBodyByRequest = new WeakMap(); /** * If the handler never read the body, stop retaining upload chunks while keeping the * existing `onData` subscription so uWS can finish delivering the request. */ export function discardUnreadUWSRequestBody(request) { uwsDrainUnreadBodyByRequest.get(request)?.(); } export function getRequestFromUWSRequest({ req, res, fetchAPI, controller, }) { const method = req.getMethod(); let duplex; const chunks = []; const pushFns = [ (chunk) => { chunks.push(chunk); }, ]; const push = (chunk) => { for (const pushFn of pushFns) { pushFn(chunk); } }; let stopped = false; const stopFns = [ () => { stopped = true; }, ]; const stop = () => { for (const stopFn of stopFns) { stopFn(); } }; // uWebSockets.js requires onData to be attached synchronously before any await in the // route handler; otherwise upload chunks can be lost. Always subscribe (including GET/HEAD // so empty-body completion still marks `stopped` for text/json helpers), then // {@link discardUnreadUWSRequestBody} can stop retaining chunks if unused. let discarding = false; // bodyHeld: body was obtained or a consumer was attached — skip unread discard. // bodyConsumed: Fetch `bodyUsed` — true only after a body method runs or a stream is locked. let bodyHeld = false; let bodyConsumed = false; res.onData(function (ab, isLast) { if (!discarding && !stopped) { push(Buffer.from(Buffer.from(ab, 0, ab.byteLength))); } if (isLast) { stop(); } }); function createChunkReadableStream() { return new fetchAPI.ReadableStream({ start(streamCtrl) { for (const chunk of chunks) { streamCtrl.enqueue(chunk); } if (stopped) { streamCtrl.close(); return; } pushFns.push((chunk) => { streamCtrl.enqueue(chunk); }); stopFns.push(() => { if (controller.signal.reason) { streamCtrl.error(controller.signal.reason); return; } if (streamCtrl.desiredSize) { streamCtrl.close(); } }); }, }); } let getReadableStream; if (method !== 'get' && method !== 'head') { duplex = 'half'; controller.signal.addEventListener('abort', () => { stop(); }, { once: true }); getReadableStream = createChunkReadableStream; } const headers = new fetchAPI.Headers(); req.forEach((key, value) => { headers.append(key, value); }); let url = `http://localhost${req.getUrl()}`; const query = req.getQuery(); if (query) { url += `?${query}`; } let buffer; // Do not pass `body` in Request init: some Fetch implementations read it during construction // and would start consuming before the handler runs. const request = new fetchAPI.Request(url, { method, headers, signal: controller.signal, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - not in the TS types yet duplex, }); function getBufferFromChunks() { if (!buffer) { buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks); } return buffer; } function collectBuffer() { bodyHeld = true; bodyConsumed = true; if (!getReadableStream) { return fakePromise(Buffer.alloc(0)); } if (stopped) { return fakePromise(getBufferFromChunks()); } return new Promise((resolve, reject) => { try { stopFns.push(() => { resolve(getBufferFromChunks()); }); } catch (e) { reject(e); } }); } function isBodyUsed(stream) { return bodyConsumed || !!stream?.locked; } function installBodyAccessors(target) { // Each Request (original vs clone) gets its own stream so consumers do not share one branch. let readableStream; function getBody() { if (!getReadableStream) { return null; } bodyHeld = true; if (stopped) { return getBufferFromChunks(); } if (!readableStream) { readableStream = createChunkReadableStream(); } return readableStream; } Object.defineProperties(target, { body: { get() { return getBody(); }, configurable: true, enumerable: true, }, bodyUsed: { get() { return isBodyUsed(readableStream); }, configurable: true, enumerable: true, }, json: { value() { return collectBuffer() .then(b => b.toString('utf8')) .then(t => JSON.parse(t)); }, configurable: true, enumerable: true, }, text: { value() { return collectBuffer().then(b => b.toString('utf8')); }, configurable: true, enumerable: true, }, arrayBuffer: { value() { return collectBuffer(); }, configurable: true, enumerable: true, }, bytes: { value() { return collectBuffer().then(b => new Uint8Array(b)); }, configurable: true, enumerable: true, }, blob: { value() { return collectBuffer().then(b => new fetchAPI.Blob([new Uint8Array(b)])); }, configurable: true, enumerable: true, }, formData: { value() { bodyHeld = true; bodyConsumed = true; if (!getReadableStream) { return fakePromise(new fetchAPI.FormData()); } // Stream path so abort rejects with aborted/closed instead of parsing a partial body. return new fetchAPI.Request(url, { method: method || 'POST', headers, body: getBody(), signal: controller.signal, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - not in the TS types yet duplex: 'half', }).formData(); }, configurable: true, enumerable: true, }, clone: { value() { if (isBodyUsed(readableStream)) { throw new TypeError('Body has already been consumed.'); } const cloned = new fetchAPI.Request(url, { method, headers, signal: controller.signal, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - not in the TS types yet duplex, }); installBodyAccessors(cloned); if (getReadableStream) { uwsDrainUnreadBodyByRequest.set(cloned, () => { if (bodyHeld) { return; } discarding = true; chunks.length = 0; buffer = undefined; }); } return cloned; }, configurable: true, enumerable: true, }, }); } installBodyAccessors(request); if (getReadableStream) { uwsDrainUnreadBodyByRequest.set(request, () => { if (bodyHeld) { return; } // Keep onData attached (uWS may still deliver chunks after end) but stop retaining them. discarding = true; chunks.length = 0; buffer = undefined; }); } return request; } export function createWritableFromUWS(uwsResponse, fetchAPI) { return new fetchAPI.WritableStream({ write(chunk) { uwsResponse.cork(() => { uwsResponse.write(chunk); }); }, close() { uwsResponse.cork(() => { uwsResponse.end(); }); }, }); } export function sendResponseToUwsOpts(uwsResponse, fetchResponse, controller, fetchAPI) { if (!fetchResponse) { uwsResponse.writeStatus('404 Not Found'); uwsResponse.end(); return; } const bufferOfRes = fetchResponse._buffer; // @ts-expect-error - Handle the case where the response is a string const strBody = fetchResponse['bodyType'] === 'String' ? fetchResponse.bodyInit : undefined; if (controller.signal.aborted) { return; } uwsResponse.cork(() => { uwsResponse.writeStatus(`${fetchResponse.status} ${fetchResponse.statusText}`); let isSetCookieHandled = false; for (const [key, value] of fetchResponse.headers) { // content-length causes an error with Node.js's fetch // transfer-encoding is handled automatically by uWebSockets.js if (key !== 'content-length' && key !== 'transfer-encoding') { if (key === 'set-cookie') { if (isSetCookieHandled) { continue; } isSetCookieHandled = true; const setCookies = fetchResponse.headers.getSetCookie?.(); if (setCookies) { for (const setCookie of setCookies) { uwsResponse.writeHeader(key, setCookie); } continue; } } uwsResponse.writeHeader(key, value); } } if (strBody) { uwsResponse.end(strBody); } else if (bufferOfRes) { uwsResponse.end(bufferOfRes); } else if (!fetchResponse.body) { uwsResponse.end(); } }); if (strBody || bufferOfRes || !fetchResponse.body) { return; } controller.signal.addEventListener('abort', () => { if (!fetchResponse.body?.locked) { fetchResponse.body?.cancel(controller.signal.reason); } }, { once: true }); return fetchResponse.body .pipeTo(createWritableFromUWS(uwsResponse, fetchAPI), { signal: controller.signal, }) .catch(err => { if (controller.signal.aborted) { return; } throw err; }); } export { fakePromise };