UNPKG

fetch-to-node

Version:

Node.js-compatible request and response objects for WinterTC runtimes

391 lines (390 loc) 16.3 kB
/* * Copyright Michael Hart * Licensed under the MIT license. See LICENSE file for details. * * Portions of this file Copyright Fastly, Inc. See LICENSE file for details. * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. */ import { Buffer } from "node:buffer"; import { ERR_HTTP_HEADERS_SENT, ERR_HTTP_INVALID_STATUS_CODE, ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_INVALID_CHAR, ERR_METHOD_NOT_IMPLEMENTED, } from "../utils/errors.js"; import { FetchOutgoingMessage, } from "./http-outgoing.js"; import { chunkExpression } from "./http-common.js"; import { FetchIncomingMessage } from "./http-incoming.js"; import { kOutHeaders } from "./internal-http.js"; import { validateLinkHeaderValue } from "../utils/types.js"; /* These items copied from Node.js: node/lib/_http_common.js. */ const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; /** * True if val contains an invalid field-vchar * field-value = *( field-content / obs-fold ) * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] * field-vchar = VCHAR / obs-text */ function checkInvalidHeaderChar(val) { return headerCharRegex.test(val); } // This file modeled after Node.js - node/lib/_http_server.js export const STATUS_CODES = { 100: "Continue", // RFC 7231 6.2.1 101: "Switching Protocols", // RFC 7231 6.2.2 102: "Processing", // RFC 2518 10.1 (obsoleted by RFC 4918) 103: "Early Hints", // RFC 8297 2 200: "OK", // RFC 7231 6.3.1 201: "Created", // RFC 7231 6.3.2 202: "Accepted", // RFC 7231 6.3.3 203: "Non-Authoritative Information", // RFC 7231 6.3.4 204: "No Content", // RFC 7231 6.3.5 205: "Reset Content", // RFC 7231 6.3.6 206: "Partial Content", // RFC 7233 4.1 207: "Multi-Status", // RFC 4918 11.1 208: "Already Reported", // RFC 5842 7.1 226: "IM Used", // RFC 3229 10.4.1 300: "Multiple Choices", // RFC 7231 6.4.1 301: "Moved Permanently", // RFC 7231 6.4.2 302: "Found", // RFC 7231 6.4.3 303: "See Other", // RFC 7231 6.4.4 304: "Not Modified", // RFC 7232 4.1 305: "Use Proxy", // RFC 7231 6.4.5 307: "Temporary Redirect", // RFC 7231 6.4.7 308: "Permanent Redirect", // RFC 7238 3 400: "Bad Request", // RFC 7231 6.5.1 401: "Unauthorized", // RFC 7235 3.1 402: "Payment Required", // RFC 7231 6.5.2 403: "Forbidden", // RFC 7231 6.5.3 404: "Not Found", // RFC 7231 6.5.4 405: "Method Not Allowed", // RFC 7231 6.5.5 406: "Not Acceptable", // RFC 7231 6.5.6 407: "Proxy Authentication Required", // RFC 7235 3.2 408: "Request Timeout", // RFC 7231 6.5.7 409: "Conflict", // RFC 7231 6.5.8 410: "Gone", // RFC 7231 6.5.9 411: "Length Required", // RFC 7231 6.5.10 412: "Precondition Failed", // RFC 7232 4.2 413: "Payload Too Large", // RFC 7231 6.5.11 414: "URI Too Long", // RFC 7231 6.5.12 415: "Unsupported Media Type", // RFC 7231 6.5.13 416: "Range Not Satisfiable", // RFC 7233 4.4 417: "Expectation Failed", // RFC 7231 6.5.14 418: "I'm a Teapot", // RFC 7168 2.3.3 421: "Misdirected Request", // RFC 7540 9.1.2 422: "Unprocessable Entity", // RFC 4918 11.2 423: "Locked", // RFC 4918 11.3 424: "Failed Dependency", // RFC 4918 11.4 425: "Too Early", // RFC 8470 5.2 426: "Upgrade Required", // RFC 2817 and RFC 7231 6.5.15 428: "Precondition Required", // RFC 6585 3 429: "Too Many Requests", // RFC 6585 4 431: "Request Header Fields Too Large", // RFC 6585 5 451: "Unavailable For Legal Reasons", // RFC 7725 3 500: "Internal Server Error", // RFC 7231 6.6.1 501: "Not Implemented", // RFC 7231 6.6.2 502: "Bad Gateway", // RFC 7231 6.6.3 503: "Service Unavailable", // RFC 7231 6.6.4 504: "Gateway Timeout", // RFC 7231 6.6.5 505: "HTTP Version Not Supported", // RFC 7231 6.6.6 506: "Variant Also Negotiates", // RFC 2295 8.1 507: "Insufficient Storage", // RFC 4918 11.5 508: "Loop Detected", // RFC 5842 7.2 509: "Bandwidth Limit Exceeded", 510: "Not Extended", // RFC 2774 7 511: "Network Authentication Required", // RFC 6585 6 }; /** * This is an implementation of ServerResponse from Node.js intended to run in * a WinterTC runtime. The 'Writable' interface of this class is wired to an in-memory * buffer. This class also provides a method that creates a Response object that * can be handled by the runtime. * * This instance can be used in normal ways, but it does not give access to the * underlying socket (because there isn't one. req.socket will always return null). * * Some code in this class is transplanted/adapted from node/lib/_httpserver.js * * NOTE: Node.js doesn't really separate the body from headers, the entire "stream" * contains the headers and the body. So we provide functions that lets us pull * the headers and body out individually at a later time. */ export class FetchServerResponse extends FetchOutgoingMessage { static encoder = new TextEncoder(); statusCode = 200; statusMessage; _sent100; _expect_continue; [kOutHeaders] = null; constructor(req, options) { super(req, options); if (req.method === "HEAD") { this._hasBody = false; } // this.req = req; // super() actually does this this.sendDate = true; this._sent100 = false; this._expect_continue = false; if (req.httpVersionMajor < 1 || req.httpVersionMinor < 1) { this.useChunkedEncodingByDefault = chunkExpression.exec(String(req.headers.te)) !== null; this.shouldKeepAlive = false; } // Difference from Node.js - // In Node.js, in addition to the above, we would check if an observer is enabled for // http, and if it is, we would start performance measurement of server response statistics. this.fetchResponse = new Promise((resolve) => { let finished = false; this.on("finish", () => { finished = true; }); const initialDataChunks = []; const initialDataWrittenHandler = (e) => { if (finished) { return; } initialDataChunks[e.index] = this.dataFromDataWrittenEvent(e); }; this.on("_dataWritten", initialDataWrittenHandler); this.on("_headersSent", (e) => { this.off("_dataWritten", initialDataWrittenHandler); // Convert the response object to a Response object and return it const { statusCode, statusMessage, headers } = e; resolve(this._toFetchResponse(statusCode, statusMessage, headers, initialDataChunks, finished)); }); }); } dataFromDataWrittenEvent(e) { const { index, entry } = e; let { data, encoding } = entry; if (index === 0) { if (typeof data !== "string") { console.error("First chunk should be string, not sure what happened."); throw new ERR_INVALID_ARG_TYPE("packet.data", ["string", "Buffer", "Uint8Array"], data); } // The first X bytes are header material, so we remove it. data = data.slice(this.writtenHeaderBytes); } if (typeof data === "string") { if (encoding === undefined || encoding === "utf8" || encoding === "utf-8") { data = FetchServerResponse.encoder.encode(data); } else { data = Buffer.from(data, encoding ?? undefined); } } return data ?? Buffer.from([]); } _finish() { // Difference from Node.js - // In Node.js, if server response statistics performance is being measured, we would stop it. super._finish(); } assignSocket(socket) { // Difference from Node.js - // Socket is not supported throw new ERR_METHOD_NOT_IMPLEMENTED("assignSocket"); } detachSocket(socket) { // Difference from Node.js - // Socket is not supported throw new ERR_METHOD_NOT_IMPLEMENTED("detachSocket"); } writeContinue(callback) { this._writeRaw("HTTP/1.1 100 Continue\r\n\r\n", "ascii", callback); this._sent100 = true; } writeProcessing(callback) { this._writeRaw("HTTP/1.1 102 Processing\r\n\r\n", "ascii", callback); } writeEarlyHints(hints, callback) { let head = "HTTP/1.1 103 Early Hints\r\n"; // Difference from Node.js - // In Node.js, we would validate the hints object here. // validateObject(hints, 'hints'); if (hints.link === null || hints.link === undefined) { return; } const link = validateLinkHeaderValue(hints.link); if (link.length === 0) { return; } head += "Link: " + link + "\r\n"; for (const key of Object.keys(hints)) { if (key !== "link") { head += key + ": " + hints[key] + "\r\n"; } } head += "\r\n"; this._writeRaw(head, "ascii", callback); } _implicitHeader() { this.writeHead(this.statusCode); } writeHead(statusCode, reason, obj) { if (this._header) { throw new ERR_HTTP_HEADERS_SENT("write"); } const originalStatusCode = statusCode; statusCode |= 0; if (statusCode < 100 || statusCode > 999) { throw new ERR_HTTP_INVALID_STATUS_CODE(originalStatusCode); } if (typeof reason === "string") { // writeHead(statusCode, reasonPhrase[, headers]) this.statusMessage = reason; } else { // writeHead(statusCode[, headers]) this.statusMessage ||= STATUS_CODES[statusCode] || "unknown"; obj ??= reason; } this.statusCode = statusCode; let headers; if (this[kOutHeaders]) { // Slow-case: when progressive API and header fields are passed. let k; if (Array.isArray(obj)) { if (obj.length % 2 !== 0) { throw new ERR_INVALID_ARG_VALUE("headers", obj); } // Headers in obj should override previous headers but still // allow explicit duplicates. To do so, we first remove any // existing conflicts, then use appendHeader. for (let n = 0; n < obj.length; n += 2) { k = obj[n + 0]; this.removeHeader(String(k)); } for (let n = 0; n < obj.length; n += 2) { k = obj[n]; if (k) { this.appendHeader(String(k), obj[n + 1]); } } } else if (obj) { const keys = Object.keys(obj); // Retain for(;;) loop for performance reasons // Refs: https://github.com/nodejs/node/pull/30958 for (let i = 0; i < keys.length; i++) { k = keys[i]; if (k) { this.setHeader(k, obj[k]); } } } // Only progressive api is used headers = this[kOutHeaders]; } else { // Only writeHead() called headers = obj; } if (checkInvalidHeaderChar(this.statusMessage)) { throw new ERR_INVALID_CHAR("statusMessage"); } const statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; if (statusCode === 204 || statusCode === 304 || (statusCode >= 100 && statusCode <= 199)) { // RFC 2616, 10.2.5: // The 204 response MUST NOT include a message-body, and thus is always // terminated by the first empty line after the header fields. // RFC 2616, 10.3.5: // The 304 response MUST NOT contain a message-body, and thus is always // terminated by the first empty line after the header fields. // RFC 2616, 10.1 Informational 1xx: // This class of status code indicates a provisional response, // consisting only of the Status-Line and optional headers, and is // terminated by an empty line. this._hasBody = false; } // Don't keep alive connections where the client expects 100 Continue // but we sent a final status; they may put extra bytes on the wire. if (this._expect_continue && !this._sent100) { this.shouldKeepAlive = false; } // Convert headers to a compatible type for _storeHeader const convertedHeaders = headers && !Array.isArray(headers) ? headers : headers; this._storeHeader(statusLine, convertedHeaders ?? null); return this; } // Docs-only deprecated: DEP0063 writeHeader = this.writeHead; fetchResponse; _toFetchResponse(status, statusText, sentHeaders, initialDataChunks, finished) { const headers = new Headers(); for (const [header, value] of sentHeaders) { headers.append(header, value); } const _this = this; let body = this._hasBody ? new ReadableStream({ start(controller) { for (const dataChunk of initialDataChunks) { controller.enqueue(dataChunk); } if (finished) { controller.close(); } else { _this.on("finish", () => { finished = true; controller.close(); }); _this.on("_dataWritten", (e) => { if (finished) { return; } const data = _this.dataFromDataWrittenEvent(e); controller.enqueue(data); }); } }, }) : null; // @ts-expect-error this is (currently) a cloudflare-specific class if (body != null && typeof FixedLengthStream !== "undefined") { const contentLength = parseInt(headers.get("content-length") ?? "", 10); // will be NaN if not set if (contentLength >= 0) { // @ts-expect-error this is (currently) a cloudflare-specific class body = body.pipeThrough(new FixedLengthStream(contentLength)); } } return new Response(body, { status, statusText, headers, }); } } export function toReqRes(req, options) { const { createIncomingMessage = () => new FetchIncomingMessage(), createServerResponse = (incoming) => new FetchServerResponse(incoming), ctx, } = options ?? {}; const incoming = createIncomingMessage(ctx); const serverResponse = createServerResponse(incoming, ctx); const reqUrl = new URL(req.url); const versionMajor = 1; const versionMinor = 1; incoming.httpVersionMajor = versionMajor; incoming.httpVersionMinor = versionMinor; incoming.httpVersion = `${versionMajor}.${versionMinor}`; incoming.url = reqUrl.pathname + reqUrl.search; incoming.upgrade = false; const headers = []; for (const [headerName, headerValue] of req.headers) { headers.push(headerName); headers.push(headerValue); } incoming._addHeaderLines(headers, headers.length); incoming.method = req.method; incoming._stream = req.body; return { req: incoming, res: serverResponse, }; } export function toFetchResponse(res) { if (!(res instanceof FetchServerResponse)) { throw new Error("toFetchResponse must be called on a ServerResponse generated by toReqRes"); } return res.fetchResponse; }