├── .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 |
--------------------------------------------------------------------------------