Skip to content

implement node:http server-side modules #4591

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from

Conversation

anonrig
Copy link
Member

@anonrig anonrig commented Jul 22, 2025

Implements the remaining modules necessary to add http.createServer. Starting from today, the following code works without any issues:

import http from 'node:http';
import { strictEqual, ok } from 'node:assert';
import { nodeCompatHttpServerHandler } from 'cloudflare:workers';

export const testHttpServerMultiHeaders = {
  async test(_ctrl, env) {
    const { promise, resolve } = Promise.withResolvers();
    const server = http.createServer(function (req, res) {
      strictEqual(req.headers.host, 'yagiz');
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('EOF');

      server.close();
    });

    server.listen(8080, async function () {
      const res = await env.SERVICE.fetch('https://cloudflare.com', {
        headers: [['host', 'yagiz']],
      });

      strictEqual(res.status, 200);
      strictEqual(res.headers.get('content-type'), 'text/plain');
      strictEqual(await res.text(), 'EOF');

      resolve();
    });

    await promise;
  },
};

export default nodeCompatHttpServerHandler({ port: 8080 });

Before landing this pull-request the following todos are required:

  • Add remaining http-server tests
  • Add node:https as well

@anonrig anonrig requested review from a team as code owners July 22, 2025 18:26
Copy link
Collaborator

@jasnell jasnell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good first pass. The server API should be gated behind an experimental compat flag that is separate from the one used to enable the http client APIs so we can roll those out separately. We likely won't be able to lift the experimental flag off these until more of the test coverage is filled in.

More importantly, I don't think the handling of the response stream here is adequate. I left comments explaining why, but the way it's structured I think there's far too much internal buffering of the data as opposed to streaming it out as it is written. I think that needs to be addressed before this can land.

jasnell

This comment was marked as duplicate.

@anonrig anonrig force-pushed the yagiz/implement-node-http-serverside branch from 797e44d to 2965714 Compare July 22, 2025 22:55
@anonrig
Copy link
Member Author

anonrig commented Jul 25, 2025

@kentonv I've updated the public API as your recommendation (nodeCompatHttpServerHandler). just wanted to share before we land this next week.

);
}
return {
async fetch(request: Request): Promise<Response> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would include a comment in here about the env and ctx arguments are intentionally omitted here and the expectation, at least for now, is to use importable env and importable waitUntil.

const instance = portMapper.get(port);
if (!instance) {
throw new Error(
`Http server with port ${port} not found. This is likely a bug with your code.`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some indication on how to fix this in the message would be nice. Something about referencing server.listen(...) or a link to docs.

@@ -31,3 +31,6 @@ export const workerdExperimental: boolean;
export const durableObjectGetExisting: boolean;
export const vectorizeQueryMetadataOptional: boolean;
export const nodeJsZlib: boolean;
export const enableNodejsProcessV2: boolean;
export const enableNodejsHttpServerModules: boolean;
export const jsWeakRef: boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw, now that globalThis.Cloudflare exists, this is largely obsolete and can probably be replaced. Not critical for this PR tho.

@@ -0,0 +1,4 @@
export interface Fetcher {
fetch: (request: Request) => Promise<Response>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not

Suggested change
fetch: (request: Request) => Promise<Response>;
fetch: typeof fetch;

?

}

this.#tryRead().catch(() => {
/* Ignore errors */
Copy link
Collaborator

@jasnell jasnell Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why ignore errors? Specifically, from what I can determine tryRead() does not actually need to throw anything.

break;
}
this.push(value);
const data = await this.#reader.read();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd move the getReader() call into the try here. getReader() can throw and that should not be ignored and should lead to this being destroyed.

Suggested change
const data = await this.#reader.read();
this.#reader ??= this._stream.getReader();
const data = await this.#reader.read();

let body = null;

if (this._hasBody) {
body = new ReadableStream<Uint8Array>({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
body = new ReadableStream<Uint8Array>({
body = new ReadableStream<Uint8Array>({
type: 'bytes',

Comment on lines +471 to +473
this.on('error', (error) => {
controller.error(error);
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this.on('error', (error) => {
controller.error(error);
});
this.once('error', controller.error.bind(controller));

Comment on lines +475 to +477
cancel: (reason: unknown): void => {
this.destroy(reason);
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cancel: (reason: unknown): void => {
this.destroy(reason);
},
cancel: this.destroy.bind(this),

Comment on lines +498 to +500
return encoding == null || encoding === 'utf8' || encoding === 'utf-8'
? this.#encoder.encode(data)
: Buffer.from(data, encoding);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just use Buffer.from(data, encoding) for UTF8 also?

Comment on lines +615 to +617
statusCode === 204 ||
statusCode === 304 ||
(statusCode >= 100 && statusCode <= 199)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
statusCode === 204 ||
statusCode === 304 ||
(statusCode >= 100 && statusCode <= 199)
statusCode === 204 || statusCode === 304

Comment on lines +154 to +157
this.emit('close');
queueMicrotask(() => {
callback?.();
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The callback passed into close is registered as a 'close' event handler and the close event should be emitted in the queueMicrotask(...)

Suggested change
this.emit('close');
queueMicrotask(() => {
callback?.();
});
if (typeof callback === 'function') {
this.on('close', callback);
}
queueMicrotask(() => {
this.emit('close');
});

For instance, in Node.js:

jsnell@james-cloudflare-build:~/tmp$ node a
after close
closed 1
closed 2
jsnell@james-cloudflare-build:~/tmp$ cat a.js
const { createServer } = require('node:http');

const server = createServer();

server.on('close', () => console.log('closed 1'));

server.listen(8888);

server.close(() => console.log('closed 2'));

console.log('after close');
jsnell@james-cloudflare-build:~/tmp$ 

Comment on lines +189 to +192
const { incoming, response } = this.#toReqRes(request);
this.emit('connection', this, incoming);
this.emit('request', incoming, response);
return getServerResponseFetchResponse(response);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, if any errors are thrown synchronously here they will bubble up to the regular fetch handler, erroring the request, which is fine if that's what we want. However, I think we should consider wrapping this in a try/catch and erroring the server.

}
incoming._addHeaderLines(headers, headers.length);

// TODO(soon): It would be useful if there was a way to expose request.cf properties.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say it's essential, not just useful. We should figure this out before we flip the switch making node:http available. Can be just as simple as adding a new own property to incoming

Comment on lines +225 to +235
if (typeof options.port === 'number' || typeof options.port === 'string') {
validatePort(options.port, 'options.port');
}

if (!('port' in options)) {
throw new ERR_INVALID_ARG_VALUE(
'options',
options,
'must have the property "port"'
);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validatePort(...) already checks the typeof for the input. This just feels a bit awkward in terms of how these checks are structured.

);
}

if (this.port != null || portMapper.has(Number(options.port))) {
Copy link
Collaborator

@jasnell jasnell Jul 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (this.port != null || portMapper.has(Number(options.port))) {
if (this.port != null || portMapper.has(+options.port)) {

But again, this feels a bit awkward. validatePort(...) returns the port value converted to a number.

Comment on lines +463 to +464
new Error(
`Content-Length mismatch: wrote ${state.bytesWritten} bytes but Content-Length header was ${state.contentLength}`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a node.js-style error with a code?

(name = "worker", esModule = embed "http-server-nodejs-test.js")
],
compatibilityDate = "2025-01-15",
compatibilityFlags = ["nodejs_compat", "experimental", "enable_nodejs_http_modules", "enable_nodejs_http_server_modules"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For here it looks like both "enable_nodejs_http_modules" and "enable_nodejs_http_server_modules" are required to enable the server.

Could you please clarify that in compatibility-date.capnp where there is no mention to that:

  enableNodejsHttpServerModules @103 :Bool
      $compatEnableFlag("enable_nodejs_http_server_modules")
      $compatDisableFlag("disable_nodejs_http_server_modules");
  # Enables Node.js http server related modules such as node:_http_server

What I would like to see documented:

What would happen if "enable_nodejs_http_server_modules" is used without "enable_nodejs_http_modules" - if that works we should document that.

If that doesn't work and both flags have to be enabled, you could change to say that "enable_nodejs_http_modules" is implied by "enable_nodejs_http_server_modules"

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventially both of these would have impliedByAfter annotations such that they would just be on when nodejs_compat is enabled... but yeah, some expanded docs about the relationship of these flags is helpful.

Comment on lines +3 to +5
import type { Server as _Server } from 'node:https';

export class Server extends HttpServer implements _Server {
Copy link
Contributor

@vicb vicb Jul 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import type { Server as _Server } from 'node:https';
export class Server extends HttpServer implements _Server {
import type { Server as HttpsServer } from 'node:https';
export class Server extends HttpServer implements HttpsServer {

Comment on lines +58 to +59
Server as _Server,
ServerResponse as _ServerResponse,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Server as _Server,
ServerResponse as _ServerResponse,
Server as HttpServer,
ServerResponse as HttpServerResponse,

Comment on lines +74 to +75
// Since finalization registry is only available under a specific compat flag,
// let's check if it's enabled to preserve backward compatibility.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nits:

  • "jsWeakRef" is more specific than "a specific"
  • Not really "backward compatibility" but rather have to do with flags?
Suggested change
// Since finalization registry is only available under a specific compat flag,
// let's check if it's enabled to preserve backward compatibility.
// The finalization registry is only available under the `jsWeakRef` flag

if (key === 'host') {
// By default fetch implementation will join "host" header values with a comma.
// But in order to be node.js compatible, we need to select the first if possible.
headers.push(key, value.split(', ').at(0) as string);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
headers.push(key, value.split(', ').at(0) as string);
headers.push(key, value.split(', ', 1).at(0) as string);

// existing conflicts, then use appendHeader.

for (let n = 0; n < obj.length; n += 2) {
k = obj[n + 0];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
k = obj[n + 0];
k = obj[n];

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants