UNPKG

@whatwg-node/server

Version:

Fetch API compliant HTTP Server adapter

568 lines (567 loc) • 21.8 kB
import { createDeferredPromise, fakePromise, handleMaybePromise, isPromise, } from '@whatwg-node/promise-helpers'; export { isPromise, createDeferredPromise }; export function isAsyncIterable(body) { return (body != null && typeof body === 'object' && typeof body[Symbol.asyncIterator] === 'function'); } function getPort(nodeRequest) { if (nodeRequest.socket?.localPort) { return nodeRequest.socket?.localPort; } const hostInHeader = nodeRequest.headers?.[':authority'] || nodeRequest.headers?.host; const portInHeader = hostInHeader?.split(':')?.[1]; if (portInHeader) { return portInHeader; } return 80; } function getHostnameWithPort(nodeRequest) { if (nodeRequest.headers?.[':authority']) { return nodeRequest.headers?.[':authority']; } if (nodeRequest.headers?.host) { return nodeRequest.headers?.host; } const port = getPort(nodeRequest); if (nodeRequest.hostname) { return nodeRequest.hostname + ':' + port; } const localIp = nodeRequest.socket?.localAddress; if (localIp && !localIp?.includes('::') && !localIp?.includes('ffff')) { return `${localIp}:${port}`; } return 'localhost'; } function buildFullUrl(nodeRequest) { const hostnameWithPort = getHostnameWithPort(nodeRequest); const protocol = nodeRequest.protocol || (nodeRequest.socket?.encrypted ? 'https' : 'http'); const endpoint = nodeRequest.originalUrl || nodeRequest.url || '/graphql'; return `${protocol}://${hostnameWithPort}${endpoint}`; } function isRequestBody(body) { const stringTag = body[Symbol.toStringTag]; if (typeof body === 'string' || stringTag === 'Uint8Array' || stringTag === 'Blob' || stringTag === 'FormData' || stringTag === 'URLSearchParams' || isAsyncIterable(body)) { return true; } return false; } export function normalizeNodeRequest(nodeRequest, fetchAPI, nodeResponse, __useCustomAbortCtrl) { const rawRequest = nodeRequest.raw || nodeRequest.req || nodeRequest; let fullUrl = buildFullUrl(rawRequest); if (nodeRequest.query) { const url = new fetchAPI.URL(fullUrl); for (const key in nodeRequest.query) { url.searchParams.set(key, nodeRequest.query[key]); } fullUrl = url.toString(); } let normalizedHeaders = nodeRequest.headers; if (nodeRequest.headers?.[':method']) { normalizedHeaders = {}; for (const key in nodeRequest.headers) { if (!key.startsWith(':')) { normalizedHeaders[key] = nodeRequest.headers[key]; } } } const controller = __useCustomAbortCtrl ? createCustomAbortControllerSignal() : new AbortController(); if (nodeResponse?.once) { const closeEventListener = () => { if (!controller.signal.aborted) { Object.defineProperty(rawRequest, 'aborted', { value: true }); controller.abort(nodeResponse.errored ?? undefined); } }; nodeResponse.once('error', closeEventListener); nodeResponse.once('close', closeEventListener); nodeResponse.once('finish', () => { nodeResponse.removeListener('close', closeEventListener); }); } if (nodeRequest.method === 'GET' || nodeRequest.method === 'HEAD') { return new fetchAPI.Request(fullUrl, { method: nodeRequest.method, headers: normalizedHeaders, signal: controller.signal, }); } /** * Some Node server frameworks like Serverless Express sends a dummy object with body but as a Buffer not string * so we do those checks to see is there something we can use directly as BodyInit * because the presence of body means the request stream is already consumed and, * rawRequest cannot be used as BodyInit/ReadableStream by Fetch API in this case. */ const maybeParsedBody = nodeRequest.body; if (maybeParsedBody != null && Object.keys(maybeParsedBody).length > 0) { if (isRequestBody(maybeParsedBody)) { return new fetchAPI.Request(fullUrl, { method: nodeRequest.method || 'GET', headers: normalizedHeaders, body: maybeParsedBody, signal: controller.signal, }); } const request = new fetchAPI.Request(fullUrl, { method: nodeRequest.method || 'GET', headers: normalizedHeaders, signal: controller.signal, }); if (!request.headers.get('content-type')?.includes('json')) { request.headers.set('content-type', 'application/json; charset=utf-8'); } return new Proxy(request, { get: (target, prop, receiver) => { switch (prop) { case 'json': return () => fakePromise(maybeParsedBody); case 'text': return () => fakePromise(JSON.stringify(maybeParsedBody)); default: if (globalThis.Bun) { // workaround for https://github.com/oven-sh/bun/issues/12368 // Proxy.get doesn't seem to get `receiver` correctly return Reflect.get(target, prop); } return Reflect.get(target, prop, receiver); } }, }); } // perf: instead of spreading the object, we can just pass it as is and it performs better return new fetchAPI.Request(fullUrl, { method: nodeRequest.method, headers: normalizedHeaders, signal: controller.signal, // @ts-expect-error - AsyncIterable is supported as body body: rawRequest, duplex: 'half', }); } export function isReadable(stream) { return stream.read != null; } export function isNodeRequest(request) { return isReadable(request); } export function isServerResponse(stream) { // Check all used functions are defined return (stream != null && stream.setHeader != null && stream.end != null && stream.once != null && stream.write != null); } export function isReadableStream(stream) { return stream != null && stream.getReader != null; } export function isFetchEvent(event) { return event != null && event.request != null && event.respondWith != null; } function configureSocket(rawRequest) { rawRequest?.socket?.setTimeout?.(0); rawRequest?.socket?.setNoDelay?.(true); rawRequest?.socket?.setKeepAlive?.(true); } function endResponse(serverResponse) { // @ts-expect-error Avoid arguments adaptor trampoline https://v8.dev/blog/adaptor-frame serverResponse.end(null, null, null); } function sendAsyncIterable(serverResponse, asyncIterable) { let closed = false; const closeEventListener = () => { closed = true; }; serverResponse.once('error', closeEventListener); serverResponse.once('close', closeEventListener); serverResponse.once('finish', () => { serverResponse.removeListener('close', closeEventListener); serverResponse.removeListener('error', closeEventListener); }); const iterator = asyncIterable[Symbol.asyncIterator](); const pump = () => iterator.next().then(({ done, value }) => { if (closed || done) { return; } return handleMaybePromise(() => safeWrite(value, serverResponse), () => (closed ? endResponse(serverResponse) : pump())); }); return pump(); } function safeWrite(chunk, serverResponse) { // @ts-expect-error http and http2 writes are actually compatible const result = serverResponse.write(chunk); if (!result) { return new Promise(resolve => serverResponse.once('drain', resolve)); } } export function sendNodeResponse(fetchResponse, serverResponse, nodeRequest, __useSingleWriteHead) { if (serverResponse.closed || serverResponse.destroyed || serverResponse.writableEnded) { return; } if (!fetchResponse) { serverResponse.statusCode = 404; endResponse(serverResponse); return; } if (__useSingleWriteHead && // @ts-expect-error - headersInit is a private property fetchResponse.headers?.headersInit && // @ts-expect-error - headersInit is a private property !Array.isArray(fetchResponse.headers.headersInit) && // @ts-expect-error - headersInit is a private property !fetchResponse.headers.headersInit.get && // @ts-expect-error - map is a private property !fetchResponse.headers._map && // @ts-expect-error - _setCookies is a private property !fetchResponse.headers._setCookies?.length) { serverResponse.writeHead(fetchResponse.status, fetchResponse.statusText, // @ts-expect-error - headersInit is a private property fetchResponse.headers.headersInit); } else { // @ts-expect-error - setHeaders exist if (serverResponse.setHeaders) { // @ts-expect-error - setHeaders exist serverResponse.setHeaders(fetchResponse.headers); } else { let setCookiesSet = false; fetchResponse.headers.forEach((value, key) => { if (key === 'set-cookie') { if (setCookiesSet) { return; } setCookiesSet = true; const setCookies = fetchResponse.headers.getSetCookie?.(); if (setCookies) { serverResponse.setHeader('set-cookie', setCookies); return; } } serverResponse.setHeader(key, value); }); } serverResponse.writeHead(fetchResponse.status, fetchResponse.statusText); } // @ts-expect-error - Handle the case where the response is a string if (fetchResponse['bodyType'] === 'String') { return handleMaybePromise( // @ts-expect-error - bodyInit is a private property () => safeWrite(fetchResponse.bodyInit, serverResponse), () => endResponse(serverResponse)); } // Optimizations for node-fetch const bufOfRes = // @ts-expect-error - _buffer is a private property fetchResponse._buffer; if (bufOfRes) { return handleMaybePromise(() => safeWrite(bufOfRes, serverResponse), () => endResponse(serverResponse)); } // Other fetch implementations const fetchBody = fetchResponse.body; if (fetchBody == null) { endResponse(serverResponse); return; } if ( // @ts-expect-error - Uint8Array is a valid body type fetchBody[Symbol.toStringTag] === 'Uint8Array') { return handleMaybePromise(() => safeWrite(fetchBody, serverResponse), () => endResponse(serverResponse)); } configureSocket(nodeRequest); if (isReadable(fetchBody)) { serverResponse.once('close', () => { fetchBody.destroy(); }); fetchBody.pipe(serverResponse, { end: true, }); return; } if (isReadableStream(fetchBody)) { return sendReadableStream(nodeRequest, serverResponse, fetchBody); } if (isAsyncIterable(fetchBody)) { return sendAsyncIterable(serverResponse, fetchBody); } } function sendReadableStream(nodeRequest, serverResponse, readableStream) { const reader = readableStream.getReader(); nodeRequest?.once?.('error', err => { reader.cancel(err); }); function pump() { return reader .read() .then(({ done, value }) => done ? endResponse(serverResponse) : handleMaybePromise(() => safeWrite(value, serverResponse), pump)); } return pump(); } export function isRequestInit(val) { return (val != null && typeof val === 'object' && ('body' in val || 'cache' in val || 'credentials' in val || 'headers' in val || 'integrity' in val || 'keepalive' in val || 'method' in val || 'mode' in val || 'redirect' in val || 'referrer' in val || 'referrerPolicy' in val || 'signal' in val || 'window' in val)); } // from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#copying_accessors export function completeAssign(...args) { const [target, ...sources] = args.filter(arg => arg != null && typeof arg === 'object'); sources.forEach(source => { // modified Object.keys to Object.getOwnPropertyNames // because Object.keys only returns enumerable properties const descriptors = Object.getOwnPropertyNames(source).reduce((descriptors, key) => { const descriptor = Object.getOwnPropertyDescriptor(source, key); if (descriptor) { descriptors[key] = Object.getOwnPropertyDescriptor(source, key); } return descriptors; }, {}); // By default, Object.assign copies enumerable Symbols, too Object.getOwnPropertySymbols(source).forEach(sym => { const descriptor = Object.getOwnPropertyDescriptor(source, sym); if (descriptor?.enumerable) { descriptors[sym] = descriptor; } }); Object.defineProperties(target, descriptors); }); return target; } export { iterateAsyncVoid } from '@whatwg-node/promise-helpers'; export function handleErrorFromRequestHandler(error, ResponseCtor) { return new ResponseCtor(error.stack || error.message || error.toString(), { status: error.status || 500, }); } export function isolateObject(originalCtx, waitUntilFn) { if (originalCtx == null) { if (waitUntilFn == null) { return {}; } return { waitUntil: waitUntilFn, }; } return completeAssign(Object.create(originalCtx), { waitUntil: waitUntilFn, }, originalCtx); } export function handleAbortSignalAndPromiseResponse(response$, abortSignal) { if (abortSignal?.aborted) { throw abortSignal.reason; } if (isPromise(response$) && abortSignal) { const deferred$ = createDeferredPromise(); function abortSignalFetchErrorHandler() { deferred$.reject(abortSignal.reason); } abortSignal.addEventListener('abort', abortSignalFetchErrorHandler, { once: true }); response$ .then(function fetchSuccessHandler(res) { deferred$.resolve(res); }) .catch(function fetchErrorHandler(err) { deferred$.reject(err); }) .finally(() => { abortSignal.removeEventListener('abort', abortSignalFetchErrorHandler); }); return deferred$.promise; } return response$; } export const decompressedResponseMap = new WeakMap(); const supportedEncodingsByFetchAPI = new WeakMap(); export function getSupportedEncodings(fetchAPI) { let supportedEncodings = supportedEncodingsByFetchAPI.get(fetchAPI); if (!supportedEncodings) { const possibleEncodings = ['deflate', 'gzip', 'deflate-raw', 'br']; if (fetchAPI.DecompressionStream?.['supportedFormats']) { supportedEncodings = fetchAPI.DecompressionStream['supportedFormats']; } else { supportedEncodings = possibleEncodings.filter(encoding => { // deflate-raw is not supported in Node.js >v20 if (globalThis.process?.version?.startsWith('v2') && fetchAPI.DecompressionStream === globalThis.DecompressionStream && encoding === 'deflate-raw') { return false; } try { // eslint-disable-next-line no-new new fetchAPI.DecompressionStream(encoding); return true; } catch { return false; } }); } supportedEncodingsByFetchAPI.set(fetchAPI, supportedEncodings); } return supportedEncodings; } export function handleResponseDecompression(response, fetchAPI) { const contentEncodingHeader = response?.headers.get('content-encoding'); if (!contentEncodingHeader || contentEncodingHeader === 'none') { return response; } if (!response?.body) { return response; } let decompressedResponse = decompressedResponseMap.get(response); if (!decompressedResponse || decompressedResponse.bodyUsed) { let decompressedBody = response.body; const contentEncodings = contentEncodingHeader.split(','); if (!contentEncodings.every(encoding => getSupportedEncodings(fetchAPI).includes(encoding))) { return new fetchAPI.Response(`Unsupported 'Content-Encoding': ${contentEncodingHeader}`, { status: 415, statusText: 'Unsupported Media Type', }); } for (const contentEncoding of contentEncodings) { decompressedBody = decompressedBody.pipeThrough(new fetchAPI.DecompressionStream(contentEncoding)); } decompressedResponse = new fetchAPI.Response(decompressedBody, response); decompressedResponseMap.set(response, decompressedResponse); } return decompressedResponse; } const terminateEvents = ['SIGINT', 'exit', 'SIGTERM']; const disposableStacks = new Set(); let eventListenerRegistered = false; function ensureEventListenerForDisposableStacks() { if (eventListenerRegistered) { return; } eventListenerRegistered = true; for (const event of terminateEvents) { globalThis.process.once(event, function terminateHandler() { return Promise.allSettled([...disposableStacks].map(stack => !stack.disposed && stack.disposeAsync())); }); } } export function ensureDisposableStackRegisteredForTerminateEvents(disposableStack) { if (globalThis.process) { ensureEventListenerForDisposableStacks(); if (!disposableStacks.has(disposableStack)) { disposableStacks.add(disposableStack); disposableStack.defer(() => { disposableStacks.delete(disposableStack); }); } } } class CustomAbortControllerSignal extends EventTarget { aborted = false; _onabort = null; _reason; constructor() { super(); const nodeEvents = globalThis.process?.getBuiltinModule?.('node:events'); // @ts-expect-error - We know kMaxEventTargetListeners is available in node:events if (nodeEvents?.kMaxEventTargetListeners) { // @ts-expect-error - See https://github.com/nodejs/node/pull/55816/files#diff-03bd4f07a1006cb0daaddced702858751b20f5ab7681cb0719c1b1d80d6ca05cR31 this[nodeEvents.kMaxEventTargetListeners] = 0; } } throwIfAborted() { if (this._nativeCtrl?.signal?.throwIfAborted) { return this._nativeCtrl.signal.throwIfAborted(); } if (this.aborted) { throw this._reason; } } _nativeCtrl; ensureNativeCtrl() { if (!this._nativeCtrl) { const isAborted = this.aborted; this._nativeCtrl = new AbortController(); if (isAborted) { this._nativeCtrl.abort(this._reason); } } return this._nativeCtrl; } abort(reason) { if (this._nativeCtrl?.abort) { return this._nativeCtrl?.abort(reason); } this._reason = reason || new DOMException('This operation was aborted', 'AbortError'); this.aborted = true; this.dispatchEvent(new Event('abort')); } get signal() { if (this._nativeCtrl?.signal) { return this._nativeCtrl.signal; } return this; } get reason() { if (this._nativeCtrl?.signal) { return this._nativeCtrl.signal.reason; } return this._reason; } get onabort() { if (this._onabort) { return this._onabort; } return this._onabort; } set onabort(value) { if (this._nativeCtrl?.signal) { this._nativeCtrl.signal.onabort = value; return; } if (this._onabort) { this.removeEventListener('abort', this._onabort); } this._onabort = value; if (value) { this.addEventListener('abort', value); } } } export function createCustomAbortControllerSignal() { if (globalThis.Bun || globalThis.Deno) { return new AbortController(); } return new Proxy(new CustomAbortControllerSignal(), { get(target, prop, receiver) { if (prop.toString().includes('kDependantSignals')) { const nativeCtrl = target.ensureNativeCtrl(); return Reflect.get(nativeCtrl.signal, prop, nativeCtrl.signal); } return Reflect.get(target, prop, receiver); }, set(target, prop, value, receiver) { if (prop.toString().includes('kDependantSignals')) { const nativeCtrl = target.ensureNativeCtrl(); return Reflect.set(nativeCtrl.signal, prop, value, nativeCtrl.signal); } return Reflect.set(target, prop, value, receiver); }, getPrototypeOf() { return AbortSignal.prototype; }, }); }