UNPKG

@remix-run/node-fetch-server

Version:
182 lines (181 loc) 7.13 kB
import { readStream } from "./read-stream.js"; /** * Wraps a fetch handler in a Node.js request listener that can be used with: * * - [`http.createServer()`](https://nodejs.org/api/http.html#httpcreateserveroptions-requestlistener) * - [`https.createServer()`](https://nodejs.org/api/https.html#httpscreateserveroptions-requestlistener) * - [`http2.createServer()`](https://nodejs.org/api/http2.html#http2createserveroptions-onrequesthandler) * - [`http2.createSecureServer()`](https://nodejs.org/api/http2.html#http2createsecureserveroptions-onrequesthandler) * * Example: * * ```ts * import * as http from 'node:http'; * import { createRequestListener } from '@remix-run/node-fetch-server'; * * async function handler(request) { * return new Response('Hello, world!'); * } * * let server = http.createServer( * createRequestListener(handler) * ); * * server.listen(3000); * ``` * * @param handler The fetch handler to use for processing incoming requests * @param options Request listener options * @returns A Node.js request listener function */ export function createRequestListener(handler, options) { let onError = options?.onError ?? defaultErrorHandler; return async (req, res) => { let request = createRequest(req, res, options); let client = { address: req.socket.remoteAddress, family: req.socket.remoteFamily, port: req.socket.remotePort, }; let response; try { response = await handler(request, client); } catch (error) { try { response = (await onError(error)) ?? internalServerError(); } catch (error) { console.error(`There was an error in the error handler: ${error}`); response = internalServerError(); } } await sendResponse(res, response); }; } function defaultErrorHandler(error) { console.error(error); return internalServerError(); } function internalServerError() { return new Response( // "Internal Server Error" new Uint8Array([ 73, 110, 116, 101, 114, 110, 97, 108, 32, 83, 101, 114, 118, 101, 114, 32, 69, 114, 114, 111, 114, ]), { status: 500, headers: { 'Content-Type': 'text/plain', }, }); } /** * Creates a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) object from * * - a [`http.IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage)/[`http.ServerResponse`](https://nodejs.org/api/http.html#class-httpserverresponse) pair * - a [`http2.Http2ServerRequest`](https://nodejs.org/api/http2.html#class-http2http2serverrequest)/[`http2.Http2ServerResponse`](https://nodejs.org/api/http2.html#class-http2http2serverresponse) pair * * @param req The incoming request object * @param res The server response object * @param options Options for creating the request * @returns A `Request` object */ export function createRequest(req, res, options) { let controller = new AbortController(); // Abort once we can no longer write a response if we have // not yet sent a response (i.e., `close` without `finish`) // `finish` -> done rendering the response // `close` -> response can no longer be written to res.once('close', () => controller?.abort()); res.once('finish', () => (controller = null)); let method = req.method ?? 'GET'; let headers = createHeaders(req); let protocol = options?.protocol ?? ('encrypted' in req.socket && req.socket.encrypted ? 'https:' : 'http:'); let host = options?.host ?? headers.get('Host') ?? req.headers[':authority'] ?? 'localhost'; let url = new URL(req.url, `${protocol}//${host}`); let init = { method, headers, signal: controller.signal }; if (method !== 'GET' && method !== 'HEAD') { init.body = new ReadableStream({ start(controller) { req.on('data', (chunk) => { controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); }); req.on('end', () => { controller.close(); }); }, }); init.duplex = 'half'; } return new Request(url, init); } /** * Creates a [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object from the headers in a Node.js * [`http.IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage)/[`http2.Http2ServerRequest`](https://nodejs.org/api/http2.html#class-http2http2serverrequest). * * @param req The incoming request object * @returns A `Headers` object */ export function createHeaders(req) { let headers = new Headers(); let rawHeaders = req.rawHeaders; for (let i = 0; i < rawHeaders.length; i += 2) { if (rawHeaders[i].startsWith(':')) continue; headers.append(rawHeaders[i], rawHeaders[i + 1]); } return headers; } /** * Sends a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to the client using a Node.js * [`http.ServerResponse`](https://nodejs.org/api/http.html#class-httpserverresponse)/[`http2.Http2ServerResponse`](https://nodejs.org/api/http2.html#class-http2http2serverresponse) * object. * * @param res The server response object * @param response The response to send */ export async function sendResponse(res, response) { // Iterate over response.headers so we are sure to send multiple Set-Cookie headers correctly. // These would incorrectly be merged into a single header if we tried to use // `Object.fromEntries(response.headers.entries())`. let headers = {}; for (let [key, value] of response.headers) { if (key in headers) { if (Array.isArray(headers[key])) { headers[key].push(value); } else { headers[key] = [headers[key], value]; } } else { headers[key] = value; } } if (res.req.httpVersionMajor === 1) { ; res.writeHead(response.status, response.statusText, headers); } else { // HTTP/2 doesn't support status messages // https://datatracker.ietf.org/doc/html/rfc7540#section-8.1.2.4 // // HTTP2 `res.writeHead()` will safely ignore the statusText parameter, but // it will emit a warning which we want to avoid. // https://nodejs.org/docs/latest-v22.x/api/http2.html#responsewriteheadstatuscode-statusmessage-headers ; res.writeHead(response.status, headers); } if (response.body != null && res.req.method !== 'HEAD') { for await (let chunk of readStream(response.body)) { // @ts-expect-error - Node typings for http2 require a 2nd parameter to write but it's optional if (res.write(chunk) === false) { await new Promise((resolve) => { res.once('drain', resolve); }); } } } res.end(); }