├── .github └── workflows │ └── ci-release.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── package-lock.json ├── package.json ├── src ├── http-compute-js │ ├── http-common.ts │ ├── http-incoming.ts │ ├── http-outgoing.ts │ ├── http-server.test.ts │ ├── http-server.ts │ ├── internal-http.ts │ └── internal-streams-state.ts ├── index.ts ├── polyfills.ts ├── types │ ├── process.d.ts │ └── stream-browserify.d.ts └── utils │ ├── errors.ts │ └── types.ts └── tsconfig.json /.github/workflows/ci-release.yaml: -------------------------------------------------------------------------------- 1 | name: Release CI 2 | on: 3 | push: 4 | tags: 5 | # This looks like a regex, but it's actually a filter pattern 6 | # see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet 7 | - 'v*.*.*' 8 | - 'v*.*.*-*' 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: "Checkout code" 16 | uses: actions/checkout@v4 17 | 18 | - name: Validate SemVer tag 19 | run: | 20 | TAG="${GITHUB_REF_NAME#refs/tags/}" 21 | if [[ ! "$TAG" =~ ^v[0-9]+(\.[0-9]+){2}(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then 22 | echo "::error::Invalid tag: $TAG. Must follow SemVer syntax (e.g., v1.2.3, v1.2.3-alpha)." 23 | exit 1 24 | fi 25 | shell: bash 26 | 27 | - name: Set up Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 'lts/*' 31 | registry-url: 'https://registry.npmjs.org' 32 | 33 | - name: Extract prerelease tag if present 34 | id: extract-tag 35 | run: | 36 | TAG="${GITHUB_REF_NAME#v}" # Remove the "v" prefix 37 | if [[ "$TAG" == *-* ]]; then 38 | PRERELEASE=${TAG#*-} # Remove everything before the dash 39 | PRERELEASE=${PRERELEASE%%.*} # Remove everything after the first period 40 | else 41 | PRERELEASE="latest" 42 | fi 43 | echo "DIST_TAG=$PRERELEASE" >> $GITHUB_ENV 44 | 45 | - name: Install npm dependencies 46 | run: npm install 47 | 48 | - name: Publish to npmjs.org 49 | env: 50 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | run: | 52 | echo "Publishing to npmjs.org using dist-tag: $DIST_TAG" 53 | npm publish --access=public --tag "$DIST_TAG" 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | .nyc_output 8 | 9 | # Dependency directories 10 | node_modules/ 11 | 12 | # Output 13 | dist/ 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Fixed 11 | 12 | - Fix unnecessary override field for kOutHeaders 13 | - Fix signature of _setHeader 14 | - Lock @types/node to 18.6.4 to ensure successful builds 15 | - Clarify license for Node.js portions 16 | 17 | ### Updated 18 | 19 | - Update TypeScript and vitest 20 | - Polyfills referenced directly, no more Webpack requirement 21 | 22 | ## [1.1.5] - 2025-01-06 23 | 24 | ### Added 25 | 26 | - Release to npmjs using CI workflow 27 | 28 | ### Fixed 29 | 30 | - Updated dependency versions 31 | 32 | ## [1.1.4] - 2024-01-25 33 | 34 | ### Fixed 35 | 36 | - Fix: Multiple Set-Cookie headers should not be overwritten 37 | 38 | ## [1.1.3] - 2024-01-25 39 | 40 | ### Fixed 41 | 42 | - Fix: set req complete when pushing null 43 | 44 | ## [1.1.2] - 2023-11-08 45 | 46 | - Apply "Compute" branding change. 47 | 48 | ## [1.1.1] - 2023-10-14 49 | 50 | ### Updated 51 | 52 | - Ensure ESM compatibility 53 | 54 | ## [1.1.0] - 2023-09-19 55 | 56 | ### Updated 57 | 58 | - Position @fastly/js-compute as a devDependency and peerDependency. 59 | 60 | ## [1.0.0] - 2023-05-19 61 | 62 | ### Changed 63 | 64 | - Updated to @fastly/js-compute@2.0.0 65 | 66 | ## [0.4.0] - 2022-12-23 67 | 68 | ### Changed 69 | 70 | - Updated to @fastly/js-compute@1.0.0 71 | 72 | ## [0.3.2] - 2022-12-02 73 | 74 | ### Changed 75 | 76 | - Updated to js-compute@0.5.12 77 | - Removed polyfills for setTimeout and clearTimeout, as they are now supported natively in js-compute 78 | 79 | ## [0.3.1] - 2022-10-22 80 | 81 | ### Changed 82 | 83 | - Changed to use TextEncoder instead of Buffer.from() for converting UTF-8 text streams to binary, giving massive performance improvement 84 | 85 | [unreleased]: https://github.com/fastly/http-compute-js/compare/v1.1.5...HEAD 86 | [1.1.5]: https://github.com/fastly/http-compute-js/compare/v1.1.4...v1.1.5 87 | [1.1.4]: https://github.com/fastly/http-compute-js/compare/v1.1.3...v1.1.4 88 | [1.1.3]: https://github.com/fastly/http-compute-js/compare/v1.1.2...v1.1.3 89 | [1.1.2]: https://github.com/fastly/http-compute-js/compare/v1.1.1...v1.1.2 90 | [1.1.1]: https://github.com/fastly/http-compute-js/compare/v1.1.0...v1.1.1 91 | [1.1.0]: https://github.com/fastly/http-compute-js/compare/v1.0.0...v1.1.0 92 | [1.0.0]: https://github.com/fastly/http-compute-js/compare/v0.4.0...v1.0.0 93 | [0.4.0]: https://github.com/fastly/http-compute-js/compare/v0.3.2...v0.4.0 94 | [0.3.2]: https://github.com/fastly/http-compute-js/compare/v0.3.1...v0.3.2 95 | [0.3.1]: https://github.com/fastly/http-compute-js/releases/tag/v0.3.1 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | @fastly/http-compute-js is licensed for use as follows: 2 | 3 | """ 4 | MIT License 5 | 6 | Copyright (c) 2025 Fastly 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | """ 26 | 27 | This license applies to portions of this code that are based on parts of Node.js 28 | originating from the https://github.com/nodejs/node repository: 29 | 30 | """ 31 | Copyright Node.js contributors. All rights reserved. 32 | 33 | Permission is hereby granted, free of charge, to any person obtaining a copy 34 | of this software and associated documentation files (the "Software"), to 35 | deal in the Software without restriction, including without limitation the 36 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 37 | sell copies of the Software, and to permit persons to whom the Software is 38 | furnished to do so, subject to the following conditions: 39 | 40 | The above copyright notice and this permission notice shall be included in 41 | all copies or substantial portions of the Software. 42 | 43 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 44 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 45 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 46 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 47 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 48 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 49 | IN THE SOFTWARE. 50 | """ 51 | 52 | This license applies to portions of this code that are based on parts of Node.js 53 | originating from the https://github.com/joyent/node repository: 54 | 55 | """ 56 | Copyright Joyent, Inc. and other Node contributors. All rights reserved. 57 | Permission is hereby granted, free of charge, to any person obtaining a copy 58 | of this software and associated documentation files (the "Software"), to 59 | deal in the Software without restriction, including without limitation the 60 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 61 | sell copies of the Software, and to permit persons to whom the Software is 62 | furnished to do so, subject to the following conditions: 63 | 64 | The above copyright notice and this permission notice shall be included in 65 | all copies or substantial portions of the Software. 66 | 67 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 68 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 69 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 70 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 71 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 72 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 73 | IN THE SOFTWARE. 74 | """ 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastly/http-compute-js 2 | 3 | A library aiming to provide Node.js-compatible request and response objects. 4 | 5 | Compute provides [Request and Response objects](https://developer.fastly.com/learning/compute/javascript/#composing-requests-and-responses) based on the modern [Fetch standard](https://fetch.spec.whatwg.org/) rather than the `req` and `res` objects traditionally seen in Node.js programs. If you are more familiar with using the Node.js request and response objects, or have some libraries that work with them, this library aims to let you do that. 6 | 7 | ## Usage 8 | 9 | To your Compute JavaScript project (which you can create using `npm init @fastly/create` and the Compute JavaScript [Empty Starter Kit](https://github.com/fastly/compute-starter-kit-javascript-empty)), add the `@fastly/http-compute-js` package as a dependency. 10 | 11 | ``` 12 | npm install @fastly/http-compute-js 13 | ``` 14 | 15 | In your program: 16 | 17 | ```javascript 18 | import http from '@fastly/http-compute-js'; 19 | 20 | const server = http.createServer((req, res) => { 21 | res.writeHead(200, { 'Content-Type': 'application/json' }); 22 | res.end(JSON.stringify({ 23 | data: 'Hello World!' 24 | })); 25 | }); 26 | 27 | server.listen(); 28 | ``` 29 | 30 | `req` and `res` are implementations of [`IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage) and [`ServerResponse`](https://nodejs.org/api/http.html#class-httpserverresponse), respectively, and can be used as in a Node.js program. 31 | 32 | `req` is an `IncomingMessage` object whose `Readable` interface has been wired to the body of the Compute request's body stream. As such, you can read from it using the standard `on('data')`/`on('end')` mechanisms, or using libraries such as [`parse-body`](https://www.npmjs.com/package/parse-body). You can also read the headers and other information using the standard interface. 33 | 34 | `res` is a `ServerResponse` object whose `Writable` interface is wired to an in-memory buffer. Write to it normally using `res.write()` / `res.end()` or pipe to it using `res.pipe()`. You can also set headers and status code using the standard interfaces. 35 | 36 | ### Notes / Known Issues 37 | 38 | * The aim of this library is to provide compatibility where practical. Please understand that some features are not possible to achieve 100% compatibility with Node.js, due to platform differences. 39 | * The library uses npm packages such as [stream-browserify](https://www.npmjs.com/package/stream-browserify), [buffer](https://www.npmjs.com/package/buffer), [events](https://www.npmjs.com/package/events), and [process](https://www.npmjs.com/package/process) to simulate Node.js behavior for low-level objects. 40 | * Other libraries that consume `IncomingMessage` and `ServerResponse` may or may not be compatible with Compute. Some may work if you apply polyfills [during module bundling](https://developer.fastly.com/learning/compute/javascript/#module-bundling). 41 | * HTTP Version is currently always reported as `1.1`. 42 | * Unlike in Node.js, the `socket` property of these objects is always `null`, and cannot be assigned. 43 | * Some functionality is not (yet) supported: `http.Agent`, `http.ClientRequest`, `http.get()`, `http.request()`, to name a few. 44 | * Transfer-Encoding: chunked does not work at the moment and has been disabled. 45 | * At the current time, the `ServerResponse` write stream must be finished before the `Response` object is generated. 46 | 47 | ### Example 48 | 49 | The following is an example that reads the URL, method, headers, and body from the request, and writes a response. 50 | 51 | ```javascript 52 | import http from '@fastly/http-compute-js'; 53 | 54 | const server = http.createServer(async (req, res) => { 55 | // Get URL, method, headers, and body from req 56 | const url = req.url; 57 | const method = req.method; 58 | const headers = {}; 59 | for (let [key, value] of Object.entries(req.headers)) { 60 | if(!Array.isArray(value)) { 61 | value = [String(value)]; 62 | } 63 | headers[key] = value.join(', '); 64 | } 65 | let body = null; 66 | if (method !== 'GET' && method !== 'HEAD') { 67 | // Reading data out of a stream.Readable 68 | body = await new Promise(resolve => { 69 | const data = []; 70 | req.on('data', (chunk) => { 71 | data.push(chunk); 72 | }); 73 | req.on('end', () => { 74 | resolve(data.join('')); 75 | }); 76 | }); 77 | } 78 | 79 | // Write output to res 80 | res.writeHead(200, { 'Content-Type': 'application/json' }); 81 | res.end(JSON.stringify({ 82 | url, 83 | method, 84 | headers, 85 | body, 86 | })); 87 | }); 88 | 89 | server.listen(); 90 | ``` 91 | 92 | ## The `server` object 93 | 94 | `server` is an instance of `HttpServer`, modeled after [`http.Server`](https://nodejs.org/api/http.html#class-httpserver). It is created using the `createServer()` function, usually passing in your request handler. The `server` begins to listen for `fetch` events once the `listen()` function is called. 95 | 96 | `createServer([onRequest])` 97 | * Instantiates an `HttpServer` instance, optionally passing in an onRequest listener. 98 | * Parameters: 99 | * `onRequest` - (optional) supplying this is equivalent to calling `server.on('request', onRequest)` after instantiation. 100 | * Returns: an `HttpServer` instance. 101 | 102 | `HttpServer` class members: 103 | - `listen([port][,onListening])` 104 | * Starts the `server` listening for `fetch` events. 105 | * Parameters: 106 | * `port` - (optional) a port number. This argument is purely for API compatibility with Node's `server.listen()`, and is ignored by Compute. 107 | * `onListening` - (optional) supplying this is equivalent to calling `server.on('listening', onListening)` before calling this method. 108 | - event: `'listening'` 109 | * Emitted when the `fetch` event handler has been established after calling `server.listen()`. 110 | - event: `'request'` 111 | * Emitted each time there is a request. 112 | * Parameters: 113 | * `request` - `http.IncomingMessage` 114 | * `response` - `http.ServerResponse` 115 | 116 | ## Manual instantiation of `req` and `res` 117 | 118 | Sometimes, you may need to use Node.js-compatible request and response objects for only some parts of your application. Or, you may be working with an existing application or package (for example, "middleware") designed to interact with these objects. 119 | 120 | `@fastly/http-compute-js` provides utility functions that help in this case to help you go back and forth between the `Request` and `Response` objects used in Compute and their Node.js-compatible counterparts. 121 | 122 | `toReqRes(request)` 123 | * Converts from a Compute-provided `Request` object to a pair of Node.js-compatible request and response objects. 124 | * Parameters: 125 | * `request` - A [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. You would 126 | typically obtain this from the `request` property of the `event` object received by your `fetch` event 127 | handler. 128 | * Returns: an object with the following properties. 129 | * `req` - An [`http.IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage) 130 | object whose `Readable` interface has been wired to the `Request` object's `body`. NOTE: This is an error 131 | if the `Request`'s `body` has already been used. 132 | * `res` - An [`http.ServerResponse`](https://nodejs.org/api/http.html#class-httpserverresponse) 133 | object whose `Writable` interface has been wired to an in-memory buffer. 134 | 135 | `toComputeResponse(res)` 136 | * Creates a new `Response` object from the `res` object above, based on the status code, headers, and body that has been written to it. This `Response` object is typically used as the return value from a Compute `fetch` handler. 137 | * Parameters: 138 | * `res` - An `http.ServerResponse` object created by `toReqRes()`. 139 | * Returns: a promise that resolves to a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object. 140 | * NOTE: This function returns a `Promise` that resolves to a `Response` once the `res` object emits the [`'finish'`](https://nodejs.org/api/http.html#event-finish) event, which typically happens when you call [`res.end()`](https://nodejs.org/api/http.html#responseenddata-encoding-callback). If your application never signals the end of output, this promise will never resolve, and your application will likely time out. 141 | * If an error occurs, the promise will reject with that error. 142 | 143 | ### Example 144 | 145 | The following is an example that shows the use of the manual instantiation functions in a Compute JavaScript application written using a `fetch` event listener. Node.js-compatible `req` and `res` objects are produced from `event.request`. After having some output written, a `Response` object is created from the `res` object and returned from the event listener. 146 | 147 | ```javascript 148 | /// 149 | import { toReqRes, toComputeResponse } from '@fastly/http-compute-js'; 150 | 151 | addEventListener('fetch', (event) => event.respondWith(handleRequest(event))); 152 | async function handleRequest(event) { 153 | // Create Node.js-compatible req and res from event.request 154 | const { req, res } = toReqRes(event.request); 155 | 156 | res.writeHead(200, { 'Content-Type': 'application/json' }); 157 | res.end(JSON.stringify({ 158 | data: 'Hello World!' 159 | })); 160 | 161 | // Create a Compute-at-Edge Response object based on res, and return it 162 | return await toComputeResponse(res); 163 | } 164 | ``` 165 | 166 | ## Issues 167 | 168 | If you encounter any non-security-related bug or unexpected behavior, please [file an issue][bug] using the bug report template. 169 | 170 | [bug]: https://github.com/fastly/http-compute-js/issues/new?labels=bug 171 | 172 | ### Security issues 173 | 174 | Please see our [SECURITY.md](./SECURITY.md) for guidance on reporting security-related issues. 175 | 176 | ## License 177 | 178 | [MIT](./LICENSE). 179 | 180 | In order for this library to function without requiring a direct dependency on Node.js itself, portions of the code in this library are adapted / copied from Node.js. Those portions are Copyright Joyent, Inc. and other Node.js contributors. 181 | 182 | See the LICENSE file for details. 183 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Report a security issue 2 | 3 | The project team welcomes security reports and is committed to providing prompt attention to security issues. Security 4 | issues should be reported privately via [Fastly’s security issue reporting process](https://www.fastly.com/security/report-security-issue). 5 | 6 | ## Security advisories 7 | 8 | Remediation of security vulnerabilities is prioritized by the project team. The project team endeavors to coordinate 9 | remediation with third-party stakeholders, and is committed to transparency in the disclosure process. The team announces 10 | security issues via [GitHub](https://github.com/fastly/http-compute-js/releases) on a best-effort basis. 11 | 12 | Note that communications related to security issues in Fastly-maintained OSS as described here are distinct from 13 | [Fastly Security Advisories](https://www.fastly.com/security-advisories). 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastly/http-compute-js", 3 | "version": "2.0.0-beta.0", 4 | "description": "Node.js-compatible request and response objects for JavaScript on Fastly Compute", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "type": "module", 8 | "directories": { 9 | "lib": "lib" 10 | }, 11 | "scripts": { 12 | "prepack": "npm run clean && npm run compile", 13 | "compile": "tsc --build tsconfig.json", 14 | "clean": "rm -rf dist", 15 | "test": "vitest run" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/fastly/http-compute-js.git" 20 | }, 21 | "keywords": [ 22 | "fastly", 23 | "compute", 24 | "compute-js", 25 | "node", 26 | "http", 27 | "request", 28 | "response" 29 | ], 30 | "author": "Katsuyuki Omuro ", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/fastly/http-compute-js/issues" 34 | }, 35 | "homepage": "https://github.com/fastly/http-compute-js#readme", 36 | "dependencies": { 37 | "@fastly/js-compute": "^3.34.0", 38 | "buffer": "^5.7.1", 39 | "events": "^3.3.0", 40 | "node-inspect-extracted": "^1.1.0", 41 | "process": "^0.11.10", 42 | "stream-browserify": "^3.0.0" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "18.6.4", 46 | "typescript": "^5.8.3", 47 | "vitest": "^1.6.1" 48 | }, 49 | "peerDependencies": { 50 | "@fastly/js-compute": "^2.0.0 || ^3.0.0" 51 | }, 52 | "files": [ 53 | "dist/**/*.js", 54 | "dist/**/*.js.map", 55 | "dist/**/*.d.ts", 56 | "LICENSE", 57 | "README.md", 58 | "SECURITY.md" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /src/http-compute-js/http-common.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | * 5 | * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. 6 | */ 7 | 8 | /* These items copied from Node.js: node/lib/_http_common.js. */ 9 | 10 | const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/; 11 | /** 12 | * Verifies that the given val is a valid HTTP token 13 | * per the rules defined in RFC 7230 14 | * See https://tools.ietf.org/html/rfc7230#section-3.2.6 15 | */ 16 | export function checkIsHttpToken(val: string) { 17 | return tokenRegExp.exec(val) !== null; 18 | } 19 | 20 | 21 | const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; 22 | /** 23 | * True if val contains an invalid field-vchar 24 | * field-value = *( field-content / obs-fold ) 25 | * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] 26 | * field-vchar = VCHAR / obs-text 27 | */ 28 | export function checkInvalidHeaderChar(val: string) { 29 | return headerCharRegex.exec(val) !== null; 30 | } 31 | 32 | 33 | export const chunkExpression = /(?:^|\W)chunked(?:$|\W)/i 34 | 35 | -------------------------------------------------------------------------------- /src/http-compute-js/http-incoming.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | * 5 | * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. 6 | */ 7 | 8 | // This file modeled after Node.js - node/lib/_http_incoming.js 9 | 10 | import { Readable } from 'stream-browserify'; 11 | import { type IncomingHttpHeaders, type IncomingMessage } from 'node:http'; 12 | 13 | import { ERR_METHOD_NOT_IMPLEMENTED } from '../utils/errors.js'; 14 | 15 | const kHeaders = Symbol('kHeaders'); 16 | const kHeadersDistinct = Symbol('kHeadersDistinct'); 17 | const kHeadersCount = Symbol('kHeadersCount'); 18 | const kTrailers = Symbol('kTrailers'); 19 | const kTrailersDistinct = Symbol('kTrailersDistinct'); 20 | const kTrailersCount = Symbol('kTrailersCount'); 21 | 22 | /** 23 | * This is an implementation of IncomingMessage from Node.js intended to run in 24 | * Fastly Compute. The 'Readable' interface of this class is wired to a 'Request' 25 | * object's 'body'. 26 | * 27 | * This instance can be used in normal ways, but it does not give access to the 28 | * underlying socket (because there isn't one. req.socket will always return null). 29 | * 30 | * Some code in this class is transplanted/adapted from node/lib/_http_incoming.js 31 | */ 32 | export class ComputeJsIncomingMessage extends Readable implements IncomingMessage { 33 | 34 | // This actually reaches into in Readable 35 | declare _readableState: { 36 | readingMore: boolean, 37 | }; 38 | 39 | get socket(): any { 40 | // Difference from Node.js - 41 | // We don't really have a way to support direct access to the socket 42 | return null; 43 | } 44 | set socket(_val: any) { 45 | // Difference from Node.js - 46 | // We don't really have a way to support direct access to the socket 47 | throw new ERR_METHOD_NOT_IMPLEMENTED('socket'); 48 | } 49 | 50 | httpVersionMajor!: number; 51 | httpVersionMinor!: number; 52 | httpVersion!: string; 53 | complete: boolean = false; 54 | [kHeaders]: IncomingHttpHeaders | null = null; 55 | [kHeadersDistinct]: Record | null = null; 56 | [kHeadersCount]: number = 0; 57 | rawHeaders: string[] = []; 58 | [kTrailers]: NodeJS.Dict | null = null; 59 | [kTrailersDistinct]: Record | null = null; 60 | [kTrailersCount]: number = 0; 61 | rawTrailers: string[] = []; 62 | 63 | aborted: boolean = false; 64 | 65 | // A flag that seems to indicate this is an upgrade request 66 | // TODO: someday? 67 | upgrade: boolean = false; 68 | 69 | // request (server) only 70 | url: string = ''; 71 | method!: string; 72 | 73 | // TODO: Support ClientRequest 74 | // statusCode = null; 75 | // statusMessage = null; 76 | // client = socket; 77 | 78 | _consuming: boolean; 79 | _dumped: boolean; 80 | 81 | // The underlying ReadableStream 82 | _stream: ReadableStream | null = null; 83 | 84 | constructor() { 85 | 86 | const streamOptions = {}; 87 | 88 | // Difference from Node.js - 89 | // In Node.js, if the IncomingMessages is associated with a socket then 90 | // that socket's 'readableHighWaterMark' would be used to set 91 | // streamOptions.highWaterMark before calling parent constructor. 92 | 93 | super(streamOptions); 94 | 95 | this._readableState.readingMore = true; 96 | 97 | this._consuming = false; 98 | 99 | // Flag for when we decide that this message cannot possibly be 100 | // read by the user, so there's no point continuing to handle it. 101 | this._dumped = false; 102 | } 103 | 104 | get connection() { 105 | // Difference from Node.js - 106 | // We don't really have a way to support direct access to the socket 107 | return null; 108 | } 109 | 110 | set connection(_socket: any) { 111 | // Difference from Node.js - 112 | // We don't really have a way to support direct access to the socket 113 | console.error('No support for IncomingMessage.connection'); 114 | } 115 | 116 | get headers() { 117 | if (!this[kHeaders]) { 118 | this[kHeaders] = {}; 119 | 120 | const src = this.rawHeaders; 121 | const dst = this[kHeaders]; 122 | 123 | for (let n = 0; n < this[kHeadersCount]; n += 2) { 124 | this._addHeaderLine(src[n], src[n + 1], dst); 125 | } 126 | } 127 | return this[kHeaders]; 128 | } 129 | 130 | set headers(val: IncomingHttpHeaders) { 131 | this[kHeaders] = val; 132 | } 133 | 134 | get headersDistinct() { 135 | if (!this[kHeadersDistinct]) { 136 | this[kHeadersDistinct] = {}; 137 | 138 | const src = this.rawHeaders; 139 | const dst = this[kHeadersDistinct]; 140 | 141 | for (let n = 0; n < this[kHeadersCount]; n += 2) { 142 | this._addHeaderLineDistinct(src[n], src[n + 1], dst); 143 | } 144 | } 145 | return this[kHeadersDistinct]; 146 | } 147 | 148 | set headersDistinct(val: Record) { 149 | this[kHeadersDistinct] = val; 150 | } 151 | 152 | get trailers() { 153 | if (!this[kTrailers]) { 154 | this[kTrailers] = {}; 155 | 156 | const src = this.rawTrailers; 157 | const dst = this[kTrailers]; 158 | 159 | for (let n = 0; n < this[kTrailersCount]; n += 2) { 160 | this._addHeaderLine(src[n], src[n + 1], dst); 161 | } 162 | } 163 | return this[kTrailers]; 164 | } 165 | 166 | set trailers(val: NodeJS.Dict) { 167 | this[kTrailers] = val; 168 | } 169 | 170 | get trailersDistinct() { 171 | if (!this[kTrailersDistinct]) { 172 | this[kTrailersDistinct] = {}; 173 | 174 | const src = this.rawTrailers; 175 | const dst = this[kTrailersDistinct]; 176 | 177 | for (let n = 0; n < this[kTrailersCount]; n += 2) { 178 | this._addHeaderLineDistinct(src[n], src[n + 1], dst); 179 | } 180 | } 181 | return this[kTrailersDistinct]; 182 | } 183 | 184 | set trailersDistinct(val: Record) { 185 | this[kTrailersDistinct] = val; 186 | } 187 | 188 | setTimeout(msecs: number, callback?: () => void): this { 189 | // Difference from Node.js - 190 | // In Node.js, this is supposed to set the underlying socket to time out 191 | // after some time and then run a callback. 192 | // We do nothing here since we don't really have a way to support direct 193 | // access to the socket. 194 | return this; 195 | } 196 | 197 | override async _read(n: number): Promise { 198 | // As this is an implementation of stream.Readable, we provide a _read() 199 | // function that pumps the next chunk out of the underlying ReadableStream. 200 | 201 | if (!this._consuming) { 202 | this._readableState.readingMore = false; 203 | this._consuming = true; 204 | } 205 | 206 | // Difference from Node.js - 207 | // The Node.js implementation will already have its internal buffer 208 | // filled by the parserOnBody function. 209 | // For our implementation, we use the ReadableStream instance. 210 | 211 | if(this._stream == null) { 212 | // For GET and HEAD requests, the stream would be empty. 213 | // Simply signal that we're done. 214 | this.complete = true; 215 | this.push(null); 216 | return; 217 | } 218 | 219 | const reader = this._stream.getReader(); 220 | try { 221 | const data = await reader.read(); 222 | if (data.done) { 223 | // Done with stream, tell Readable we have no more data; 224 | this.complete = true; 225 | this.push(null); 226 | } else { 227 | this.push(data.value); 228 | } 229 | } catch (e) { 230 | this.destroy(e); 231 | } finally { 232 | reader.releaseLock(); 233 | } 234 | } 235 | 236 | override _destroy(err: Error | null, cb: (err?: Error | null) => void) { 237 | if (!this.readableEnded || !this.complete) { 238 | this.aborted = true; 239 | this.emit('aborted'); 240 | } 241 | 242 | // Difference from Node.js - 243 | // Node.js would check for the existence of the socket and do some additional 244 | // cleanup. 245 | 246 | // By the way, I believe this name 'onError' is misleading, it is called 247 | // regardless of whether there was an error. The callback is expected to 248 | // check for the existence of the error to decide whether the result was 249 | // actually an error. 250 | process.nextTick(onError, this, err, cb); 251 | } 252 | 253 | _addHeaderLines(headers: string[], n: number) { 254 | if (headers && headers.length) { 255 | let dest; 256 | if (this.complete) { 257 | this.rawTrailers = headers; 258 | this[kTrailersCount] = n; 259 | dest = this[kTrailers]; 260 | } else { 261 | this.rawHeaders = headers; 262 | this[kHeadersCount] = n; 263 | dest = this[kHeaders]; 264 | } 265 | 266 | if (dest) { 267 | for (let i = 0; i < n; i += 2) { 268 | this._addHeaderLine(headers[i], headers[i + 1], dest); 269 | } 270 | } 271 | } 272 | } 273 | 274 | _addHeaderLine(field: string, value: string, dest: IncomingHttpHeaders) { 275 | field = matchKnownFields(field); 276 | const flag = field.charCodeAt(0); 277 | if (flag === 0 || flag === 2) { 278 | field = field.slice(1); 279 | // Make a delimited list 280 | if (typeof dest[field] === 'string') { 281 | dest[field] += (flag === 0 ? ', ' : '; ') + value; 282 | } else { 283 | dest[field] = value; 284 | } 285 | } else if (flag === 1) { 286 | // Array header -- only Set-Cookie at the moment 287 | if (dest['set-cookie'] !== undefined) { 288 | dest['set-cookie'].push(value); 289 | } else { 290 | dest['set-cookie'] = [value]; 291 | } 292 | } else if (dest[field] === undefined) { 293 | // Drop duplicates 294 | dest[field] = value; 295 | } 296 | } 297 | 298 | _addHeaderLineDistinct(field: string, value: string, dest: Record) { 299 | field = field.toLowerCase(); 300 | if (!dest[field]) { 301 | dest[field] = [value]; 302 | } else { 303 | dest[field].push(value); 304 | } 305 | } 306 | 307 | } 308 | 309 | /* These items copied from Node.js: node/lib/_http_incoming.js, because they are not exported from that file. */ 310 | 311 | // This function is used to help avoid the lowercasing of a field name if it 312 | // matches a 'traditional cased' version of a field name. It then returns the 313 | // lowercased name to both avoid calling toLowerCase() a second time and to 314 | // indicate whether the field was a 'no duplicates' field. If a field is not a 315 | // 'no duplicates' field, a `0` byte is prepended as a flag. The one exception 316 | // to this is the Set-Cookie header which is indicated by a `1` byte flag, since 317 | // it is an 'array' field and thus is treated differently in _addHeaderLines(). 318 | // TODO: perhaps http_parser could be returning both raw and lowercased versions 319 | // of known header names to avoid us having to call toLowerCase() for those 320 | // headers. 321 | function matchKnownFields(field: string, lowercased: boolean = false): string { 322 | switch (field.length) { 323 | case 3: 324 | if (field === 'Age' || field === 'age') return 'age'; 325 | break; 326 | case 4: 327 | if (field === 'Host' || field === 'host') return 'host'; 328 | if (field === 'From' || field === 'from') return 'from'; 329 | if (field === 'ETag' || field === 'etag') return 'etag'; 330 | if (field === 'Date' || field === 'date') return '\u0000date'; 331 | if (field === 'Vary' || field === 'vary') return '\u0000vary'; 332 | break; 333 | case 6: 334 | if (field === 'Server' || field === 'server') return 'server'; 335 | if (field === 'Cookie' || field === 'cookie') return '\u0002cookie'; 336 | if (field === 'Origin' || field === 'origin') return '\u0000origin'; 337 | if (field === 'Expect' || field === 'expect') return '\u0000expect'; 338 | if (field === 'Accept' || field === 'accept') return '\u0000accept'; 339 | break; 340 | case 7: 341 | if (field === 'Referer' || field === 'referer') return 'referer'; 342 | if (field === 'Expires' || field === 'expires') return 'expires'; 343 | if (field === 'Upgrade' || field === 'upgrade') return '\u0000upgrade'; 344 | break; 345 | case 8: 346 | if (field === 'Location' || field === 'location') 347 | return 'location'; 348 | if (field === 'If-Match' || field === 'if-match') 349 | return '\u0000if-match'; 350 | break; 351 | case 10: 352 | if (field === 'User-Agent' || field === 'user-agent') 353 | return 'user-agent'; 354 | if (field === 'Set-Cookie' || field === 'set-cookie') 355 | return '\u0001'; 356 | if (field === 'Connection' || field === 'connection') 357 | return '\u0000connection'; 358 | break; 359 | case 11: 360 | if (field === 'Retry-After' || field === 'retry-after') 361 | return 'retry-after'; 362 | break; 363 | case 12: 364 | if (field === 'Content-Type' || field === 'content-type') 365 | return 'content-type'; 366 | if (field === 'Max-Forwards' || field === 'max-forwards') 367 | return 'max-forwards'; 368 | break; 369 | case 13: 370 | if (field === 'Authorization' || field === 'authorization') 371 | return 'authorization'; 372 | if (field === 'Last-Modified' || field === 'last-modified') 373 | return 'last-modified'; 374 | if (field === 'Cache-Control' || field === 'cache-control') 375 | return '\u0000cache-control'; 376 | if (field === 'If-None-Match' || field === 'if-none-match') 377 | return '\u0000if-none-match'; 378 | break; 379 | case 14: 380 | if (field === 'Content-Length' || field === 'content-length') 381 | return 'content-length'; 382 | break; 383 | case 15: 384 | if (field === 'Accept-Encoding' || field === 'accept-encoding') 385 | return '\u0000accept-encoding'; 386 | if (field === 'Accept-Language' || field === 'accept-language') 387 | return '\u0000accept-language'; 388 | if (field === 'X-Forwarded-For' || field === 'x-forwarded-for') 389 | return '\u0000x-forwarded-for'; 390 | break; 391 | case 16: 392 | if (field === 'Content-Encoding' || field === 'content-encoding') 393 | return '\u0000content-encoding'; 394 | if (field === 'X-Forwarded-Host' || field === 'x-forwarded-host') 395 | return '\u0000x-forwarded-host'; 396 | break; 397 | case 17: 398 | if (field === 'If-Modified-Since' || field === 'if-modified-since') 399 | return 'if-modified-since'; 400 | if (field === 'Transfer-Encoding' || field === 'transfer-encoding') 401 | return '\u0000transfer-encoding'; 402 | if (field === 'X-Forwarded-Proto' || field === 'x-forwarded-proto') 403 | return '\u0000x-forwarded-proto'; 404 | break; 405 | case 19: 406 | if (field === 'Proxy-Authorization' || field === 'proxy-authorization') 407 | return 'proxy-authorization'; 408 | if (field === 'If-Unmodified-Since' || field === 'if-unmodified-since') 409 | return 'if-unmodified-since'; 410 | break; 411 | } 412 | if (lowercased) { 413 | return '\u0000' + field; 414 | } 415 | return matchKnownFields(field.toLowerCase(), true); 416 | } 417 | 418 | function onError(self: ComputeJsIncomingMessage, error: Error | null, cb: (err?: Error | null) => void) { 419 | // This is to keep backward compatible behavior. 420 | // An error is emitted only if there are listeners attached to the event. 421 | if (self.listenerCount('error') === 0) { 422 | cb(); 423 | } else { 424 | cb(error); 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /src/http-compute-js/http-outgoing.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | * 5 | * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. 6 | */ 7 | 8 | // This file modeled after Node.js - node/lib/_http_outgoing.js 9 | 10 | import { Buffer } from 'buffer'; 11 | import { Writable } from 'stream-browserify'; 12 | 13 | import { type OutgoingHttpHeaders, type OutgoingMessage, type IncomingMessage, type OutgoingHttpHeader } from 'node:http'; 14 | 15 | import { 16 | ERR_HTTP_HEADERS_SENT, 17 | ERR_HTTP_INVALID_HEADER_VALUE, 18 | ERR_HTTP_TRAILER_INVALID, 19 | ERR_INVALID_ARG_TYPE, 20 | ERR_INVALID_ARG_VALUE, 21 | ERR_INVALID_CHAR, 22 | ERR_INVALID_HTTP_TOKEN, 23 | ERR_METHOD_NOT_IMPLEMENTED, 24 | ERR_STREAM_ALREADY_FINISHED, ERR_STREAM_CANNOT_PIPE, 25 | ERR_STREAM_DESTROYED, 26 | ERR_STREAM_NULL_VALUES, 27 | ERR_STREAM_WRITE_AFTER_END 28 | } from '../utils/errors.js'; 29 | import { isUint8Array, validateString } from '../utils/types.js'; 30 | import { kNeedDrain, kOutHeaders, utcDate } from './internal-http.js'; 31 | import { getDefaultHighWaterMark } from './internal-streams-state.js'; 32 | import { 33 | checkInvalidHeaderChar, 34 | checkIsHttpToken, 35 | chunkExpression as RE_TE_CHUNKED, 36 | } from './http-common.js'; 37 | 38 | const kCorked = Symbol('corked'); 39 | const kUniqueHeaders = Symbol('kUniqueHeaders'); 40 | 41 | function debug(format: string) { 42 | //console.log('http ' + format); 43 | } 44 | 45 | /* These items copied from Node.js: node/lib/_http_outgoing.js. */ 46 | 47 | const nop = () => {}; 48 | const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; 49 | const HIGH_WATER_MARK = getDefaultHighWaterMark(); 50 | 51 | function validateHeaderName(name: string) { 52 | if (typeof name !== 'string' || !name || !checkIsHttpToken(name)) { 53 | throw new ERR_INVALID_HTTP_TOKEN('Header name', name); 54 | } 55 | } 56 | 57 | function validateHeaderValue(name: string, value: number | string | ReadonlyArray | undefined) { 58 | if (value === undefined) { 59 | throw new ERR_HTTP_INVALID_HEADER_VALUE(String(value), name); 60 | } 61 | if (checkInvalidHeaderChar(String(value))) { 62 | debug(`Header "${name}" contains invalid characters`); 63 | throw new ERR_INVALID_CHAR('header content', name); 64 | } 65 | } 66 | 67 | // isCookieField performs a case-insensitive comparison of a provided string 68 | // against the word "cookie." As of V8 6.6 this is faster than handrolling or 69 | // using a case-insensitive RegExp. 70 | function isCookieField(s: string) { 71 | return s.length === 6 && s.toLowerCase() === 'cookie'; 72 | } 73 | 74 | type WriteCallback = (err?: Error) => void; 75 | 76 | type OutputData = { 77 | data: string | Buffer | Uint8Array, 78 | encoding: BufferEncoding | undefined, 79 | callback: WriteCallback | undefined, 80 | }; 81 | 82 | type WrittenDataBufferEntry = OutputData & { 83 | length: number, 84 | written: boolean, 85 | }; 86 | 87 | type WrittenDataBufferConstructorArgs = { 88 | onWrite?: (index: number, entry: WrittenDataBufferEntry) => void, 89 | } 90 | /** 91 | * An in-memory buffer that stores the chunks that have been streamed to an 92 | * OutgoingMessage instance. 93 | */ 94 | export class WrittenDataBuffer { 95 | [kCorked]: number = 0; 96 | entries: WrittenDataBufferEntry[] = []; 97 | onWrite?: (index: number, entry: WrittenDataBufferEntry) => void; 98 | 99 | constructor(params: WrittenDataBufferConstructorArgs = {}) { 100 | this.onWrite = params.onWrite; 101 | } 102 | 103 | write(data: string | Uint8Array, encoding?: BufferEncoding, callback?: WriteCallback) { 104 | this.entries.push({ 105 | data, 106 | length: data.length, 107 | encoding, 108 | callback, 109 | written: false, 110 | }); 111 | this._flush(); 112 | 113 | return true; 114 | } 115 | 116 | cork() { 117 | this[kCorked]++; 118 | } 119 | 120 | uncork() { 121 | this[kCorked]--; 122 | this._flush(); 123 | } 124 | 125 | _flush() { 126 | if(this[kCorked] <= 0) { 127 | for(const [index, entry] of this.entries.entries()) { 128 | if(!entry.written) { 129 | entry.written = true; 130 | if(this.onWrite != null) { 131 | this.onWrite(index, entry); 132 | } 133 | if(entry.callback != null) { 134 | entry.callback.call(undefined); 135 | } 136 | } 137 | } 138 | } 139 | } 140 | 141 | get writableLength() { 142 | return this.entries.reduce((acc, entry) => { 143 | return acc + (entry.written! && entry.length! ? entry.length : 0); 144 | }, 0); 145 | } 146 | 147 | get writableHighWaterMark() { 148 | return HIGH_WATER_MARK; 149 | } 150 | 151 | get writableCorked() { 152 | return this[kCorked]; 153 | } 154 | } 155 | 156 | export type HeadersSentEvent = { 157 | statusCode: number, 158 | statusMessage: string, 159 | headers: [header: string, value: string][], 160 | }; 161 | 162 | export type DataWrittenEvent = { 163 | index: number, 164 | entry: WrittenDataBufferEntry, 165 | }; 166 | 167 | /** 168 | * This is an implementation of OutgoingMessage from Node.js intended to run in 169 | * Fastly Compute. The 'Writable' interface of this class is wired to an in-memory 170 | * buffer. 171 | * 172 | * This instance can be used in normal ways, but it does not give access to the 173 | * underlying socket (because there isn't one. req.socket will always return null). 174 | * 175 | * Some code in this class is transplanted/adapted from node/lib/_http_outgoing.js 176 | */ 177 | export class ComputeJsOutgoingMessage extends Writable implements OutgoingMessage { 178 | 179 | // Queue that holds all currently pending data, until the response will be 180 | // assigned to the socket (until it will its turn in the HTTP pipeline). 181 | outputData: OutputData[] = []; 182 | 183 | // `outputSize` is an approximate measure of how much data is queued on this 184 | // response. `_onPendingData` will be invoked to update similar global 185 | // per-connection counter. That counter will be used to pause/unpause the 186 | // TCP socket and HTTP Parser and thus handle the backpressure. 187 | outputSize = 0; 188 | 189 | // Difference from Node.js - 190 | // `writtenHeaderBytes` is the number of bytes the header has taken. 191 | // Since Node.js writes both the headers and body into the same outgoing 192 | // stream, it helps to keep track of this so that we can skip that many bytes 193 | // from the beginning of the stream when providing the outgoing stream. 194 | writtenHeaderBytes = 0; 195 | 196 | chunkedEncoding: boolean = false; 197 | finished: boolean = false; 198 | readonly req: IncomingMessage; 199 | sendDate: boolean = false; 200 | shouldKeepAlive: boolean = true; // ?? 201 | useChunkedEncodingByDefault: boolean = false; // Not liked by viceroy? for now disabling 202 | 203 | _last: boolean; 204 | maxRequestsOnConnectionReached: boolean; 205 | _defaultKeepAlive: boolean; 206 | _removedConnection: boolean; 207 | _removedContLen: boolean; 208 | _removedTE: boolean; 209 | 210 | _contentLength: number | null; 211 | _hasBody: boolean; 212 | _trailer: string; 213 | [kNeedDrain]: boolean; 214 | 215 | _headerSent: boolean; 216 | [kCorked]: number; 217 | _closed: boolean; 218 | 219 | _header: string | null; 220 | [kOutHeaders]: Record | null; 221 | 222 | _keepAliveTimeout: number; 223 | 224 | _onPendingData: (delta: number) => void; 225 | 226 | [kUniqueHeaders]: Set | null; 227 | 228 | _writtenDataBuffer: WrittenDataBuffer = new WrittenDataBuffer({onWrite: this._onDataWritten.bind(this)}); 229 | 230 | constructor(req: IncomingMessage) { 231 | super(); 232 | 233 | this.req = req; 234 | 235 | this._last = false; 236 | this.maxRequestsOnConnectionReached = false; 237 | this._defaultKeepAlive = true; 238 | this._removedConnection = false; 239 | this._removedContLen = false; 240 | this._removedTE = false; 241 | this._contentLength = null; 242 | this._hasBody = true; 243 | this._trailer = ''; 244 | this[kNeedDrain] = false; 245 | this._headerSent = false; 246 | this[kCorked] = 0; 247 | this._closed = false; 248 | 249 | this._header = null; 250 | this[kOutHeaders] = null; 251 | 252 | this._keepAliveTimeout = 0; 253 | 254 | this._onPendingData = nop; 255 | 256 | this[kUniqueHeaders] = null; 257 | } 258 | 259 | get _headers() { 260 | console.warn('DEP0066: OutgoingMessage.prototype._headers is deprecated'); 261 | return this.getHeaders(); 262 | } 263 | 264 | set _headers(val) { 265 | console.warn('DEP0066: OutgoingMessage.prototype._headers is deprecated'); 266 | if (val == null) { 267 | this[kOutHeaders] = null; 268 | } else if (typeof val === 'object') { 269 | const headers = this[kOutHeaders] = Object.create(null); 270 | const keys = Object.keys(val); 271 | // Retain for(;;) loop for performance reasons 272 | // Refs: https://github.com/nodejs/node/pull/30958 273 | for (let i = 0; i < keys.length; ++i) { 274 | const name = keys[i]; 275 | headers[name.toLowerCase()] = [name, val[name]]; 276 | } 277 | } 278 | } 279 | 280 | get connection() { 281 | // Difference from Node.js - 282 | // Connection is not supported 283 | return null; 284 | } 285 | 286 | set connection(_socket: any) { 287 | // Difference from Node.js - 288 | // Connection is not supported 289 | console.error('No support for OutgoingMessage.connection'); 290 | } 291 | 292 | get socket() { 293 | // Difference from Node.js - 294 | // socket is not supported 295 | return null; 296 | } 297 | 298 | set socket(_socket: any) { 299 | // Difference from Node.js - 300 | // socket is not supported 301 | console.error('No support for OutgoingMessage.socket'); 302 | } 303 | 304 | get _headerNames() { 305 | console.warn('DEP0066: OutgoingMessage.prototype._headerNames is deprecated'); 306 | const headers = this[kOutHeaders]; 307 | if (headers !== null) { 308 | const out = Object.create(null); 309 | const keys = Object.keys(headers); 310 | // Retain for(;;) loop for performance reasons 311 | // Refs: https://github.com/nodejs/node/pull/30958 312 | for (let i = 0; i < keys.length; ++i) { 313 | const key = keys[i]; 314 | const val = headers[key][0]; 315 | out[key] = val; 316 | } 317 | return out; 318 | } 319 | return null; 320 | } 321 | 322 | set _headerNames(val: any) { 323 | console.warn('DEP0066: OutgoingMessage.prototype._headerNames is deprecated'); 324 | if (typeof val === 'object' && val !== null) { 325 | const headers = this[kOutHeaders]; 326 | if (!headers) 327 | return; 328 | const keys = Object.keys(val); 329 | // Retain for(;;) loop for performance reasons 330 | // Refs: https://github.com/nodejs/node/pull/30958 331 | for (let i = 0; i < keys.length; ++i) { 332 | const header = headers[keys[i]]; 333 | if (header) 334 | header[0] = val[keys[i]]; 335 | } 336 | } 337 | } 338 | 339 | _renderHeaders() { 340 | if (this._header) { 341 | throw new ERR_HTTP_HEADERS_SENT('render'); 342 | } 343 | 344 | const headersMap = this[kOutHeaders]; 345 | const headers: Record = {}; 346 | 347 | if (headersMap !== null) { 348 | const keys = Object.keys(headersMap); 349 | // Retain for(;;) loop for performance reasons 350 | // Refs: https://github.com/nodejs/node/pull/30958 351 | for (let i = 0, l = keys.length; i < l; i++) { 352 | const key = keys[i]; 353 | headers[headersMap[key][0]] = headersMap[key][1]; 354 | } 355 | } 356 | return headers; 357 | } 358 | 359 | override cork(): void { 360 | // Difference from Node.js - 361 | // In Node.js, if a socket exists, we would call cork() on the socket instead 362 | // In our implementation, we do the same to the "written data buffer" instead. 363 | 364 | if(this._writtenDataBuffer != null) { 365 | this._writtenDataBuffer.cork(); 366 | } else { 367 | this[kCorked]++; 368 | } 369 | } 370 | 371 | override uncork(): void { 372 | // Difference from Node.js - 373 | // In Node.js, if a socket exists, we would call uncork() on the socket instead 374 | // In our implementation, we do the same to the "written data buffer" instead. 375 | 376 | if(this._writtenDataBuffer != null) { 377 | this._writtenDataBuffer.uncork(); 378 | } else { 379 | this[kCorked]--; 380 | } 381 | } 382 | 383 | setTimeout(msecs: number, callback?: () => void): this { 384 | // Difference from Node.js - 385 | // In Node.js, this is supposed to set the underlying socket to time out 386 | // after some time and then run a callback. 387 | // We do nothing here since we don't really have a way to support direct 388 | // access to the socket. 389 | return this; 390 | } 391 | 392 | override destroy(error?: Error): this { 393 | if (this.destroyed) { 394 | return this; 395 | } 396 | this.destroyed = true; 397 | 398 | // Difference from Node.js - 399 | // In Node.js, we would also attempt to destroy the underlying socket. 400 | return this; 401 | } 402 | 403 | _send(data: string | Uint8Array, encoding?: BufferEncoding | WriteCallback, callback?: WriteCallback) { 404 | // This is a shameful hack to get the headers and first body chunk onto 405 | // the same packet. Future versions of Node are going to take care of 406 | // this at a lower level and in a more general way. 407 | if (!this._headerSent) { 408 | const header = this._header!; 409 | if (typeof data === 'string' && 410 | (encoding === 'utf8' || encoding === 'latin1' || !encoding)) { 411 | data = header + data; 412 | } else { 413 | this.outputData.unshift({ 414 | data: header, 415 | encoding: 'latin1', 416 | callback: undefined, 417 | }); 418 | this.outputSize += header.length; 419 | this._onPendingData(header.length); 420 | } 421 | this.writtenHeaderBytes = header.length; 422 | 423 | // Save written headers as object 424 | const [ statusLine, ...headerLines ] = this._header!.split('\r\n'); 425 | 426 | const STATUS_LINE_REGEXP = /^HTTP\/1\.1 (?\d+) (?.*)$/; 427 | const statusLineResult = STATUS_LINE_REGEXP.exec(statusLine); 428 | 429 | if (statusLineResult == null) { 430 | throw new Error('Unexpected! Status line was ' + statusLine); 431 | } 432 | 433 | const { statusCode: statusCodeText, statusMessage } = statusLineResult.groups ?? {}; 434 | const statusCode = parseInt(statusCodeText, 10); 435 | const headers: [header: string, value: string][] = [] 436 | 437 | for (const headerLine of headerLines) { 438 | if(headerLine !== '') { 439 | const pos = headerLine.indexOf(': '); 440 | const k = headerLine.slice(0, pos); 441 | const v = headerLine.slice(pos + 2); // Skip the colon and the space 442 | headers.push([k, v]); 443 | } 444 | } 445 | 446 | this._headerSent = true; 447 | 448 | // Difference from Node.js - 449 | // After headers are 'sent', we trigger an event 450 | const event: HeadersSentEvent = { 451 | statusCode, 452 | statusMessage, 453 | headers, 454 | }; 455 | this.emit('_headersSent', event); 456 | } 457 | return this._writeRaw(data, encoding, callback); 458 | }; 459 | 460 | _onDataWritten(index: number, entry: WrittenDataBufferEntry) { 461 | const event: DataWrittenEvent = { index, entry }; 462 | this.emit('_dataWritten', event); 463 | } 464 | 465 | _writeRaw(data: string | Uint8Array, encoding?: BufferEncoding | WriteCallback, callback?: WriteCallback) { 466 | // Difference from Node.js - 467 | // In Node.js, we would check for an underlying socket, and if that socket 468 | // exists and is already destroyed, simply return false. 469 | 470 | let e: BufferEncoding | undefined; 471 | if (typeof encoding === 'function') { 472 | callback = encoding; 473 | e = undefined; 474 | } else { 475 | e = encoding; 476 | } 477 | 478 | // Difference from Node.js - 479 | // In Node.js, we would check for an underlying socket, and if that socket 480 | // exists and is currently writable, it would flush any pending data to the socket and then 481 | // write the current chunk's data directly into the socket. Afterwards, it would return with the 482 | // value returned from socket.write(). 483 | 484 | // In our implementation, instead we do the same for the "written data buffer". 485 | if(this._writtenDataBuffer != null) { 486 | // There might be pending data in the this.output buffer. 487 | if (this.outputData.length) { 488 | this._flushOutput(this._writtenDataBuffer); 489 | } 490 | // Directly write to the buffer. 491 | return this._writtenDataBuffer.write(data, e, callback); 492 | } 493 | 494 | // Buffer, as long as we're not destroyed. 495 | this.outputData.push({ data, encoding: e, callback }); 496 | this.outputSize += data.length; 497 | this._onPendingData(data.length); 498 | return this.outputSize < HIGH_WATER_MARK; 499 | } 500 | 501 | _storeHeader(firstLine: string, headers: OutgoingHttpHeaders | ReadonlyArray | ReadonlyArray<[string, string]> | null) { 502 | // firstLine in the case of request is: 'GET /index.html HTTP/1.1\r\n' 503 | // in the case of response it is: 'HTTP/1.1 200 OK\r\n' 504 | const state = { 505 | connection: false, 506 | contLen: false, 507 | te: false, 508 | date: false, 509 | expect: false, 510 | trailer: false, 511 | header: firstLine 512 | }; 513 | 514 | if (headers) { 515 | if (headers === this[kOutHeaders]) { 516 | for (const key in headers) { 517 | const entry = (headers as Record)[key]; 518 | processHeader(this, state, entry[0], entry[1], false); 519 | } 520 | } else if (Array.isArray(headers)) { 521 | if (headers.length && Array.isArray(headers[0])) { 522 | for (let i = 0; i < headers.length; i++) { 523 | const entry = headers[i]; 524 | processHeader(this, state, entry[0], entry[1], true); 525 | } 526 | } else { 527 | if (headers.length % 2 !== 0) { 528 | throw new ERR_INVALID_ARG_VALUE('headers', headers); 529 | } 530 | 531 | for (let n = 0; n < headers.length; n += 2) { 532 | processHeader(this, state, headers[n], headers[n + 1], true); 533 | } 534 | } 535 | } else { 536 | for (const key in headers) { 537 | if (headers.hasOwnProperty(key)) { 538 | const _headers = headers as OutgoingHttpHeaders; 539 | processHeader(this, state, key, _headers[key] as OutgoingHttpHeader, true); 540 | } 541 | } 542 | } 543 | } 544 | 545 | let { header } = state; 546 | 547 | // Date header 548 | if (this.sendDate && !state.date) { 549 | header += 'Date: ' + utcDate() + '\r\n'; 550 | } 551 | 552 | // Force the connection to close when the response is a 204 No Content or 553 | // a 304 Not Modified and the user has set a "Transfer-Encoding: chunked" 554 | // header. 555 | // 556 | // RFC 2616 mandates that 204 and 304 responses MUST NOT have a body but 557 | // node.js used to send out a zero chunk anyway to accommodate clients 558 | // that don't have special handling for those responses. 559 | // 560 | // It was pointed out that this might confuse reverse proxies to the point 561 | // of creating security liabilities, so suppress the zero chunk and force 562 | // the connection to close. 563 | 564 | // NOTE: the "as any" here is needed because 'statusCode' is only 565 | // defined on the subclass but is used here. 566 | if ( 567 | this.chunkedEncoding && ((this as any).statusCode === 204 || 568 | (this as any).statusCode === 304)) { 569 | debug((this as any).statusCode + ' response should not use chunked encoding,' + 570 | ' closing connection.'); 571 | this.chunkedEncoding = false; 572 | this.shouldKeepAlive = false; 573 | } 574 | 575 | // keep-alive logic 576 | if (this._removedConnection) { 577 | this._last = true; 578 | this.shouldKeepAlive = false; 579 | } else if (!state.connection) { 580 | // this.agent would only exist on class ClientRequest 581 | const shouldSendKeepAlive = ( 582 | this.shouldKeepAlive && 583 | (state.contLen || this.useChunkedEncodingByDefault /* || this.agent */) 584 | ); 585 | if (shouldSendKeepAlive && this.maxRequestsOnConnectionReached) { 586 | header += 'Connection: close\r\n'; 587 | } else if (shouldSendKeepAlive) { 588 | header += 'Connection: keep-alive\r\n'; 589 | if (this._keepAliveTimeout && this._defaultKeepAlive) { 590 | const timeoutSeconds = Math.floor(this._keepAliveTimeout / 1000); 591 | header += `Keep-Alive: timeout=${timeoutSeconds}\r\n`; 592 | } 593 | } else { 594 | this._last = true; 595 | header += 'Connection: close\r\n'; 596 | } 597 | } 598 | 599 | if (!state.contLen && !state.te) { 600 | if (!this._hasBody) { 601 | // Make sure we don't end the 0\r\n\r\n at the end of the message. 602 | this.chunkedEncoding = false; 603 | } else if (!this.useChunkedEncodingByDefault) { 604 | this._last = true; 605 | } else if (!state.trailer && 606 | !this._removedContLen && 607 | typeof this._contentLength === 'number') { 608 | header += 'Content-Length: ' + this._contentLength + '\r\n'; 609 | } else if (!this._removedTE) { 610 | header += 'Transfer-Encoding: chunked\r\n'; 611 | this.chunkedEncoding = true; 612 | } else { 613 | // We should only be able to get here if both Content-Length and 614 | // Transfer-Encoding are removed by the user. 615 | // See: test/parallel/test-http-remove-header-stays-removed.js 616 | debug('Both Content-Length and Transfer-Encoding are removed'); 617 | } 618 | } 619 | 620 | // Test non-chunked message does not have trailer header set, 621 | // message will be terminated by the first empty line after the 622 | // header fields, regardless of the header fields present in the 623 | // message, and thus cannot contain a message body or 'trailers'. 624 | if (this.chunkedEncoding !== true && state.trailer) { 625 | throw new ERR_HTTP_TRAILER_INVALID(); 626 | } 627 | 628 | this._header = header + '\r\n'; 629 | this._headerSent = false; 630 | 631 | // Wait until the first body chunk, or close(), is sent to flush, 632 | // UNLESS we're sending Expect: 100-continue. 633 | if (state.expect) { 634 | this._send(''); 635 | } 636 | } 637 | 638 | setHeader(name: string, value: number | string | ReadonlyArray): this { 639 | if (this._header) { 640 | throw new ERR_HTTP_HEADERS_SENT('set'); 641 | } 642 | validateHeaderName(name); 643 | validateHeaderValue(name, value); 644 | 645 | let headers = this[kOutHeaders]; 646 | if (headers === null) { 647 | this[kOutHeaders] = headers = Object.create(null); 648 | } 649 | 650 | headers![name.toLowerCase()] = [name, value]; 651 | return this; 652 | } 653 | 654 | appendHeader(name: string, value: number | string | ReadonlyArray) { 655 | if (this._header) { 656 | throw new ERR_HTTP_HEADERS_SENT('append'); 657 | } 658 | validateHeaderName(name); 659 | validateHeaderValue(name, value); 660 | 661 | const field = name.toLowerCase(); 662 | const headers = this[kOutHeaders]; 663 | if (headers === null || !headers[field]) { 664 | return this.setHeader(name, value); 665 | } 666 | 667 | // Prepare the field for appending, if required 668 | if (!Array.isArray(headers[field][1])) { 669 | headers[field][1] = [headers[field][1]]; 670 | } 671 | 672 | const existingValues = headers[field][1]; 673 | if (Array.isArray(value)) { 674 | for (let i = 0, length = value.length; i < length; i++) { 675 | existingValues.push(value[i]); 676 | } 677 | } else { 678 | existingValues.push(value); 679 | } 680 | 681 | return this; 682 | } 683 | 684 | getHeader(name: string): number | string | string[] | undefined { 685 | validateString(name, 'name'); 686 | 687 | const headers = this[kOutHeaders]; 688 | if (headers === null) { 689 | return undefined; 690 | } 691 | 692 | const entry = headers[name.toLowerCase()]; 693 | return entry && entry[1]; 694 | } 695 | 696 | getHeaderNames(): string[] { 697 | return this[kOutHeaders] !== null ? Object.keys(this[kOutHeaders]) : []; 698 | } 699 | 700 | getRawHeaderNames() { 701 | const headersMap = this[kOutHeaders]; 702 | if (headersMap === null) return []; 703 | 704 | const values = Object.values(headersMap); 705 | const headers = Array(values.length); 706 | // Retain for(;;) loop for performance reasons 707 | // Refs: https://github.com/nodejs/node/pull/30958 708 | for (let i = 0, l = values.length; i < l; i++) { 709 | headers[i] = values[i][0]; 710 | } 711 | 712 | return headers; 713 | }; 714 | 715 | getHeaders(): OutgoingHttpHeaders { 716 | const headers = this[kOutHeaders]; 717 | const ret = Object.create(null); 718 | if (headers) { 719 | const keys = Object.keys(headers); 720 | // Retain for(;;) loop for performance reasons 721 | // Refs: https://github.com/nodejs/node/pull/30958 722 | for (let i = 0; i < keys.length; ++i) { 723 | const key = keys[i]; 724 | const val = headers[key][1]; 725 | ret[key] = val; 726 | } 727 | } 728 | return ret; 729 | } 730 | 731 | hasHeader(name: string): boolean { 732 | validateString(name, 'name'); 733 | return this[kOutHeaders] !== null && 734 | !!this[kOutHeaders][name.toLowerCase()]; 735 | } 736 | 737 | removeHeader(name: string): void { 738 | validateString(name, 'name'); 739 | 740 | if (this._header) { 741 | throw new ERR_HTTP_HEADERS_SENT('remove'); 742 | } 743 | 744 | const key = name.toLowerCase(); 745 | 746 | switch (key) { 747 | case 'connection': 748 | this._removedConnection = true; 749 | break; 750 | case 'content-length': 751 | this._removedContLen = true; 752 | break; 753 | case 'transfer-encoding': 754 | this._removedTE = true; 755 | break; 756 | case 'date': 757 | this.sendDate = false; 758 | break; 759 | } 760 | 761 | if (this[kOutHeaders] !== null) { 762 | delete this[kOutHeaders][key]; 763 | } 764 | } 765 | 766 | _implicitHeader() { 767 | throw new ERR_METHOD_NOT_IMPLEMENTED('_implicitHeader()'); 768 | } 769 | 770 | get headersSent() { 771 | return !!this._header; 772 | } 773 | 774 | override write(chunk: string | Buffer | Uint8Array, encoding?: BufferEncoding | WriteCallback, callback?: WriteCallback): boolean { 775 | let e: BufferEncoding | undefined; 776 | if (typeof encoding === 'function') { 777 | callback = encoding; 778 | e = undefined; 779 | } else { 780 | e = encoding; 781 | } 782 | 783 | const ret = write_(this, chunk, e, callback, false); 784 | if (!ret) { 785 | this[kNeedDrain] = true; 786 | } 787 | return ret; 788 | } 789 | 790 | addTrailers(headers: OutgoingHttpHeaders | ReadonlyArray<[string, string]>): void { 791 | this._trailer = ''; 792 | 793 | const isArray = Array.isArray(headers); 794 | const keys = isArray ? [...headers.keys()] : Object.keys(headers); 795 | // Retain for(;;) loop for performance reasons 796 | // Refs: https://github.com/nodejs/node/pull/30958 797 | for (let i = 0, l = keys.length; i < l; i++) { 798 | let field: string, value: OutgoingHttpHeader | undefined; 799 | if (isArray) { 800 | const _headers = headers as ReadonlyArray<[string, string]>; 801 | const key = keys[i] as number; 802 | field = _headers[key][0]; 803 | value = _headers[key][1]; 804 | } else { 805 | const _headers = headers as OutgoingHttpHeaders; 806 | const key = keys[i] as string; 807 | field = key; 808 | value = _headers[key]; 809 | } 810 | if (!field || !checkIsHttpToken(field)) { 811 | throw new ERR_INVALID_HTTP_TOKEN('Trailer name', field); 812 | } 813 | 814 | // Check if the field must be sent several times 815 | if ( 816 | Array.isArray(value) && value.length > 1 && 817 | (!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase())) 818 | ) { 819 | for (let j = 0, l = value.length; j < l; j++) { 820 | if (checkInvalidHeaderChar(value[j])) { 821 | debug(`Trailer "${field}"[${j}] contains invalid characters`); 822 | throw new ERR_INVALID_CHAR('trailer content', field); 823 | } 824 | this._trailer += field + ': ' + value[j] + '\r\n'; 825 | } 826 | } else { 827 | if (Array.isArray(value)) { 828 | value = value.join('; '); 829 | } else { 830 | value = String(value); 831 | } 832 | 833 | if (checkInvalidHeaderChar(value)) { 834 | debug(`Trailer "${field}" contains invalid characters`); 835 | throw new ERR_INVALID_CHAR('trailer content', field); 836 | } 837 | this._trailer += field + ': ' + value + '\r\n'; 838 | } 839 | } 840 | } 841 | 842 | override end(chunk?: string | Buffer | Uint8Array | WriteCallback, encoding?: BufferEncoding | WriteCallback, callback?: WriteCallback) { 843 | let ch: string | Buffer | Uint8Array | undefined; 844 | let e: BufferEncoding | undefined; 845 | if (typeof chunk === 'function') { 846 | callback = chunk; 847 | ch = undefined; 848 | e = undefined; 849 | } else if (typeof encoding === 'function') { 850 | callback = encoding; 851 | ch = chunk; 852 | e = undefined; 853 | } else { 854 | ch = chunk; 855 | e = encoding; 856 | } 857 | 858 | if (ch) { 859 | if (this.finished) { 860 | onError(this, 861 | new ERR_STREAM_WRITE_AFTER_END(), 862 | typeof callback !== 'function' ? nop : callback); 863 | return this; 864 | } 865 | 866 | // Difference from Node.js - 867 | // In Node.js, if a socket exists, we would also call socket.cork() at this point. 868 | // For our implementation we do the same for the "written data buffer" 869 | if(this._writtenDataBuffer != null) { 870 | this._writtenDataBuffer.cork(); 871 | } 872 | write_(this, ch, e, undefined, true); 873 | } else if (this.finished) { 874 | if (typeof callback === 'function') { 875 | if (!this.writableFinished) { 876 | this.on('finish', callback); 877 | } else { 878 | callback(new ERR_STREAM_ALREADY_FINISHED('end')); 879 | } 880 | } 881 | return this; 882 | } else if (!this._header) { 883 | // Difference from Node.js - 884 | // In Node.js, if a socket exists, we would also call socket.cork() at this point. 885 | // For our implementation we do the same for the "written data buffer" 886 | if(this._writtenDataBuffer != null) { 887 | this._writtenDataBuffer.cork(); 888 | } 889 | this._contentLength = 0; 890 | this._implicitHeader(); 891 | } 892 | 893 | if (typeof callback === 'function') 894 | this.once('finish', callback); 895 | 896 | const finish = onFinish.bind(undefined, this); 897 | 898 | if (this._hasBody && this.chunkedEncoding) { 899 | this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish); 900 | } else if (!this._headerSent || this.writableLength || ch) { 901 | this._send('', 'latin1', finish); 902 | } else { 903 | process.nextTick(finish); 904 | } 905 | 906 | // Difference from Node.js - 907 | // In Node.js, if a socket exists, we would also call socket.uncork() at this point. 908 | // For our implementation we do the same for the "written data buffer" 909 | if(this._writtenDataBuffer != null) { 910 | this._writtenDataBuffer.uncork(); 911 | } 912 | this[kCorked] = 0; 913 | 914 | this.finished = true; 915 | 916 | // There is the first message on the outgoing queue, and we've sent 917 | // everything to the socket. 918 | debug('outgoing message end.'); 919 | // Difference from Node.js - 920 | // In Node.js, if a socket exists, and there is no pending output data, 921 | // we would also call this._finish() at this point. 922 | // For our implementation we do the same for the "written data buffer" 923 | 924 | if (this.outputData.length === 0 && 925 | this._writtenDataBuffer != null 926 | ) { 927 | this._finish(); 928 | } 929 | 930 | return this; 931 | } 932 | 933 | _finish() { 934 | // Difference from Node.js - 935 | // In Node.js, this function is only called if a socket exists. 936 | // This function would assert() for a socket and then emit 'prefinish'. 937 | // For our implementation we do the same for the "written data buffer" 938 | this.emit('prefinish'); 939 | } 940 | 941 | _flushOutput(dataBuffer: WrittenDataBuffer) { 942 | while (this[kCorked]) { 943 | this[kCorked]--; 944 | dataBuffer.cork(); 945 | } 946 | 947 | const outputLength = this.outputData.length; 948 | if (outputLength <= 0) 949 | return undefined; 950 | 951 | const outputData = this.outputData; 952 | dataBuffer.cork(); 953 | let ret; 954 | // Retain for(;;) loop for performance reasons 955 | // Refs: https://github.com/nodejs/node/pull/30958 956 | for (let i = 0; i < outputLength; i++) { 957 | const { data, encoding, callback } = outputData[i]; 958 | ret = dataBuffer.write(data, encoding, callback); 959 | } 960 | dataBuffer.uncork(); 961 | 962 | this.outputData = []; 963 | this._onPendingData(-this.outputSize); 964 | this.outputSize = 0; 965 | 966 | return ret; 967 | } 968 | 969 | flushHeaders(): void { 970 | if (!this._header) { 971 | this._implicitHeader(); 972 | } 973 | 974 | // Force-flush the headers. 975 | this._send(''); 976 | } 977 | 978 | override pipe(destination: T): T { 979 | // OutgoingMessage should be write-only. Piping from it is disabled. 980 | this.emit('error', new ERR_STREAM_CANNOT_PIPE()); 981 | return destination; 982 | }; 983 | } 984 | 985 | type HeaderState = { 986 | connection: boolean, 987 | contLen: boolean, 988 | te: boolean, 989 | date: boolean, 990 | expect: boolean, 991 | trailer: boolean, 992 | header: string, 993 | }; 994 | 995 | function processHeader( 996 | self: ComputeJsOutgoingMessage, 997 | state: HeaderState, 998 | key: string, 999 | value: OutgoingHttpHeader, 1000 | validate: boolean 1001 | ) { 1002 | if (validate) { 1003 | validateHeaderName(key); 1004 | } 1005 | if (Array.isArray(value)) { 1006 | if ( 1007 | (value.length < 2 || !isCookieField(key)) && 1008 | (!self[kUniqueHeaders] || !self[kUniqueHeaders].has(key.toLowerCase())) 1009 | ) { 1010 | // Retain for(;;) loop for performance reasons 1011 | // Refs: https://github.com/nodejs/node/pull/30958 1012 | for (let i = 0; i < value.length; i++) { 1013 | storeHeader(self, state, key, value[i], validate); 1014 | } 1015 | return; 1016 | } 1017 | value = value.join('; '); 1018 | } 1019 | storeHeader(self, state, key, String(value), validate); 1020 | } 1021 | 1022 | function storeHeader( 1023 | self: ComputeJsOutgoingMessage, 1024 | state: HeaderState, 1025 | key: string, 1026 | value: string, 1027 | validate: boolean 1028 | ) { 1029 | if (validate) { 1030 | validateHeaderValue(key, value); 1031 | } 1032 | state.header += key + ': ' + value + '\r\n'; 1033 | matchHeader(self, state, key, value); 1034 | } 1035 | 1036 | function matchHeader( 1037 | self: ComputeJsOutgoingMessage, 1038 | state: HeaderState, 1039 | field: string, 1040 | value: string 1041 | ) { 1042 | if (field.length < 4 || field.length > 17) 1043 | return; 1044 | field = field.toLowerCase(); 1045 | switch (field) { 1046 | case 'connection': 1047 | state.connection = true; 1048 | self._removedConnection = false; 1049 | if (RE_CONN_CLOSE.exec(value) !== null) 1050 | self._last = true; 1051 | else 1052 | self.shouldKeepAlive = true; 1053 | break; 1054 | case 'transfer-encoding': 1055 | state.te = true; 1056 | self._removedTE = false; 1057 | if (RE_TE_CHUNKED.exec(value) !== null) 1058 | self.chunkedEncoding = true; 1059 | break; 1060 | case 'content-length': 1061 | state.contLen = true; 1062 | self._removedContLen = false; 1063 | break; 1064 | case 'date': 1065 | case 'expect': 1066 | case 'trailer': 1067 | state[field] = true; 1068 | break; 1069 | case 'keep-alive': 1070 | self._defaultKeepAlive = false; 1071 | break; 1072 | } 1073 | } 1074 | 1075 | const crlf_buf = Buffer.from('\r\n'); 1076 | 1077 | function onError(msg: ComputeJsOutgoingMessage, err: Error, callback: WriteCallback) { 1078 | // Difference from Node.js - 1079 | // In Node.js, we would check for the existence of a socket. If one exists, we would 1080 | // use that async ID to scope the error. 1081 | // Instead, we do this. 1082 | process.nextTick(emitErrorNt, msg, err, callback); 1083 | } 1084 | 1085 | function emitErrorNt(msg: ComputeJsOutgoingMessage, err: Error, callback: WriteCallback) { 1086 | callback(err); 1087 | if (typeof msg.emit === 'function' && !msg._closed) { 1088 | msg.emit('error', err); 1089 | } 1090 | } 1091 | 1092 | function write_(msg: ComputeJsOutgoingMessage, chunk: string | Buffer | Uint8Array, encoding: BufferEncoding | undefined, callback: WriteCallback | undefined, fromEnd: boolean) { 1093 | if (typeof callback !== 'function') { 1094 | callback = nop; 1095 | } 1096 | 1097 | let len: number; 1098 | if (chunk === null) { 1099 | throw new ERR_STREAM_NULL_VALUES(); 1100 | } else if (typeof chunk === 'string') { 1101 | len = Buffer.byteLength(chunk, encoding ?? undefined); 1102 | } else if (isUint8Array(chunk)) { 1103 | len = chunk.length; 1104 | } else { 1105 | throw new ERR_INVALID_ARG_TYPE( 1106 | 'chunk', ['string', 'Buffer', 'Uint8Array'], chunk); 1107 | } 1108 | 1109 | let err: Error | undefined = undefined; 1110 | if (msg.finished) { 1111 | err = new ERR_STREAM_WRITE_AFTER_END(); 1112 | } else if (msg.destroyed) { 1113 | err = new ERR_STREAM_DESTROYED('write'); 1114 | } 1115 | 1116 | if (err) { 1117 | if (!msg.destroyed) { 1118 | onError(msg, err, callback); 1119 | } else { 1120 | process.nextTick(callback, err); 1121 | } 1122 | return false; 1123 | } 1124 | 1125 | if (!msg._header) { 1126 | if (fromEnd) { 1127 | msg._contentLength = len; 1128 | } 1129 | msg._implicitHeader(); 1130 | } 1131 | 1132 | if (!msg._hasBody) { 1133 | debug('This type of response MUST NOT have a body. ' + 1134 | 'Ignoring write() calls.'); 1135 | process.nextTick(callback); 1136 | return true; 1137 | } 1138 | 1139 | // Difference from Node.js - 1140 | // In Node.js, we would also check at this point if a socket exists and is not corked. 1141 | // If so, we'd cork the socket and then queue up an 'uncork' for the next tick. 1142 | // In our implementation we do the same for "written data buffer" 1143 | if (!fromEnd && msg._writtenDataBuffer != null && !msg._writtenDataBuffer.writableCorked) { 1144 | msg._writtenDataBuffer.cork(); 1145 | process.nextTick(connectionCorkNT, msg._writtenDataBuffer); 1146 | } 1147 | 1148 | let ret; 1149 | if (msg.chunkedEncoding && chunk.length !== 0) { 1150 | msg._send(len.toString(16), 'latin1', undefined); 1151 | msg._send(crlf_buf, undefined, undefined); 1152 | msg._send(chunk, encoding, undefined); 1153 | ret = msg._send(crlf_buf, undefined, callback); 1154 | } else { 1155 | ret = msg._send(chunk, encoding, callback); 1156 | } 1157 | 1158 | debug('write ret = ' + ret); 1159 | return ret; 1160 | } 1161 | 1162 | function connectionCorkNT(dataBuffer: WrittenDataBuffer) { 1163 | dataBuffer.uncork(); 1164 | } 1165 | 1166 | function onFinish(outmsg: ComputeJsOutgoingMessage) { 1167 | // Difference from Node.js - 1168 | // In Node.js, if a socket exists and already had an error, we would simply return. 1169 | outmsg.emit('finish'); 1170 | } 1171 | 1172 | // Override some properties this way, because TypeScript won't let us override 1173 | // properties with accessors. 1174 | Object.defineProperties(ComputeJsOutgoingMessage.prototype, { 1175 | writableFinished: { 1176 | get() { 1177 | // Difference from Node.js - 1178 | // In Node.js, there is one additional requirement -- 1179 | // there must be no underlying socket (or its writableLength must be 0). 1180 | // In this implementation we will do the same against "written data buffer". 1181 | return ( 1182 | this.finished && 1183 | this.outputSize === 0 && ( 1184 | this._writtenDataBuffer == null || 1185 | this._writtenDataBuffer.writableLength === 0 1186 | ) 1187 | ); 1188 | }, 1189 | }, 1190 | writableObjectMode: { 1191 | get() { 1192 | return false; 1193 | }, 1194 | }, 1195 | writableLength: { 1196 | get() { 1197 | // Difference from Node.js - 1198 | // In Node.js, if a socket exists then that socket's writableLength is added to 1199 | // this value. 1200 | // In this implementation we will do the same against "written data buffer". 1201 | return this.outputSize + (this._writtenDataBuffer != null ? this._writtenDataBuffer.writableLength : 0); 1202 | }, 1203 | }, 1204 | writableHighWaterMark: { 1205 | get() { 1206 | // Difference from Node.js - 1207 | // In Node.js, if a socket exists then that socket's writableHighWaterMark is added to 1208 | // this value. 1209 | // In this implementation we will do the same against "written data buffer". 1210 | return HIGH_WATER_MARK + (this._writtenDataBuffer != null ? this._writtenDataBuffer.writableHighWaterMark : 0); 1211 | }, 1212 | }, 1213 | writableCorked: { 1214 | get() { 1215 | // Difference from Node.js - 1216 | // In Node.js, if a socket exists then that socket's writableCorked is added to 1217 | // this value. 1218 | // In this implementation we will do the same against "written data buffer". 1219 | return this[kCorked] + (this._writtenDataBuffer != null ? this._writtenDataBuffer.writableCorked : 0); 1220 | }, 1221 | }, 1222 | writableEnded: { 1223 | get() { 1224 | return this.finished; 1225 | }, 1226 | }, 1227 | writableNeedDrain: { 1228 | get() { 1229 | return !this.destroyed && !this.finished && this[kNeedDrain]; 1230 | }, 1231 | }, 1232 | }); 1233 | -------------------------------------------------------------------------------- /src/http-compute-js/http-server.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { toComputeResponse, toReqRes } from "./http-server"; 3 | 4 | test("multiple set-cookie headers", async () => { 5 | const { res: nodeRes } = toReqRes(new Request("https://example.com")); 6 | 7 | // taken from https://nodejs.org/api/http.html#responsesetheadername-value 8 | nodeRes.setHeader("Set-Cookie", ["type=ninja", "language=javascript"]); 9 | nodeRes.writeHead(200); 10 | nodeRes.end(); 11 | 12 | const webResponse = await toComputeResponse(nodeRes); 13 | expect(webResponse.headers.get("set-cookie")).toEqual( 14 | "type=ninja, language=javascript" 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /src/http-compute-js/http-server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | * 5 | * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. 6 | */ 7 | 8 | // This file modeled after Node.js - node/lib/_http_server.js 9 | 10 | import { Buffer } from 'buffer'; 11 | import { EventEmitter } from 'events'; 12 | import { type IncomingMessage, type OutgoingHttpHeader, type OutgoingHttpHeaders, type ServerResponse } from 'node:http'; 13 | 14 | import { 15 | ERR_HTTP_HEADERS_SENT, 16 | ERR_HTTP_INVALID_STATUS_CODE, ERR_INVALID_ARG_TYPE, 17 | ERR_INVALID_ARG_VALUE, 18 | ERR_INVALID_CHAR, 19 | ERR_METHOD_NOT_IMPLEMENTED, 20 | } from '../utils/errors.js'; 21 | import { ComputeJsOutgoingMessage, DataWrittenEvent, HeadersSentEvent } from './http-outgoing.js'; 22 | import { chunkExpression } from './http-common.js'; 23 | import { ComputeJsIncomingMessage } from './http-incoming.js'; 24 | import { kOutHeaders } from './internal-http.js'; 25 | 26 | /* These items copied from Node.js: node/lib/_http_common.js. */ 27 | 28 | const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; 29 | /** 30 | * True if val contains an invalid field-vchar 31 | * field-value = *( field-content / obs-fold ) 32 | * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] 33 | * field-vchar = VCHAR / obs-text 34 | */ 35 | function checkInvalidHeaderChar(val: string): boolean { 36 | return headerCharRegex.exec(val) !== null; 37 | } 38 | 39 | export const STATUS_CODES: Record = { 40 | 100: 'Continue', // RFC 7231 6.2.1 41 | 101: 'Switching Protocols', // RFC 7231 6.2.2 42 | 102: 'Processing', // RFC 2518 10.1 (obsoleted by RFC 4918) 43 | 103: 'Early Hints', // RFC 8297 2 44 | 200: 'OK', // RFC 7231 6.3.1 45 | 201: 'Created', // RFC 7231 6.3.2 46 | 202: 'Accepted', // RFC 7231 6.3.3 47 | 203: 'Non-Authoritative Information', // RFC 7231 6.3.4 48 | 204: 'No Content', // RFC 7231 6.3.5 49 | 205: 'Reset Content', // RFC 7231 6.3.6 50 | 206: 'Partial Content', // RFC 7233 4.1 51 | 207: 'Multi-Status', // RFC 4918 11.1 52 | 208: 'Already Reported', // RFC 5842 7.1 53 | 226: 'IM Used', // RFC 3229 10.4.1 54 | 300: 'Multiple Choices', // RFC 7231 6.4.1 55 | 301: 'Moved Permanently', // RFC 7231 6.4.2 56 | 302: 'Found', // RFC 7231 6.4.3 57 | 303: 'See Other', // RFC 7231 6.4.4 58 | 304: 'Not Modified', // RFC 7232 4.1 59 | 305: 'Use Proxy', // RFC 7231 6.4.5 60 | 307: 'Temporary Redirect', // RFC 7231 6.4.7 61 | 308: 'Permanent Redirect', // RFC 7238 3 62 | 400: 'Bad Request', // RFC 7231 6.5.1 63 | 401: 'Unauthorized', // RFC 7235 3.1 64 | 402: 'Payment Required', // RFC 7231 6.5.2 65 | 403: 'Forbidden', // RFC 7231 6.5.3 66 | 404: 'Not Found', // RFC 7231 6.5.4 67 | 405: 'Method Not Allowed', // RFC 7231 6.5.5 68 | 406: 'Not Acceptable', // RFC 7231 6.5.6 69 | 407: 'Proxy Authentication Required', // RFC 7235 3.2 70 | 408: 'Request Timeout', // RFC 7231 6.5.7 71 | 409: 'Conflict', // RFC 7231 6.5.8 72 | 410: 'Gone', // RFC 7231 6.5.9 73 | 411: 'Length Required', // RFC 7231 6.5.10 74 | 412: 'Precondition Failed', // RFC 7232 4.2 75 | 413: 'Payload Too Large', // RFC 7231 6.5.11 76 | 414: 'URI Too Long', // RFC 7231 6.5.12 77 | 415: 'Unsupported Media Type', // RFC 7231 6.5.13 78 | 416: 'Range Not Satisfiable', // RFC 7233 4.4 79 | 417: 'Expectation Failed', // RFC 7231 6.5.14 80 | 418: 'I\'m a Teapot', // RFC 7168 2.3.3 81 | 421: 'Misdirected Request', // RFC 7540 9.1.2 82 | 422: 'Unprocessable Entity', // RFC 4918 11.2 83 | 423: 'Locked', // RFC 4918 11.3 84 | 424: 'Failed Dependency', // RFC 4918 11.4 85 | 425: 'Too Early', // RFC 8470 5.2 86 | 426: 'Upgrade Required', // RFC 2817 and RFC 7231 6.5.15 87 | 428: 'Precondition Required', // RFC 6585 3 88 | 429: 'Too Many Requests', // RFC 6585 4 89 | 431: 'Request Header Fields Too Large', // RFC 6585 5 90 | 451: 'Unavailable For Legal Reasons', // RFC 7725 3 91 | 500: 'Internal Server Error', // RFC 7231 6.6.1 92 | 501: 'Not Implemented', // RFC 7231 6.6.2 93 | 502: 'Bad Gateway', // RFC 7231 6.6.3 94 | 503: 'Service Unavailable', // RFC 7231 6.6.4 95 | 504: 'Gateway Timeout', // RFC 7231 6.6.5 96 | 505: 'HTTP Version Not Supported', // RFC 7231 6.6.6 97 | 506: 'Variant Also Negotiates', // RFC 2295 8.1 98 | 507: 'Insufficient Storage', // RFC 4918 11.5 99 | 508: 'Loop Detected', // RFC 5842 7.2 100 | 509: 'Bandwidth Limit Exceeded', 101 | 510: 'Not Extended', // RFC 2774 7 102 | 511: 'Network Authentication Required' // RFC 6585 6 103 | }; 104 | 105 | /** 106 | * This is an implementation of ServerResponse from Node.js intended to run in 107 | * Fastly Compute. The 'Writable' interface of this class is wired to an in-memory 108 | * buffer. This class also provides a method that creates a Response object that 109 | * can be handled by Compute. 110 | * 111 | * This instance can be used in normal ways, but it does not give access to the 112 | * underlying socket (because there isn't one. req.socket will always return null). 113 | * 114 | * Some code in this class is transplanted/adapted from node/lib/_httpserver.js 115 | * 116 | * NOTE: Node.js doesn't really separate the body from headers, the entire "stream" 117 | * contains the headers and the body. So we provide functions that lets us pull 118 | * the headers and body out individually at a later time. 119 | */ 120 | export class ComputeJsServerResponse extends ComputeJsOutgoingMessage implements ServerResponse { 121 | 122 | static encoder = new TextEncoder(); 123 | 124 | statusCode: number = 200; 125 | statusMessage!: string; 126 | 127 | _sent100: boolean; 128 | _expect_continue: boolean; 129 | 130 | constructor(req: IncomingMessage) { 131 | super(req); 132 | 133 | if (req.method === 'HEAD') { 134 | this._hasBody = false; 135 | } 136 | 137 | // this.req = req; // super() actually does this 138 | this.sendDate = true; 139 | this._sent100 = false; 140 | this._expect_continue = false; 141 | 142 | if (req.httpVersionMajor < 1 || req.httpVersionMinor < 1) { 143 | this.useChunkedEncodingByDefault = chunkExpression.exec(String(req.headers.te)) !== null; 144 | this.shouldKeepAlive = false; 145 | } 146 | 147 | // Difference from Node.js - 148 | // In Node.js, in addition to the above, we would check if an observer is enabled for 149 | // http, and if it is, we would start performance measurement of server response statistics. 150 | // We may choose to do something like this too in the future. 151 | 152 | // In our implementation, we set up some event handlers to fulfill the Compute Response. 153 | this.computeResponse = new Promise(resolve => { 154 | let finished = false; 155 | this.on('finish', () => { 156 | finished = true; 157 | }); 158 | const initialDataChunks: (Buffer | Uint8Array)[] = []; 159 | const initialDataWrittenHandler = (e: DataWrittenEvent) => { 160 | if (finished) { 161 | return; 162 | } 163 | initialDataChunks[e.index] = this.dataFromDataWrittenEvent(e); 164 | }; 165 | this.on('_dataWritten', initialDataWrittenHandler); 166 | this.on('_headersSent', (e: HeadersSentEvent) => { 167 | this.off('_dataWritten', initialDataWrittenHandler); 168 | // Convert the response object to Compute Response object and return it 169 | const { statusCode, statusMessage, headers } = e; 170 | resolve(this._toComputeResponse(statusCode, statusMessage, headers, initialDataChunks, finished)); 171 | }); 172 | }); 173 | } 174 | 175 | dataFromDataWrittenEvent(e: DataWrittenEvent): Buffer | Uint8Array { 176 | const { index, entry } = e; 177 | 178 | let { data, encoding } = entry; 179 | if(index === 0) { 180 | if(typeof data !== 'string') { 181 | console.error('First chunk should be string, not sure what happened.'); 182 | throw new ERR_INVALID_ARG_TYPE('packet.data', [ 'string', 'Buffer', 'Uint8Array' ], data); 183 | } 184 | // The first X bytes are header material, so we remove it. 185 | data = data.slice(this.writtenHeaderBytes); 186 | } 187 | 188 | if(typeof data === 'string') { 189 | if(encoding === undefined || encoding === 'utf8' || encoding === 'utf-8') { 190 | data = ComputeJsServerResponse.encoder.encode(data); 191 | } else { 192 | data = Buffer.from(data, encoding); 193 | } 194 | } 195 | 196 | return data; 197 | } 198 | 199 | override _finish() { 200 | // Difference from Node.js - 201 | // In Node.js, if server response statistics performance is being measured, we would stop it. 202 | super._finish(); 203 | } 204 | 205 | assignSocket(socket: any): void { 206 | // Difference from Node.js - 207 | // Socket is not supported 208 | throw new ERR_METHOD_NOT_IMPLEMENTED('assignSocket'); 209 | } 210 | 211 | detachSocket(socket: any): void { 212 | // Difference from Node.js - 213 | // Socket is not supported 214 | throw new ERR_METHOD_NOT_IMPLEMENTED('detachSocket'); 215 | } 216 | 217 | writeContinue(callback?: () => void): void { 218 | this._writeRaw('HTTP/1.1 100 Continue\r\n\r\n', 'ascii', callback); 219 | this._sent100 = true; 220 | } 221 | 222 | writeProcessing(callback?: () => void): void { 223 | this._writeRaw('HTTP/1.1 102 Processing\r\n\r\n', 'ascii', callback); 224 | } 225 | 226 | override _implicitHeader() { 227 | this.writeHead(this.statusCode); 228 | } 229 | 230 | writeHead( 231 | statusCode: number, 232 | reason?: string | OutgoingHttpHeaders | OutgoingHttpHeader[], 233 | obj?: OutgoingHttpHeaders | OutgoingHttpHeader[] 234 | ): this { 235 | const originalStatusCode = statusCode; 236 | 237 | statusCode |= 0; 238 | if (statusCode < 100 || statusCode > 999) { 239 | throw new ERR_HTTP_INVALID_STATUS_CODE(originalStatusCode); 240 | } 241 | 242 | if (typeof reason === 'string') { 243 | // This means this was called as: 244 | // writeHead(statusCode, reasonPhrase[, headers]) 245 | this.statusMessage = reason; 246 | } else { 247 | // This means this was called as: 248 | // writeHead(statusCode[, headers]) 249 | if (!this.statusMessage) 250 | this.statusMessage = STATUS_CODES[statusCode] || 'unknown'; 251 | obj = reason; 252 | } 253 | this.statusCode = statusCode; 254 | 255 | let headers; 256 | if (this[kOutHeaders]) { 257 | // Slow-case: when progressive API and header fields are passed. 258 | let k; 259 | if (Array.isArray(obj)) { 260 | if (obj.length % 2 !== 0) { 261 | throw new ERR_INVALID_ARG_VALUE('headers', obj); 262 | } 263 | 264 | for (let n = 0; n < obj.length; n += 2) { 265 | k = obj[n]; 266 | if (k) { 267 | this.setHeader(k as string, obj[n + 1]); 268 | } 269 | } 270 | } else if (obj) { 271 | const keys = Object.keys(obj); 272 | // Retain for(;;) loop for performance reasons 273 | // Refs: https://github.com/nodejs/node/pull/30958 274 | for (let i = 0; i < keys.length; i++) { 275 | k = keys[i]; 276 | if (k) { 277 | this.setHeader(k, obj[k]!); 278 | } 279 | } 280 | } 281 | if (k === undefined && this._header) { 282 | throw new ERR_HTTP_HEADERS_SENT('render'); 283 | } 284 | // Only progressive api is used 285 | headers = this[kOutHeaders]; 286 | } else { 287 | // Only writeHead() called 288 | headers = obj; 289 | } 290 | 291 | if (checkInvalidHeaderChar(this.statusMessage)) { 292 | throw new ERR_INVALID_CHAR('statusMessage'); 293 | } 294 | 295 | const statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; 296 | 297 | if (statusCode === 204 || statusCode === 304 || 298 | (statusCode >= 100 && statusCode <= 199)) { 299 | // RFC 2616, 10.2.5: 300 | // The 204 response MUST NOT include a message-body, and thus is always 301 | // terminated by the first empty line after the header fields. 302 | // RFC 2616, 10.3.5: 303 | // The 304 response MUST NOT contain a message-body, and thus is always 304 | // terminated by the first empty line after the header fields. 305 | // RFC 2616, 10.1 Informational 1xx: 306 | // This class of status code indicates a provisional response, 307 | // consisting only of the Status-Line and optional headers, and is 308 | // terminated by an empty line. 309 | this._hasBody = false; 310 | } 311 | 312 | // Don't keep alive connections where the client expects 100 Continue 313 | // but we sent a final status; they may put extra bytes on the wire. 314 | if (this._expect_continue && !this._sent100) { 315 | this.shouldKeepAlive = false; 316 | } 317 | 318 | this._storeHeader(statusLine, headers ?? null); 319 | 320 | return this; 321 | } 322 | 323 | writeHeader = this.writeHead; 324 | 325 | computeResponse: Promise; 326 | 327 | _toComputeResponse( 328 | status: number, 329 | statusText: string, 330 | sentHeaders: [header: string, value: string][], 331 | initialDataChunks: (Buffer | Uint8Array)[], 332 | finished: boolean, 333 | ) { 334 | const headers = new Headers(); 335 | for (const [header, value] of sentHeaders) { 336 | headers.append(header, value); 337 | } 338 | 339 | const _this = this; 340 | const body = this._hasBody ? new ReadableStream({ 341 | start(controller) { 342 | for (const dataChunk of initialDataChunks) { 343 | controller.enqueue(dataChunk); 344 | } 345 | 346 | if(finished) { 347 | controller.close(); 348 | } else { 349 | _this.on('finish', () => { 350 | finished = true; 351 | controller.close(); 352 | }); 353 | _this.on('_dataWritten', (e: DataWrittenEvent) => { 354 | if (finished) { 355 | return; 356 | } 357 | const data = _this.dataFromDataWrittenEvent(e); 358 | controller.enqueue(data); 359 | }); 360 | } 361 | }, 362 | }) : null; 363 | 364 | return new Response(body, { 365 | status, 366 | statusText, 367 | headers, 368 | }); 369 | } 370 | } 371 | 372 | export type ReqRes = { 373 | req: IncomingMessage, 374 | res: ServerResponse, 375 | }; 376 | 377 | export type ToReqResOptions = { 378 | createIncomingMessage?: (ctx?: any) => ComputeJsIncomingMessage, 379 | createServerResponse?: (incomingMessage: ComputeJsIncomingMessage, ctx?: any) => ComputeJsServerResponse, 380 | ctx?: any; 381 | }; 382 | 383 | export function toReqRes(req: Request, options?: ToReqResOptions): ReqRes { 384 | 385 | const { 386 | createIncomingMessage = () => new ComputeJsIncomingMessage(), 387 | createServerResponse = (incoming: ComputeJsIncomingMessage) => new ComputeJsServerResponse(incoming), 388 | ctx, 389 | } = options ?? {}; 390 | 391 | const incoming = createIncomingMessage(ctx); 392 | const serverResponse = createServerResponse(incoming, ctx); 393 | 394 | const reqUrl = new URL(req.url); 395 | 396 | // In C@E I don't think you can actually detect HTTP version, so we'll use 1.1 397 | // Who uses this anyway? 398 | const versionMajor = 1; 399 | const versionMinor = 1; 400 | incoming.httpVersionMajor = versionMajor; 401 | incoming.httpVersionMinor = versionMinor; 402 | incoming.httpVersion = `${versionMajor}.${versionMinor}`; 403 | incoming.url = reqUrl.pathname + reqUrl.search; 404 | incoming.upgrade = false; // TODO: support this, if there is some way to do it 405 | 406 | const headers = []; 407 | for (const [headerName, headerValue] of req.headers) { 408 | headers.push(headerName); 409 | headers.push(headerValue); 410 | } 411 | 412 | incoming._addHeaderLines(headers, headers.length); 413 | 414 | incoming.method = req.method; 415 | incoming._stream = req.body; 416 | 417 | return { 418 | req: incoming, 419 | res: serverResponse, 420 | }; 421 | 422 | } 423 | 424 | export function toComputeResponse(res: ServerResponse) { 425 | if(!(res instanceof ComputeJsServerResponse)) { 426 | throw new Error('toComputeResponse must be called on ServerResponse generated by generateRequestResponse'); 427 | } 428 | 429 | return res.computeResponse; 430 | } 431 | 432 | export type HttpServerOptions = { 433 | }; 434 | 435 | export type RequestListener = 436 | (req: IncomingMessage, res: ServerResponse) => void; 437 | 438 | export type ListenListener = 439 | () => void; 440 | 441 | /** 442 | * This class simplifies the creation of a request event listener that provides 443 | * access to IncomingMessage and ServerResponse in Compute. Its interface 444 | * is inspired by http.Server from Node.js. 445 | */ 446 | export class HttpServer extends EventEmitter { 447 | constructor(_options?: HttpServerOptions) { 448 | // options is currently unused. 449 | super(); 450 | } 451 | 452 | _listening: boolean = false; 453 | listen(port?: number | ListenListener, onListen?: ListenListener) { 454 | if(this._listening) { 455 | throw new Error(`Cannot call 'listen()' more than once on a single HttpServer instance.`); 456 | } 457 | if(typeof port === 'function') { 458 | onListen = port; 459 | port = undefined; 460 | } 461 | if(port != null) { 462 | console.warn('Cannot set port programmatically. The port used is determined by the Compute environment.'); 463 | } 464 | if(onListen != null) { 465 | console.log(`Attaching 'listening' listener, but note that this event runs outside the context of handling a request.`); 466 | this.on('listening', onListen); 467 | } 468 | 469 | const handleRequest = async (event: FetchEvent) => { 470 | // Create Node.js-compatible request and response from event.request 471 | const { req, res } = toReqRes(event.request); 472 | this.emit('request', req, res); 473 | 474 | // Convert the object to Compute-compatible response 475 | return await toComputeResponse(res); 476 | }; 477 | 478 | addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); 479 | } 480 | } 481 | 482 | /** 483 | * Simplifies the creation of the HttpServer instance. Its interface 484 | * is inspired by http.createServer from Node.js. 485 | */ 486 | export function createServer(options?: HttpServerOptions | RequestListener, onRequest?: RequestListener) { 487 | 488 | if(typeof options === 'function') { 489 | onRequest = options as RequestListener; 490 | options = undefined; 491 | } 492 | 493 | const server = new HttpServer(options); 494 | 495 | if(onRequest != null) { 496 | server.on('request', onRequest); 497 | } 498 | 499 | return server; 500 | } 501 | -------------------------------------------------------------------------------- /src/http-compute-js/internal-http.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | * 5 | * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. 6 | */ 7 | 8 | /* These items copied from Node.js: node/lib/internal/http.js. */ 9 | 10 | export const kNeedDrain = Symbol('kNeedDrain'); 11 | export const kOutHeaders = Symbol('kOutHeaders'); 12 | 13 | // In Node.js this utcDate is cached for 1 second, for use across 14 | // all http connections. However, in C@E we just create a new one 15 | // since we're not able to share this data across separate invocations. 16 | export function utcDate() { 17 | return new Date().toUTCString(); 18 | } 19 | -------------------------------------------------------------------------------- /src/http-compute-js/internal-streams-state.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | * 5 | * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. 6 | */ 7 | 8 | /* These items copied from Node.js: node/lib/internal/streams/state.js. */ 9 | 10 | export function getDefaultHighWaterMark(objectMode?: boolean) { 11 | return objectMode ? 16 : 16 * 1024; 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | */ 5 | 6 | /// 7 | 8 | import './polyfills.js'; 9 | 10 | export { ComputeJsIncomingMessage } from './http-compute-js/http-incoming.js'; 11 | export { ComputeJsOutgoingMessage } from './http-compute-js/http-outgoing.js'; 12 | export { 13 | STATUS_CODES, 14 | createServer, 15 | toReqRes, 16 | toComputeResponse, 17 | ComputeJsServerResponse, 18 | HttpServer, 19 | HttpServerOptions, 20 | ReqRes, 21 | ToReqResOptions, 22 | } from './http-compute-js/http-server.js'; 23 | 24 | import { 25 | createServer 26 | } from './http-compute-js/http-server.js'; 27 | export default { 28 | createServer, 29 | }; 30 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer'; 2 | import process from 'process/browser'; 3 | 4 | if (typeof globalThis.Buffer === 'undefined') { 5 | globalThis.Buffer = Buffer; 6 | } 7 | 8 | if (typeof globalThis.process === 'undefined') { 9 | globalThis.process = process; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/process.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'process/browser' { 2 | import nodeProcess from 'node:process'; 3 | export = nodeProcess; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/stream-browserify.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'stream-browserify' { 2 | import { 3 | Readable as NodeReadable, 4 | Writable as NodeWritable, 5 | } from 'node:stream'; 6 | export const Readable: typeof NodeReadable; 7 | export const Writable: typeof NodeWritable; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | * 5 | * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. 6 | */ 7 | 8 | import util from 'node-inspect-extracted'; 9 | 10 | /* These items copied from Node.js: node/lib/internal/errors.js */ 11 | 12 | const classRegExp = /^([A-Z][a-z0-9]*)+$/; 13 | // Sorted by a rough estimate on most frequently used entries. 14 | const kTypes = [ 15 | 'string', 16 | 'function', 17 | 'number', 18 | 'object', 19 | // Accept 'Function' and 'Object' as alternative to the lower cased version. 20 | 'Function', 21 | 'Object', 22 | 'boolean', 23 | 'bigint', 24 | 'symbol', 25 | ]; 26 | 27 | /** 28 | * Determine the specific type of a value for type-mismatch errors. 29 | * @param {*} value 30 | * @returns {string} 31 | */ 32 | function determineSpecificType(value: any) { 33 | if (value == null) { 34 | return '' + value; 35 | } 36 | if (typeof value === 'function' && value.name) { 37 | return `function ${value.name}`; 38 | } 39 | if (typeof value === 'object') { 40 | if (value.constructor?.name) { 41 | return `an instance of ${value.constructor.name}`; 42 | } 43 | return `${util.inspect(value, { depth: -1 })}`; 44 | } 45 | let inspected = util 46 | .inspect(value, { colors: false }); 47 | if (inspected.length > 28) { inspected = `${inspected.slice(0, 25)}...`; } 48 | 49 | return `type ${typeof value} (${inspected})`; 50 | } 51 | 52 | // The following classes are adaptations of a subset of the ERR_* classes 53 | // declared in Node.js in the file - node/lib/internal/errors.js. 54 | 55 | export class ERR_HTTP_HEADERS_SENT extends Error { 56 | constructor(arg: string) { 57 | super(`Cannot ${arg} headers after they are sent to the client`); 58 | } 59 | } 60 | 61 | export class ERR_INVALID_ARG_VALUE extends TypeError /*, RangeError */ { 62 | constructor(name: string, value: any, reason: string = 'is invalid') { 63 | let inspected = util.inspect(value); 64 | if (inspected.length > 128) { 65 | inspected = `${inspected.slice(0, 128)}...`; 66 | } 67 | const type = name.includes('.') ? 'property' : 'argument'; 68 | super(`The ${type} '${name}' ${reason}. Received ${inspected}`); 69 | } 70 | } 71 | 72 | export class ERR_INVALID_CHAR extends TypeError { 73 | constructor(name: string, field?: string) { 74 | let msg = `Invalid character in ${name}`; 75 | if (field !== undefined) { 76 | msg += ` ["${field}"]`; 77 | } 78 | super(msg); 79 | } 80 | } 81 | 82 | export class ERR_HTTP_INVALID_HEADER_VALUE extends TypeError { 83 | constructor(value: string | undefined, name: string) { 84 | super(`Invalid value "${value}" for header "${name}"`); 85 | } 86 | } 87 | 88 | export class ERR_HTTP_INVALID_STATUS_CODE extends RangeError { 89 | constructor(public originalStatusCode: number) { 90 | super(`Invalid status code: ${originalStatusCode}`); 91 | } 92 | } 93 | 94 | export class ERR_HTTP_TRAILER_INVALID extends Error { 95 | constructor() { 96 | super(`Trailers are invalid with this transfer encoding`); 97 | } 98 | } 99 | 100 | export class ERR_INVALID_ARG_TYPE extends TypeError { 101 | constructor(name: string, expected: string | string[], actual: any) { 102 | // assert(typeof name === 'string', "'name' must be a string"); 103 | if (!Array.isArray(expected)) { 104 | expected = [expected]; 105 | } 106 | 107 | let msg = 'The '; 108 | if (name.endsWith(' argument')) { 109 | // For cases like 'first argument' 110 | msg += `${name} `; 111 | } else { 112 | const type = name.includes('.') ? 'property' : 'argument'; 113 | msg += `"${name}" ${type} `; 114 | } 115 | msg += 'must be '; 116 | 117 | const types = []; 118 | const instances = []; 119 | const other = []; 120 | 121 | for (const value of expected) { 122 | // assert(typeof value === 'string', 123 | // 'All expected entries have to be of type string'); 124 | if (kTypes.includes(value)) { 125 | types.push(value.toLowerCase()); 126 | } else if (classRegExp.exec(value) !== null) { 127 | instances.push(value); 128 | } else { 129 | // assert(value !== 'object', 130 | // 'The value "object" should be written as "Object"'); 131 | other.push(value); 132 | } 133 | } 134 | 135 | // Special handle `object` in case other instances are allowed to outline 136 | // the differences between each other. 137 | if (instances.length > 0) { 138 | const pos = types.indexOf('object'); 139 | if (pos !== -1) { 140 | types.splice(pos, 1); 141 | instances.push('Object'); 142 | } 143 | } 144 | 145 | if (types.length > 0) { 146 | if (types.length > 2) { 147 | const last = types.pop(); 148 | msg += `one of type ${types.join(', ')}, or ${last}`; 149 | } else if (types.length === 2) { 150 | msg += `one of type ${types[0]} or ${types[1]}`; 151 | } else { 152 | msg += `of type ${types[0]}`; 153 | } 154 | if (instances.length > 0 || other.length > 0) 155 | msg += ' or '; 156 | } 157 | 158 | if (instances.length > 0) { 159 | if (instances.length > 2) { 160 | const last = instances.pop(); 161 | msg += 162 | `an instance of ${instances.join(', ')}, or ${last}`; 163 | } else { 164 | msg += `an instance of ${instances[0]}`; 165 | if (instances.length === 2) { 166 | msg += ` or ${instances[1]}`; 167 | } 168 | } 169 | if (other.length > 0) 170 | msg += ' or '; 171 | } 172 | 173 | if (other.length > 0) { 174 | if (other.length > 2) { 175 | const last = other.pop(); 176 | msg += `one of ${other.join(', ')}, or ${last}`; 177 | } else if (other.length === 2) { 178 | msg += `one of ${other[0]} or ${other[1]}`; 179 | } else { 180 | if (other[0].toLowerCase() !== other[0]) 181 | msg += 'an '; 182 | msg += `${other[0]}`; 183 | } 184 | } 185 | 186 | msg += `. Received ${determineSpecificType(actual)}`; 187 | 188 | super(msg); 189 | } 190 | } 191 | 192 | export class ERR_INVALID_HTTP_TOKEN extends TypeError { 193 | constructor(name: string, field: string) { 194 | super(`${name} must be a valid HTTP token ["${field}"]`); 195 | } 196 | } 197 | 198 | export class ERR_METHOD_NOT_IMPLEMENTED extends Error { 199 | constructor(methodName: string) { 200 | super(`The ${methodName} method is not implemented`); 201 | } 202 | } 203 | 204 | export class ERR_STREAM_ALREADY_FINISHED extends Error { 205 | constructor(methodName: string) { 206 | super(`Cannot call ${methodName} after a stream was finished`); 207 | } 208 | } 209 | 210 | export class ERR_STREAM_CANNOT_PIPE extends Error { 211 | constructor() { 212 | super(`Cannot pipe, not readable`); 213 | } 214 | } 215 | 216 | export class ERR_STREAM_DESTROYED extends Error { 217 | constructor(methodName: string) { 218 | super(`Cannot call ${methodName} after a stream was destroyed`); 219 | } 220 | } 221 | 222 | export class ERR_STREAM_NULL_VALUES extends TypeError { 223 | constructor() { 224 | super(`May not write null values to stream`); 225 | } 226 | } 227 | 228 | export class ERR_STREAM_WRITE_AFTER_END extends Error { 229 | constructor() { 230 | super(`write after end`); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Fastly, Inc. 3 | * Licensed under the MIT license. See LICENSE file for details. 4 | * 5 | * Portions of this file Copyright Joyent, Inc. and other Node contributors. See LICENSE file for details. 6 | */ 7 | 8 | import { ERR_INVALID_ARG_TYPE } from './errors.js'; 9 | 10 | /* These items copied from Node.js: node/lib/internal/validators.js */ 11 | 12 | export function validateString(value: any, name: string) { 13 | if (typeof value !== 'string') 14 | throw new ERR_INVALID_ARG_TYPE(name, 'string', value); 15 | } 16 | 17 | /* These items copied from Node.js: node/lib/internal/util/types.js */ 18 | 19 | export function isUint8Array(value: any) { 20 | return value != null && value[Symbol.toStringTag] === 'Uint8Array'; 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./src", 4 | "outDir": "./dist", 5 | "target": "es2018", 6 | "lib": [ "es2018" ], 7 | "moduleResolution": "node", 8 | 9 | "allowUnreachableCode": false, 10 | "allowUnusedLabels": false, 11 | "declaration": true, 12 | "declarationMap": true, 13 | "esModuleInterop": true, 14 | "newLine": "LF", 15 | "noEmitOnError": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitOverride": true, 18 | "noImplicitReturns": true, 19 | "noUnusedLocals": true, 20 | "skipLibCheck": true, 21 | "strict": true, 22 | "useUnknownInCatchVariables": false 23 | }, 24 | "include": [ 25 | "src/**/*.ts", 26 | "test/**/*.ts" 27 | ], 28 | "exclude": [ 29 | "node_modules", 30 | ] 31 | } 32 | --------------------------------------------------------------------------------