Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cloudflare/internal/sockets.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function connect(

export function internalNewHttpClient(socket: Socket): Promise<ServiceStub>;

// Identity for the current Durable Object's port scope; undefined when not in one.
// Identity for the current Durable Object's port scope; undefined when using isolate scope.
export function getPortScopeKey(): object | undefined;

export type InboundListener = {
Expand Down
6 changes: 6 additions & 0 deletions src/workerd/api/node/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,12 @@ wd_test(
],
)

wd_test(
src = "http-server-nodejs-port-scope-test.wd-test",
args = ["--experimental"],
data = ["http-server-nodejs-port-scope-test.js"],
)

js_binary(
name = "http-agent-nodejs-server",
entry_point = "http-agent-nodejs-server.js",
Expand Down
111 changes: 111 additions & 0 deletions src/workerd/api/node/tests/http-server-nodejs-port-scope-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

import { rejects, strictEqual } from 'node:assert';
import http from 'node:http';
import { handleAsNodeRequest } from 'cloudflare:node';
import { DurableObject } from 'cloudflare:workers';

const SHARED_PORT = 18080;
const DURABLE_PORT = 18081;
const EPHEMERAL_PORT = 18082;

async function requestPort(port, body) {
const response = await handleAsNodeRequest(
{ port },
new Request('https://example.com/', { method: 'POST', body })
);
return response.text();
}

export class PortHost extends DurableObject {
#label;
#server;

async listen(port, label) {
this.#label = label;
this.#server = http.createServer((request, response) => {
let body = '';
request.setEncoding('utf8');
request.on('data', (chunk) => (body += chunk));
request.on('end', () => {
response.write(this.#label);
queueMicrotask(() => response.end(`:${body}`));
});
});
await new Promise((resolve) => this.#server.listen(port, resolve));
}

request(port, body) {
return requestPort(port, body);
}

close() {
this.#server.close();
}
}

export class SharedPortHost extends PortHost {}
export class DurablePortHost extends PortHost {}
export class EphemeralPortHost extends PortHost {}

// Local-development runners evaluate user modules inside a pinned artificial
// actor, then invoke their exports from the stateless worker. The marked actor
// therefore registers its Node server in the isolate table.
export const testConfiguredRunnerActorUsesIsolatePortScope = {
async test(_controller, env) {
const host = env.SHARED.get('singleton');
const other = env.SHARED.get('other');
await host.listen(SHARED_PORT, 'shared');
await other.listen(SHARED_PORT, 'other');

strictEqual(
await requestPort(SHARED_PORT, 'stateless'),
'shared:stateless'
);
strictEqual(
await host.request(SHARED_PORT, 'singleton'),
'shared:singleton'
);
strictEqual(await other.request(SHARED_PORT, 'actor'), 'other:actor');
await host.close();
await other.close();
},
};

export const testDurableObjectPortScopesRemainIsolated = {
async test(_controller, env) {
const a = env.DURABLE.get(env.DURABLE.idFromName('a'));
const b = env.DURABLE.get(env.DURABLE.idFromName('b'));
await a.listen(DURABLE_PORT, 'a');
await b.listen(DURABLE_PORT, 'b');

strictEqual(await a.request(DURABLE_PORT, 'request'), 'a:request');
strictEqual(await b.request(DURABLE_PORT, 'request'), 'b:request');
await rejects(requestPort(DURABLE_PORT, 'request'), {
message: /^Http server with port 18081 not found/,
});

await a.close();
await b.close();
},
};

export const testEphemeralObjectPortScopesRemainIsolated = {
async test(_controller, env) {
const a = env.EPHEMERAL.get('a');
const b = env.EPHEMERAL.get('b');
await a.listen(EPHEMERAL_PORT, 'a');
await b.listen(EPHEMERAL_PORT, 'b');

strictEqual(await a.request(EPHEMERAL_PORT, 'request'), 'a:request');
strictEqual(await b.request(EPHEMERAL_PORT, 'request'), 'b:request');
await rejects(requestPort(EPHEMERAL_PORT, 'request'), {
message: /^Http server with port 18082 not found/,
});

await a.close();
await b.close();
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Workerd = import "/workerd/workerd.capnp";

const unitTests :Workerd.Config = (
services = [
( name = "http-server-nodejs-port-scope-test",
worker = (
modules = [
(name = "worker", esModule = embed "http-server-nodejs-port-scope-test.js")
],
compatibilityFlags = ["nodejs_compat", "nodejs_compat_v2", "experimental", "enable_nodejs_http_modules", "enable_nodejs_http_server_modules", "streams_enable_constructors"],
durableObjectNamespaces = [
( className = "SharedPortHost",
ephemeralLocal = void,
preventEviction = true,
unsafeUseIsolateNodePortScopeForActor = "singleton",
),
( className = "DurablePortHost",
uniqueKey = "b197f19f90d14da094a89cc26ea2400f",
),
( className = "EphemeralPortHost",
ephemeralLocal = void,
preventEviction = true,
),
],
durableObjectStorage = (inMemory = void),
bindings = [
(name = "SHARED", durableObjectNamespace = "SharedPortHost"),
(name = "DURABLE", durableObjectNamespace = "DurablePortHost"),
(name = "EPHEMERAL", durableObjectNamespace = "EphemeralPortHost"),
],
)
),
],
);
11 changes: 7 additions & 4 deletions src/workerd/api/sockets.c++
Original file line number Diff line number Diff line change
Expand Up @@ -1059,11 +1059,14 @@ jsg::Optional<kj::StringPtr> SocketsModule::getCallerDnsOverride(
}

jsg::Optional<jsg::JsObject> SocketsModule::getPortScopeKey(jsg::Lock& js) {
// A Durable Object instance is its own host for port binding; a stateless worker's host is
// the isolate, since a server it listens on must be reachable from every request.
// A Durable Object instance is normally its own host for port binding; a stateless worker's
// host is the isolate, since a server it listens on must be reachable from every request. A
// pinned internal actor may explicitly act as an artificial context for the isolate instead.
KJ_IF_SOME(ioContext, IoContext::tryCurrent()) {
if (ioContext.getActor() != kj::none) {
return ioContext.getPortScopeKey(js);
KJ_IF_SOME(actor, ioContext.getActor()) {
if (!actor.getUseIsolateNodePortScope()) {
return ioContext.getPortScopeKey(js);
}
}
}
return kj::none;
Expand Down
2 changes: 1 addition & 1 deletion src/workerd/api/sockets.h
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ class SocketsModule final: public jsg::Object {
// Returns the synthetic IP registered for a magic hostname, or undefined. Used by node:dns.
jsg::Optional<kj::StringPtr> getCallerDnsOverride(jsg::Lock& js, kj::String hostname);

// Identity for the current Durable Object's port scope, or undefined when not in one.
// Identity for the current Durable Object's port scope, or undefined when using isolate scope.
jsg::Optional<jsg::JsObject> getPortScopeKey(jsg::Lock& js);

struct InboundListener {
Expand Down
6 changes: 4 additions & 2 deletions src/workerd/io/worker.c++
Original file line number Diff line number Diff line change
Expand Up @@ -3920,9 +3920,11 @@ Worker::Actor::Actor(const Worker& worker,
jsg::Dict<kj::String> containerImages,
kj::Maybe<FacetManager&> facetManager,
kj::Maybe<ActorVersion> version,
kj::Maybe<uint64_t> holderToken)
kj::Maybe<uint64_t> holderToken,
UseIsolateNodePortScope useIsolateNodePortScope)
: worker(kj::atomicAddRef(worker)),
tracker(tracker.map([](RequestTracker& tracker) { return tracker.addRef(); })) {
tracker(tracker.map([](RequestTracker& tracker) { return tracker.addRef(); })),
useIsolateNodePortScope(useIsolateNodePortScope) {
impl = kj::heap<Impl>(*this, kj::mv(actorId), hasTransient, kj::mv(makeActorCache), kj::mv(props),
kj::mv(makeStorage), kj::mv(loopback), timerChannel, kj::mv(metrics), kj::mv(manager),
hibernationEventType, kj::mv(container), kj::mv(containerImages), facetManager);
Expand Down
11 changes: 10 additions & 1 deletion src/workerd/io/worker.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ namespace workerd {

WD_STRONG_BOOL(StructuredLogging);
WD_STRONG_BOOL(ProcessStdioPrefixed);
// Selects the isolate-level virtual Node.js port table for a pinned internal actor.
WD_STRONG_BOOL(UseIsolateNodePortScope);

namespace api {
class DurableObjectState;
Expand Down Expand Up @@ -990,7 +992,8 @@ class Worker::Actor final: public kj::Refcounted {
jsg::Dict<kj::String> containerImages = jsg::Dict<kj::String>{},
kj::Maybe<FacetManager&> facetManager = kj::none,
kj::Maybe<ActorVersion> version = kj::none,
kj::Maybe<uint64_t> holderToken = kj::none);
kj::Maybe<uint64_t> holderToken = kj::none,
UseIsolateNodePortScope useIsolateNodePortScope = UseIsolateNodePortScope::NO);

~Actor() noexcept(false);

Expand Down Expand Up @@ -1098,6 +1101,11 @@ class Worker::Actor final: public kj::Refcounted {
return *worker;
}

// Whether this actor acts as an artificial execution context for its isolate's Node servers.
UseIsolateNodePortScope getUseIsolateNodePortScope() const {
return useIsolateNodePortScope;
}

void assertCanSetAlarm();

// If there is a scheduled or running alarm with the given `scheduledTime`, return a promise to
Expand Down Expand Up @@ -1128,6 +1136,7 @@ class Worker::Actor final: public kj::Refcounted {

kj::Own<const Worker> worker;
kj::Maybe<kj::Own<RequestTracker>> tracker;
UseIsolateNodePortScope useIsolateNodePortScope;
struct Impl;
kj::Own<Impl> impl;

Expand Down
82 changes: 82 additions & 0 deletions src/workerd/server/server-test.c++
Original file line number Diff line number Diff line change
Expand Up @@ -2391,6 +2391,88 @@ KJ_TEST("Server: configuring a DO namespace with no class export is not an error
Internal Server Error)"_blockquote);
}

KJ_TEST("Server: isolate Node port scope requires a pinned actor") {
TestServer test(singleWorker(R"((
compatibilityDate = "2026-09-14",
modules = [
( name = "main.js",
esModule =
`export default { fetch() { return new Response("OK"); } };
`export class MyActorClass { fetch() { return new Response("OK"); } }
)
],
durableObjectNamespaces = [
( className = "MyActorClass",
ephemeralLocal = void,
unsafeUseIsolateNodePortScopeForActor = "singleton",
)
],
durableObjectStorage = (inMemory = void),
))"_kj));

test.server.allowExperimental();
test.expectErrors(R"(
Durable Object namespace for class "MyActorClass" in service "hello" sets unsafeUseIsolateNodePortScopeForActor without preventEviction. The actor sharing the isolate's Node.js port scope must not be evictable.
)"_blockquote);
}

KJ_TEST("Server: isolate Node port scope requires an ephemeral-local actor") {
TestServer test(singleWorker(R"((
compatibilityDate = "2026-09-14",
modules = [
( name = "main.js",
esModule =
`export default { fetch() { return new Response("OK"); } };
`export class MyActorClass { fetch() { return new Response("OK"); } }
)
],
durableObjectNamespaces = [
( className = "MyActorClass",
uniqueKey = "mykey",
preventEviction = true,
unsafeUseIsolateNodePortScopeForActor = "singleton",
)
],
durableObjectStorage = (inMemory = void),
))"_kj));

test.expectErrors(R"(
Durable Object namespace for class "MyActorClass" in service "hello" sets unsafeUseIsolateNodePortScopeForActor without ephemeralLocal. Only an ephemeral-local actor can share the isolate's Node.js port scope.
)"_blockquote);
}

KJ_TEST("Server: isolate Node port scope allows one actor per worker") {
TestServer test(singleWorker(R"((
compatibilityDate = "2026-09-14",
modules = [
( name = "main.js",
esModule =
`export default { fetch() { return new Response("OK"); } };
`export class FirstActorClass { fetch() { return new Response("OK"); } }
`export class SecondActorClass { fetch() { return new Response("OK"); } }
)
],
durableObjectNamespaces = [
( className = "FirstActorClass",
ephemeralLocal = void,
preventEviction = true,
unsafeUseIsolateNodePortScopeForActor = "first",
),
( className = "SecondActorClass",
ephemeralLocal = void,
preventEviction = true,
unsafeUseIsolateNodePortScopeForActor = "second",
)
],
durableObjectStorage = (inMemory = void),
))"_kj));

test.server.allowExperimental();
test.expectErrors(R"(
Durable Object namespace for class "SecondActorClass" in service "hello" sets unsafeUseIsolateNodePortScopeForActor, but the namespace for class "FirstActorClass" already sets it. At most one actor per worker can share the isolate's Node.js port scope.
)"_blockquote);
}

KJ_TEST("Server: call queue handler on service binding") {
TestServer test(R"((
services = [
Expand Down
Loading
Loading