diff --git a/src/cloudflare/internal/sockets.d.ts b/src/cloudflare/internal/sockets.d.ts index 747b191d26c..955e3bb07fe 100644 --- a/src/cloudflare/internal/sockets.d.ts +++ b/src/cloudflare/internal/sockets.d.ts @@ -35,7 +35,7 @@ export function connect( export function internalNewHttpClient(socket: Socket): Promise; -// 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 = { diff --git a/src/workerd/api/node/tests/BUILD.bazel b/src/workerd/api/node/tests/BUILD.bazel index 9aa00440488..2f8eb1d6264 100644 --- a/src/workerd/api/node/tests/BUILD.bazel +++ b/src/workerd/api/node/tests/BUILD.bazel @@ -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", diff --git a/src/workerd/api/node/tests/http-server-nodejs-port-scope-test.js b/src/workerd/api/node/tests/http-server-nodejs-port-scope-test.js new file mode 100644 index 00000000000..cefe799b9c5 --- /dev/null +++ b/src/workerd/api/node/tests/http-server-nodejs-port-scope-test.js @@ -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(); + }, +}; diff --git a/src/workerd/api/node/tests/http-server-nodejs-port-scope-test.wd-test b/src/workerd/api/node/tests/http-server-nodejs-port-scope-test.wd-test new file mode 100644 index 00000000000..06ba5b13512 --- /dev/null +++ b/src/workerd/api/node/tests/http-server-nodejs-port-scope-test.wd-test @@ -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"), + ], + ) + ), + ], +); diff --git a/src/workerd/api/sockets.c++ b/src/workerd/api/sockets.c++ index ca56557a319..f9234fe4733 100644 --- a/src/workerd/api/sockets.c++ +++ b/src/workerd/api/sockets.c++ @@ -1059,11 +1059,14 @@ jsg::Optional SocketsModule::getCallerDnsOverride( } jsg::Optional 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; diff --git a/src/workerd/api/sockets.h b/src/workerd/api/sockets.h index 17e68a45e1e..54a27d933f3 100644 --- a/src/workerd/api/sockets.h +++ b/src/workerd/api/sockets.h @@ -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 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 getPortScopeKey(jsg::Lock& js); struct InboundListener { diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index 6a51a410899..b471de55b0c 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -3920,9 +3920,11 @@ Worker::Actor::Actor(const Worker& worker, jsg::Dict containerImages, kj::Maybe facetManager, kj::Maybe version, - kj::Maybe holderToken) + kj::Maybe 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(*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); diff --git a/src/workerd/io/worker.h b/src/workerd/io/worker.h index c57a9f4d0fe..908bd19e984 100644 --- a/src/workerd/io/worker.h +++ b/src/workerd/io/worker.h @@ -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; @@ -990,7 +992,8 @@ class Worker::Actor final: public kj::Refcounted { jsg::Dict containerImages = jsg::Dict{}, kj::Maybe facetManager = kj::none, kj::Maybe version = kj::none, - kj::Maybe holderToken = kj::none); + kj::Maybe holderToken = kj::none, + UseIsolateNodePortScope useIsolateNodePortScope = UseIsolateNodePortScope::NO); ~Actor() noexcept(false); @@ -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 @@ -1128,6 +1136,7 @@ class Worker::Actor final: public kj::Refcounted { kj::Own worker; kj::Maybe> tracker; + UseIsolateNodePortScope useIsolateNodePortScope; struct Impl; kj::Own impl; diff --git a/src/workerd/server/server-test.c++ b/src/workerd/server/server-test.c++ index 2be79f35d54..4b2a6aabd36 100644 --- a/src/workerd/server/server-test.c++ +++ b/src/workerd/server/server-test.c++ @@ -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 = [ diff --git a/src/workerd/server/server.c++ b/src/workerd/server/server.c++ index 97afc457268..c4e84a945b3 100644 --- a/src/workerd/server/server.c++ +++ b/src/workerd/server/server.c++ @@ -319,7 +319,8 @@ class Server::ActorClass: public IoChannelFactory::ActorClassChannel { kj::Maybe> manager, kj::Maybe container, jsg::Dict containerImages, - kj::Maybe facetManager) = 0; + kj::Maybe facetManager, + UseIsolateNodePortScope useIsolateNodePortScope) = 0; // Start a request on the actor. (The actor must have been created using newActor().) virtual kj::Own startRequest( @@ -439,6 +440,18 @@ class Server::ActorNamespace final { return result; } + UseIsolateNodePortScope getUseIsolateNodePortScope(const Worker::Actor::Id& id) const { + KJ_IF_SOME(ephemeral, config.tryGet()) { + KJ_IF_SOME(configuredId, ephemeral.isolateNodePortScopeActorId) { + KJ_IF_SOME(actorId, id.tryGet()) { + return UseIsolateNodePortScope(actorId == configuredId); + } + KJ_FAIL_ASSERT("ephemeral-local actor has a durable actor ID"); + } + } + return UseIsolateNodePortScope::NO; + } + kj::Own getActorChannel( Worker::Actor::Id id, Persistent persistent = Persistent::NO) { KJ_IF_SOME(doId, id.tryGet>()) { @@ -1371,9 +1384,10 @@ class Server::ActorNamespace final { kj::mv(privileges)); } + auto useIsolateNodePortScope = ns.getUseIsolateNodePortScope(id); auto actor = actorClass->newActor(getTracker(), Worker::Actor::cloneId(id), kj::mv(makeActorCache), kj::mv(makeStorage), kj::mv(loopback), tryGetManagerRef(), - kj::mv(container), kj::mv(containerImages), *this); + kj::mv(container), kj::mv(containerImages), *this, useIsolateNodePortScope); onBrokenTask = monitorOnBroken(*actor); this->actor = kj::mv(actor); } @@ -2032,7 +2046,8 @@ class Server::InvalidConfigActorClass final: public ActorClass { kj::Maybe> manager, kj::Maybe container, jsg::Dict containerImages, - kj::Maybe facetManager) override { + kj::Maybe facetManager, + UseIsolateNodePortScope useIsolateNodePortScope) override { JSG_FAIL_REQUIRE( Error, "Cannot instantiate Durable Object class because its config is invalid."); } @@ -4133,7 +4148,8 @@ class Server::WorkerService final: public Service, kj::Maybe> manager, kj::Maybe container, jsg::Dict containerImages, - kj::Maybe facetManager) override { + kj::Maybe facetManager, + UseIsolateNodePortScope useIsolateNodePortScope) override { TimerChannel& timerChannel = *service; // We define this event ID in the internal codebase, but to have WebSocket Hibernation @@ -4150,7 +4166,8 @@ class Server::WorkerService final: public Service, return kj::refcounted(*service->worker, tracker, kj::mv(actorId), true, kj::mv(makeActorCache), className, kj::mv(props), kj::mv(makeStorage), kj::mv(loopback), timerChannel, kj::refcounted(), kj::mv(manager), hibernationEventTypeId, - kj::mv(container), kj::mv(containerImages), facetManager); + kj::mv(container), kj::mv(containerImages), facetManager, kj::none, kj::none, + useIsolateNodePortScope); } kj::Own startRequest( @@ -5497,10 +5514,11 @@ class Server::WorkerLoaderNamespace: public kj::Refcounted, private kj::TaskSet: kj::Maybe> manager, kj::Maybe container, jsg::Dict containerImages, - kj::Maybe facetManager) override { + kj::Maybe facetManager, + UseIsolateNodePortScope useIsolateNodePortScope) override { return getInner().newActor(tracker, kj::mv(actorId), kj::mv(makeActorCache), kj::mv(makeStorage), kj::mv(loopback), kj::mv(manager), kj::mv(container), - kj::mv(containerImages), facetManager); + kj::mv(containerImages), facetManager, useIsolateNodePortScope); } kj::Own startRequest( @@ -6999,7 +7017,34 @@ kj::Promise Server::startServices(jsg::V8System& v8System, if (serviceConf.isWorker()) { auto workerConf = serviceConf.getWorker(); bool hadDurable = false; + kj::Maybe isolateNodePortScopeNamespace; for (auto ns: workerConf.getDurableObjectNamespaces()) { + auto hasIsolateNodePortScopeActor = ns.hasUnsafeUseIsolateNodePortScopeForActor(); + if (hasIsolateNodePortScopeActor && !ns.getPreventEviction()) { + reportConfigError(kj::str("Durable Object namespace for class \"", ns.getClassName(), + "\" in service \"", name, + "\" sets unsafeUseIsolateNodePortScopeForActor without preventEviction. " + "The actor sharing the isolate's Node.js port scope must not be evictable.")); + } + if (hasIsolateNodePortScopeActor && + ns.which() != config::Worker::DurableObjectNamespace::EPHEMERAL_LOCAL) { + reportConfigError(kj::str("Durable Object namespace for class \"", ns.getClassName(), + "\" in service \"", name, + "\" sets unsafeUseIsolateNodePortScopeForActor without ephemeralLocal. " + "Only an ephemeral-local actor can share the isolate's Node.js port scope.")); + } + if (hasIsolateNodePortScopeActor) { + KJ_IF_SOME(existingNamespace, isolateNodePortScopeNamespace) { + reportConfigError(kj::str("Durable Object namespace for class \"", ns.getClassName(), + "\" in service \"", name, + "\" sets unsafeUseIsolateNodePortScopeForActor, but the namespace for class \"", + existingNamespace, + "\" already sets it. At most one actor per worker can share the isolate's " + "Node.js port scope.")); + } else { + isolateNodePortScopeNamespace = kj::str(ns.getClassName()); + } + } switch (ns.which()) { case config::Worker::DurableObjectNamespace::UNIQUE_KEY: hadDurable = true; @@ -7017,7 +7062,11 @@ kj::Promise Server::startServices(jsg::V8System& v8System, "workerd with `--experimental` to use this feature.")); } serviceActorConfigs.insert(kj::str(ns.getClassName()), - Ephemeral{.isEvictable = !ns.getPreventEviction(), .enableSql = ns.getEnableSql()}); + Ephemeral{.isEvictable = !ns.getPreventEviction(), + .enableSql = ns.getEnableSql(), + .isolateNodePortScopeActorId = hasIsolateNodePortScopeActor + ? kj::Maybe(kj::str(ns.getUnsafeUseIsolateNodePortScopeForActor())) + : kj::none}); continue; } reportConfigError(kj::str("Encountered unknown DurableObjectNamespace type in service \"", diff --git a/src/workerd/server/server.h b/src/workerd/server/server.h index 382ac303266..337e3041d9e 100644 --- a/src/workerd/server/server.h +++ b/src/workerd/server/server.h @@ -122,6 +122,7 @@ class Server final: private kj::TaskSet::ErrorHandler, private ChannelTokenHandl struct Ephemeral { bool isEvictable; bool enableSql; + kj::Maybe isolateNodePortScopeActorId; }; using ActorConfig = kj::OneOf; diff --git a/src/workerd/server/workerd.capnp b/src/workerd/server/workerd.capnp index 310b60c3165..86eb4a349e4 100644 --- a/src/workerd/server/workerd.capnp +++ b/src/workerd/server/workerd.capnp @@ -664,6 +664,16 @@ struct Worker { # Durable Object based on the given image. The Durable Object can access the container via the # ctx.container API. TODO(CloudChamber): add link to docs. + unsafeUseIsolateNodePortScopeForActor @6 :Text; + # Makes Node.js HTTP and TCP servers created by the ephemeral-local actor with this ID use the + # worker isolate's virtual port table instead of a table scoped to that actor instance. Other + # IDs in the namespace retain their actor-scoped port tables. + # + # This is a workerd-only escape hatch for the pinned singleton actor that local-development + # tooling uses as an artificial module-evaluation context. At most one namespace in a worker + # may set this option, and `preventEviction` must also be true so that handlers stored in the + # isolate table cannot outlive the actor instance that created them. + struct ContainerOptions { imageName @0 :Text; # Image name to be used to create the container using supported provider.