-
Notifications
You must be signed in to change notification settings - Fork 408
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
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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.
797e44d
to
2965714
Compare
@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> { |
There was a problem hiding this comment.
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.` |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not
fetch: (request: Request) => Promise<Response>; | |
fetch: typeof fetch; |
?
} | ||
|
||
this.#tryRead().catch(() => { | ||
/* Ignore errors */ |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
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>({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
body = new ReadableStream<Uint8Array>({ | |
body = new ReadableStream<Uint8Array>({ | |
type: 'bytes', |
this.on('error', (error) => { | ||
controller.error(error); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this.on('error', (error) => { | |
controller.error(error); | |
}); | |
this.once('error', controller.error.bind(controller)); |
cancel: (reason: unknown): void => { | ||
this.destroy(reason); | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cancel: (reason: unknown): void => { | |
this.destroy(reason); | |
}, | |
cancel: this.destroy.bind(this), |
return encoding == null || encoding === 'utf8' || encoding === 'utf-8' | ||
? this.#encoder.encode(data) | ||
: Buffer.from(data, encoding); |
There was a problem hiding this comment.
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?
statusCode === 204 || | ||
statusCode === 304 || | ||
(statusCode >= 100 && statusCode <= 199) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
statusCode === 204 || | |
statusCode === 304 || | |
(statusCode >= 100 && statusCode <= 199) | |
statusCode === 204 || statusCode === 304 |
this.emit('close'); | ||
queueMicrotask(() => { | ||
callback?.(); | ||
}); |
There was a problem hiding this comment.
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(...)
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$
const { incoming, response } = this.#toReqRes(request); | ||
this.emit('connection', this, incoming); | ||
this.emit('request', incoming, response); | ||
return getServerResponseFetchResponse(response); |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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
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"' | ||
); | ||
} |
There was a problem hiding this comment.
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))) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
new Error( | ||
`Content-Length mismatch: wrote ${state.bytesWritten} bytes but Content-Length header was ${state.contentLength}` |
There was a problem hiding this comment.
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"], |
There was a problem hiding this comment.
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"
There was a problem hiding this comment.
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.
import type { Server as _Server } from 'node:https'; | ||
|
||
export class Server extends HttpServer implements _Server { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 { |
Server as _Server, | ||
ServerResponse as _ServerResponse, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Server as _Server, | |
ServerResponse as _ServerResponse, | |
Server as HttpServer, | |
ServerResponse as HttpServerResponse, |
// Since finalization registry is only available under a specific compat flag, | ||
// let's check if it's enabled to preserve backward compatibility. |
There was a problem hiding this comment.
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?
// 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit
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]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
k = obj[n + 0]; | |
k = obj[n]; |
Implements the remaining modules necessary to add
http.createServer
. Starting from today, the following code works without any issues:Before landing this pull-request the following todos are required: