@whatwg-node/server
Version:
Fetch API compliant HTTP Server adapter
408 lines (407 loc) • 18 kB
JavaScript
import { chain, getInstrumented } from '@envelop/instrumentation';
import { AsyncDisposableStack, DisposableSymbols } from '@whatwg-node/disposablestack';
import * as DefaultFetchAPI from '@whatwg-node/fetch';
import { handleMaybePromise, unfakePromise } from '@whatwg-node/promise-helpers';
import { completeAssign, createCustomAbortControllerSignal, discardUnreadNodeRequestBody, ensureDisposableStackRegisteredForTerminateEvents, handleAbortSignalAndPromiseResponse, handleErrorFromRequestHandler, isFetchEvent, isNodeRequest, isolateObject, isPromise, isRequestInit, isServerResponse, iterateAsyncVoid, normalizeNodeRequest, sendNodeResponse, } from './utils.js';
import { discardUnreadUWSRequestBody, fakePromise, getRequestFromUWSRequest, isUWSResponse, sendResponseToUwsOpts, } from './uwebsockets.js';
// Required for envs like nextjs edge runtime
function isRequestAccessible(serverContext) {
try {
return !!serverContext?.request;
}
catch {
return false;
}
}
const EMPTY_OBJECT = {};
const nativeFetchAPI = {
fetch: globalThis.fetch,
Request: globalThis.Request,
Response: globalThis.Response,
Headers: globalThis.Headers,
FormData: globalThis.FormData,
ReadableStream: globalThis.ReadableStream,
WritableStream: globalThis.WritableStream,
TransformStream: globalThis.TransformStream,
CompressionStream: globalThis.CompressionStream,
DecompressionStream: globalThis.DecompressionStream,
TextDecoderStream: globalThis.TextDecoderStream,
TextEncoderStream: globalThis.TextEncoderStream,
Blob: globalThis.Blob,
File: globalThis.File,
btoa: globalThis.btoa,
TextEncoder: globalThis.TextEncoder,
TextDecoder: globalThis.TextDecoder,
URLPattern: globalThis.URLPattern || DefaultFetchAPI.URLPattern,
URL: globalThis.URL,
URLSearchParams: globalThis.URLSearchParams,
};
function createServerAdapter(serverAdapterBaseObject, options) {
const useSingleWriteHead = options?.__useSingleWriteHead == null ? true : options.__useSingleWriteHead;
const expectedFetchAPI = {
...DefaultFetchAPI,
...options?.fetchAPI,
};
function pickRightFetchAPI(req) {
if (req.constructor === expectedFetchAPI.Request) {
return expectedFetchAPI;
}
if (req.constructor === globalThis.Request) {
return nativeFetchAPI;
}
// TODO: This might need a normalization later, but for now return the ponyfill
return expectedFetchAPI;
}
const givenHandleRequest = typeof serverAdapterBaseObject === 'function'
? serverAdapterBaseObject
: serverAdapterBaseObject.handle;
const onRequestHooks = [];
const onResponseHooks = [];
let instrumentation;
const waitUntilPromises = new Set();
let _disposableStack;
function ensureDisposableStack() {
if (!_disposableStack) {
_disposableStack = new AsyncDisposableStack();
if (options?.disposeOnProcessTerminate) {
ensureDisposableStackRegisteredForTerminateEvents(_disposableStack);
}
_disposableStack.defer(() => {
if (waitUntilPromises.size > 0) {
return Promise.allSettled(waitUntilPromises).then(() => {
waitUntilPromises.clear();
}, () => {
waitUntilPromises.clear();
});
}
});
}
return _disposableStack;
}
function waitUntil(maybePromise) {
// Ensure that the disposable stack is created
if (isPromise(maybePromise)) {
ensureDisposableStack();
waitUntilPromises.add(maybePromise);
maybePromise.then(() => {
waitUntilPromises.delete(maybePromise);
}, err => {
console.error(`Unexpected error while waiting: ${err.message || err}`);
waitUntilPromises.delete(maybePromise);
});
}
}
if (options?.plugins != null) {
for (const plugin of options.plugins) {
if (plugin.instrumentation) {
instrumentation = instrumentation
? chain(instrumentation, plugin.instrumentation)
: plugin.instrumentation;
}
if (plugin.onRequest) {
onRequestHooks.push(plugin.onRequest);
}
if (plugin.onResponse) {
onResponseHooks.push(plugin.onResponse);
}
const disposeFn = plugin[DisposableSymbols.dispose];
if (disposeFn) {
ensureDisposableStack().defer(disposeFn);
}
const asyncDisposeFn = plugin[DisposableSymbols.asyncDispose];
if (asyncDisposeFn) {
ensureDisposableStack().defer(asyncDisposeFn);
}
if (plugin.onDispose) {
ensureDisposableStack().defer(plugin.onDispose);
}
}
}
let handleRequest = onRequestHooks.length > 0 || onResponseHooks.length > 0
? function handleRequest(request, serverContext) {
const fetchAPI = pickRightFetchAPI(request);
let requestHandler = givenHandleRequest;
let response;
if (onRequestHooks.length === 0) {
return handleEarlyResponse();
}
let url = request['parsedUrl'] ||
new Proxy(EMPTY_OBJECT, {
get(_target, prop, _receiver) {
url = new fetchAPI.URL(request.url, 'http://localhost');
return Reflect.get(url, prop, url);
},
});
function handleResponse(response) {
if (onResponseHooks.length === 0) {
return response;
}
return handleMaybePromise(() => iterateAsyncVoid(onResponseHooks, onResponseHook => onResponseHook({
request,
response,
serverContext,
setResponse(newResponse) {
response = newResponse;
},
fetchAPI,
})), () => response);
}
function handleEarlyResponse() {
if (!response) {
return handleMaybePromise(() => requestHandler(request, serverContext, fetchAPI), handleResponse);
}
return handleResponse(response);
}
return handleMaybePromise(() => iterateAsyncVoid(onRequestHooks, (onRequestHook, stopEarly) => onRequestHook({
request,
setRequest(newRequest) {
request = newRequest;
},
serverContext,
fetchAPI,
url,
requestHandler,
setRequestHandler(newRequestHandler) {
requestHandler = newRequestHandler;
},
endResponse(newResponse) {
response = newResponse;
if (newResponse) {
stopEarly();
}
},
})), handleEarlyResponse);
}
: function handleRequest(request, serverContext) {
return givenHandleRequest(request, serverContext, pickRightFetchAPI(request));
};
if (instrumentation?.request) {
const originalRequestHandler = handleRequest;
handleRequest = (request, initialContext, fetchAPI) => {
return getInstrumented({ request }).asyncFn(instrumentation.request, originalRequestHandler)(request, initialContext, fetchAPI);
};
}
// This is a case for Node, so expected Fetch API can be used
const useCustomAbortCtrl = options?.__useCustomAbortCtrl == null
? expectedFetchAPI.Request !== globalThis.Request
: options.__useCustomAbortCtrl;
function handleNodeRequestAndResponse(nodeRequest, nodeResponseOrContainer, ...ctx) {
const nodeResponse = nodeResponseOrContainer.raw || nodeResponseOrContainer;
const serverContext = ctx.length > 1 ? completeAssign(...ctx) : ctx[0] || {};
// Ensure `waitUntil` is available in the server context
if (!serverContext.waitUntil) {
serverContext.waitUntil = waitUntil;
}
const request = normalizeNodeRequest(nodeRequest, expectedFetchAPI, nodeResponse, useCustomAbortCtrl);
return handleMaybePromise(() => handleRequest(request, serverContext, expectedFetchAPI), response => {
// Framework integrations (Fastify/Koa/Hapi) send the Response themselves; still discard
// unread Node request bytes so keep-alive is not blocked after early endResponse.
discardUnreadNodeRequestBody(nodeRequest, response, request);
return response;
}, err => {
discardUnreadNodeRequestBody(nodeRequest);
throw err;
});
}
function requestListener(nodeRequest, nodeResponse, ...ctx) {
const defaultServerContext = {
req: nodeRequest,
res: nodeResponse,
waitUntil,
};
return unfakePromise(fakePromise()
.then(() => handleNodeRequestAndResponse(nodeRequest, nodeResponse, defaultServerContext, ...ctx))
// For request listener case, the expected fetchAPI can be used
.catch(err => handleErrorFromRequestHandler(err, expectedFetchAPI.Response))
.then(response => sendNodeResponse(response, nodeResponse, nodeRequest, useSingleWriteHead))
.catch(err => console.error(`Unexpected error while handling request: ${err.message || err}`)));
}
function handleUWS(res, req, ...ctx) {
const defaultServerContext = {
res,
req,
waitUntil,
};
const filteredCtxParts = ctx.filter(partCtx => partCtx != null);
const serverContext = filteredCtxParts.length > 0
? completeAssign(defaultServerContext, ...ctx)
: defaultServerContext;
const controller = useCustomAbortCtrl
? createCustomAbortControllerSignal()
: new AbortController();
const originalResEnd = res.end.bind(res);
let resEnded = false;
res.end = function (data) {
resEnded = true;
return originalResEnd(data);
};
const originalOnAborted = res.onAborted.bind(res);
originalOnAborted(function () {
controller.abort();
});
res.onAborted = function (cb) {
controller.signal.addEventListener('abort', cb, { once: true });
};
// For this case, picking a different Fetch API is not needed
const request = getRequestFromUWSRequest({
req,
res,
fetchAPI: expectedFetchAPI,
controller,
});
return handleMaybePromise(() => handleMaybePromise(() => handleRequest(request, serverContext, expectedFetchAPI), response => response, err => handleErrorFromRequestHandler(err, expectedFetchAPI.Response)), response => {
if (!controller.signal.aborted && !resEnded) {
// If the handler never touched the body, drop buffered upload chunks before writing
// so long-lived / streaming responses do not retain an unread body in memory.
discardUnreadUWSRequestBody(request);
return handleMaybePromise(() => sendResponseToUwsOpts(res, response, controller, expectedFetchAPI), r => r, err => {
console.error(`Unexpected error while handling request: ${err.message || err}`);
});
}
});
}
function handleEvent(event, ...ctx) {
if (!event.respondWith || !event.request) {
throw new TypeError(`Expected FetchEvent, got ${event}`);
}
const filteredCtxParts = ctx.filter(partCtx => partCtx != null);
const serverContext = filteredCtxParts.length > 0
? completeAssign({}, event, ...filteredCtxParts)
: isolateObject(event);
const response$ = handleRequest(event.request, serverContext, expectedFetchAPI);
event.respondWith(response$);
}
function handleRequestWithWaitUntil(request, ...ctx) {
const filteredCtxParts = ctx.filter(partCtx => partCtx != null);
const serverContext = filteredCtxParts.length > 1
? completeAssign({}, ...filteredCtxParts)
: isolateObject(filteredCtxParts[0], filteredCtxParts[0] == null || filteredCtxParts[0].waitUntil == null
? waitUntil
: undefined);
return handleRequest(request, serverContext, expectedFetchAPI);
}
const fetchFn = (input, ...maybeCtx) => {
if (typeof input === 'string' || 'href' in input) {
const [initOrCtx, ...restOfCtx] = maybeCtx;
if (isRequestInit(initOrCtx)) {
// For this case, it is not possible to get a native Request object
// So it is ok to pick the expected one
const request = new expectedFetchAPI.Request(input, initOrCtx);
const res$ = handleRequestWithWaitUntil(request, ...restOfCtx);
const signal = initOrCtx.signal;
if (signal) {
return handleAbortSignalAndPromiseResponse(res$, signal);
}
return res$;
}
const request = new expectedFetchAPI.Request(input);
return handleRequestWithWaitUntil(request, ...maybeCtx);
}
const res$ = handleRequestWithWaitUntil(input, ...maybeCtx);
return handleAbortSignalAndPromiseResponse(res$, input.signal);
};
const genericRequestHandler = (input, ...maybeCtx) => {
// If it is a Node request
const [initOrCtxOrRes, ...restOfCtx] = maybeCtx;
if (isNodeRequest(input)) {
if (!isServerResponse(initOrCtxOrRes)) {
throw new TypeError(`Expected ServerResponse, got ${initOrCtxOrRes}`);
}
return requestListener(input, initOrCtxOrRes, ...restOfCtx);
}
if (isUWSResponse(input)) {
return handleUWS(input, initOrCtxOrRes, ...restOfCtx);
}
if (isServerResponse(initOrCtxOrRes)) {
throw new TypeError('Got Node response without Node request');
}
// Is input a container object over Request?
if (isRequestAccessible(input)) {
// Is it FetchEvent?
if (isFetchEvent(input)) {
return handleEvent(input, ...maybeCtx);
}
// In this input is also the context
return handleRequestWithWaitUntil(input.request, input, ...maybeCtx);
}
// Or is it Request itself?
// Then ctx is present and it is the context
return fetchFn(input, ...maybeCtx);
};
const adapterObj = {
handleRequest: handleRequestWithWaitUntil,
fetch: fetchFn,
handleNodeRequestAndResponse,
requestListener,
handleEvent,
handleUWS,
handle: genericRequestHandler,
get disposableStack() {
return ensureDisposableStack();
},
[DisposableSymbols.asyncDispose]() {
if (_disposableStack && !_disposableStack.disposed) {
return _disposableStack.disposeAsync();
}
return fakePromise();
},
dispose() {
if (_disposableStack && !_disposableStack.disposed) {
return _disposableStack.disposeAsync();
}
return fakePromise();
},
waitUntil,
};
const serverAdapter = new Proxy(genericRequestHandler, {
// It should have all the attributes of the handler function and the server instance
has: (_, prop) => {
return (prop in adapterObj ||
prop in genericRequestHandler ||
(serverAdapterBaseObject && prop in serverAdapterBaseObject));
},
get: (_, prop) => {
// Somehow Deno and Node 24 don't like bound dispose functions
if (globalThis.Deno || prop === Symbol.asyncDispose || prop === Symbol.dispose) {
const adapterProp = Reflect.get(adapterObj, prop, adapterObj);
if (adapterProp) {
return adapterProp;
}
}
const adapterProp = adapterObj[prop];
if (adapterProp) {
if (adapterProp.bind) {
return adapterProp.bind(adapterObj);
}
return adapterProp;
}
const handleProp = genericRequestHandler[prop];
if (handleProp) {
if (handleProp.bind) {
return handleProp.bind(genericRequestHandler);
}
return handleProp;
}
if (serverAdapterBaseObject) {
const serverAdapterBaseObjectProp = serverAdapterBaseObject[prop];
if (serverAdapterBaseObjectProp) {
if (serverAdapterBaseObjectProp.bind) {
return function (...args) {
const returnedVal = serverAdapterBaseObject[prop](...args);
if (returnedVal === serverAdapterBaseObject) {
return serverAdapter;
}
return returnedVal;
};
}
return serverAdapterBaseObjectProp;
}
}
},
apply(_, __, args) {
return genericRequestHandler(...args);
},
});
return serverAdapter;
}
export { createServerAdapter };