From 87e1983cc74ea27c83236a553800d3224c524574 Mon Sep 17 00:00:00 2001 From: Sophie Wallace Date: Wed, 4 Feb 2026 14:04:18 +0000 Subject: [PATCH] Loopback service stubs accept a dynamic version (cohort) parameter Similar to `props`, this allows a worker to call the service stub as a function, passing along the given version information (currently only a cohort string) to the runtime. workerd's server implementation currently ignores this information, so the test included in this commit is very minimal. We could come up with a better test once we've decided how this information will be exposed to the worker, assuming it also makes sense in the workerd runtime. The internal codebase tests this feature in more depth. The feature is behind the experimental `enable_version_api` flag. Usage looks like: `let service = ctx.exports.default({ version: { cohort: "blah" } });` --- src/workerd/api/export-loopback.c++ | 15 +++- src/workerd/api/export-loopback.h | 68 +++++++++++++++---- src/workerd/io/compatibility-date.capnp | 6 ++ src/workerd/io/io-channels.h | 21 ++++-- src/workerd/server/server-test.c++ | 42 ++++++++++++ src/workerd/server/server.c++ | 5 +- src/workerd/tests/test-fixture.c++ | 5 +- .../experimental/index.d.ts | 14 +++- .../generated-snapshot/experimental/index.ts | 14 +++- 9 files changed, 163 insertions(+), 27 deletions(-) diff --git a/src/workerd/api/export-loopback.c++ b/src/workerd/api/export-loopback.c++ index b9002b8d43c..924d3c99d8b 100644 --- a/src/workerd/api/export-loopback.c++ +++ b/src/workerd/api/export-loopback.c++ @@ -8,14 +8,23 @@ namespace workerd::api { -jsg::Ref LoopbackServiceStub::call(jsg::Lock& js, Options options) { +jsg::Ref LoopbackServiceStub::callImpl(jsg::Lock& js, + jsg::Optional> propsMaybe, + jsg::Optional versionMaybe) { Frankenvalue props; - KJ_IF_SOME(p, options.props) { + KJ_IF_SOME(p, propsMaybe) { props = Frankenvalue::fromJs(js, p.getHandle(js)); } + kj::Maybe versionRequest; + KJ_IF_SOME(version, kj::mv(versionMaybe)) { + versionRequest = IoChannelFactory::VersionRequest{ + .cohort = kj::mv(version.cohort).orDefault(kj::none), + }; + } IoContext& ioctx = IoContext::current(); - auto channelObj = ioctx.getIoChannelFactory().getSubrequestChannel(channel, kj::mv(props)); + auto channelObj = ioctx.getIoChannelFactory().getSubrequestChannel( + channel, kj::mv(props), kj::mv(versionRequest)); return js.alloc(ioctx.addObject(kj::mv(channelObj))); } diff --git a/src/workerd/api/export-loopback.h b/src/workerd/api/export-loopback.h index d834cacc527..2cb2742866a 100644 --- a/src/workerd/api/export-loopback.h +++ b/src/workerd/api/export-loopback.h @@ -7,6 +7,8 @@ #include "actor.h" #include "http.h" +#include + namespace workerd::api { // LoopbackServiceStub is the type of a property of `ctx.exports` which points back at a stateless @@ -26,26 +28,66 @@ class LoopbackServiceStub: public Fetcher { JSG_STRUCT(props); }; + struct OptionsWithVersion { + struct Version { + jsg::Optional> cohort; + + JSG_STRUCT(cohort); + }; + + jsg::Optional> props; + jsg::Optional version; + + JSG_STRUCT(props, version); + }; + // Create a specialized Fetcher which can be passed over RPC. - jsg::Ref call(jsg::Lock& js, Options options); + jsg::Ref callImpl(jsg::Lock& js, + jsg::Optional> propsMaybe, + jsg::Optional versionMaybe); + + jsg::Ref call(jsg::Lock& js, Options options) { + return callImpl(js, kj::mv(options.props), kj::none); + } + + jsg::Ref callWithVersion(jsg::Lock& js, OptionsWithVersion options) { + return callImpl(js, kj::mv(options.props), kj::mv(options.version)); + } // Note that `LoopbackServiceStub` is intentionally NOT serializable, unlike its parent class // Fetcher. We want people to explicitly specialize the entrypoint with props before sending // it off to other services. - JSG_RESOURCE_TYPE(LoopbackServiceStub) { + JSG_RESOURCE_TYPE(LoopbackServiceStub, CompatibilityFlags::Reader flags) { JSG_INHERIT(Fetcher); - JSG_CALLABLE(call); + + if (flags.getEnableVersionApi()) { + JSG_CALLABLE(callWithVersion); + } else { + JSG_CALLABLE(call); + } JSG_TS_ROOT(); - JSG_TS_OVERRIDE( - type LoopbackServiceStub< - T extends Rpc.WorkerEntrypointBranded | undefined = undefined - > = Fetcher & - ( T extends CloudflareWorkersModule.WorkerEntrypoint - ? (opts: {props?: Props}) => Fetcher - : (opts: {props?: any}) => Fetcher); - ); + + if (flags.getEnableVersionApi()) { + JSG_TS_OVERRIDE( + type LoopbackServiceStub< + T extends Rpc.WorkerEntrypointBranded | undefined = undefined + > = Fetcher & + ( T extends CloudflareWorkersModule.WorkerEntrypoint + ? (opts: {props?: Props, version?: { cohort?: string | null }}) => Fetcher + : (opts: {props?: any, version?: { cohort?: string | null }}) => Fetcher); + ); + } else { + JSG_TS_OVERRIDE( + type LoopbackServiceStub< + T extends Rpc.WorkerEntrypointBranded | undefined = undefined + > = Fetcher & + ( T extends CloudflareWorkersModule.WorkerEntrypoint + ? (opts: {props?: Props}) => Fetcher + : (opts: {props?: any}) => Fetcher); + ); + } // LoopbackForExport takes the type of an exported value and evaluates to the appropriate // loopback stub for that export. @@ -170,7 +212,9 @@ class LoopbackColoLocalActorNamespace: public ColoLocalActorNamespace { }; #define EW_EXPORT_LOOPBACK_ISOLATE_TYPES \ - api::LoopbackServiceStub, api::LoopbackServiceStub::Options, api::LoopbackDurableObjectClass, \ + api::LoopbackServiceStub, api::LoopbackServiceStub::Options, \ + api::LoopbackServiceStub::OptionsWithVersion, \ + api::LoopbackServiceStub::OptionsWithVersion::Version, api::LoopbackDurableObjectClass, \ api::LoopbackDurableObjectClass::Options, api::LoopbackDurableObjectNamespace, \ api::LoopbackColoLocalActorNamespace diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index ef8fb342383..22ae9c9d1a1 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1420,4 +1420,10 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # reason string exceeds 123 bytes when UTF-8 encoded, as required by the # WHATWG WebSocket spec and RFC 6455 Section 5.5. Previously, workerd allowed # arbitrarily long close reasons without validation. + + enableVersionApi @165 :Bool + $compatEnableFlag("enable_version_api") + $experimental; + # Enables version-related APIs. This currently only enables the `version` option in loopback + # bindings to specify a requested version. The behaviour of this flag will change in the future. } diff --git a/src/workerd/io/io-channels.h b/src/workerd/io/io-channels.h index cd0b47b7942..d19a02d4f6e 100644 --- a/src/workerd/io/io-channels.h +++ b/src/workerd/io/io-channels.h @@ -116,6 +116,18 @@ class IoChannelFactory { double startTime = dateNow(); }; + // Parameters that can influence the version of a worker that is used to serve a subrequest. + struct VersionRequest { + // Request a version within the given cohort. + kj::Maybe cohort; + + VersionRequest clone() const { + return { + .cohort = cohort.map([](const kj::String& s) { return kj::str(s); }), + }; + } + }; + virtual kj::Own startSubrequest(uint channel, SubrequestMetadata metadata) = 0; // Get a Cap'n Proto RPC capability. Various binding types are backed by capabilities. @@ -194,12 +206,13 @@ class IoChannelFactory { // The reason to use this instead is when the channel is not necessarily going to be used to // start a subrequest immediately, but instead is going to be passed around as a capability. // - // `props` can only be specified if this is a loopback channel (i.e. from ctx.exports). For any - // other channel, it will throw. + // `props` and `versionRequest` can only be specified if this is a loopback channel (i.e. from + // ctx.exports). For any other channel, they will throw. // // TODO(cleanup): Consider getting rid of `startSubrequest()` in favor of this. - virtual kj::Own getSubrequestChannel( - uint channel, kj::Maybe props = kj::none) = 0; + virtual kj::Own getSubrequestChannel(uint channel, + kj::Maybe props = kj::none, + kj::Maybe versionRequest = kj::none) = 0; // Stub for a remote actor. Allows sending requests to the actor. class ActorChannel: public SubrequestChannel { diff --git a/src/workerd/server/server-test.c++ b/src/workerd/server/server-test.c++ index a125ebf9dbf..27910ff8e55 100644 --- a/src/workerd/server/server-test.c++ +++ b/src/workerd/server/server-test.c++ @@ -4450,6 +4450,48 @@ KJ_TEST("Server: ctx.exports self-referential bindings") { "{}, {\"foo\":123,\"bar\":\"abc\"}, false"); } +KJ_TEST("Server: loopback binding calls accept version property") { + TestServer test(R"(( + services = [ + ( name = "hello", + worker = ( + compatibilityDate = "2025-08-01", + compatibilityFlags = ["enable_ctx_exports", "enable_version_api"], + modules = [ + ( name = "main.js", + esModule = + `export default { + ` async fetch(request, env, ctx) { + ` const serviceVersions = await Promise.all([ + ` ctx.exports.default({ version: {} }), + ` ctx.exports.default({ version: { cohort: null } }), + ` ctx.exports.default({ version: { cohort: "test" } }), + ` ctx.exports.default({ props: {}, version: { cohort: "test" } }), + ` ].map(service => service.version)); + ` if (serviceVersions.every(version => version === this.version)) { + ` return new Response(serviceVersions[0]); + ` } + ` return new Response(null, { status: 500 }); + ` }, + ` get version() { return "constant"; }, + `} + ) + ], + ) + ), + ], + sockets = [ + ( name = "main", address = "test-addr", service = "hello" ), + ] + ))"_kj); + + test.server.allowExperimental(); + test.start(); + + auto conn = test.connect("test-addr"); + conn.httpGet200("/", "constant"); +} + // ======================================================================================= // TODO(beta): Test TLS (send and receive) diff --git a/src/workerd/server/server.c++ b/src/workerd/server/server.c++ index e609be6c6ac..b9753ad0615 100644 --- a/src/workerd/server/server.c++ +++ b/src/workerd/server/server.c++ @@ -3291,8 +3291,9 @@ class Server::WorkerService final: public Service, co_return; } - kj::Own getSubrequestChannel( - uint channel, kj::Maybe props) override { + kj::Own getSubrequestChannel(uint channel, + kj::Maybe props, + kj::Maybe versionRequest) override { auto& channels = KJ_REQUIRE_NONNULL(ioChannels.tryGet(), "link() has not been called"); diff --git a/src/workerd/tests/test-fixture.c++ b/src/workerd/tests/test-fixture.c++ index 2d0a72263f3..9466aefff87 100644 --- a/src/workerd/tests/test-fixture.c++ +++ b/src/workerd/tests/test-fixture.c++ @@ -104,8 +104,9 @@ struct DummyIoChannelFactory final: public IoChannelFactory { KJ_FAIL_ASSERT("no subrequests"); } - kj::Own getSubrequestChannel( - uint channel, kj::Maybe props) override { + kj::Own getSubrequestChannel(uint channel, + kj::Maybe props, + kj::Maybe versionRequest) override { KJ_FAIL_ASSERT("no subrequests"); } diff --git a/types/generated-snapshot/experimental/index.d.ts b/types/generated-snapshot/experimental/index.d.ts index 6877e86da0e..8c0146db2be 100755 --- a/types/generated-snapshot/experimental/index.d.ts +++ b/types/generated-snapshot/experimental/index.d.ts @@ -4099,8 +4099,18 @@ type LoopbackServiceStub< T extends Rpc.WorkerEntrypointBranded | undefined = undefined, > = Fetcher & (T extends CloudflareWorkersModule.WorkerEntrypoint - ? (opts: { props?: Props }) => Fetcher - : (opts: { props?: any }) => Fetcher); + ? (opts: { + props?: Props; + version?: { + cohort?: string | null; + }; + }) => Fetcher + : (opts: { + props?: any; + version?: { + cohort?: string | null; + }; + }) => Fetcher); type LoopbackDurableObjectClass< T extends Rpc.DurableObjectBranded | undefined = undefined, > = DurableObjectClass & diff --git a/types/generated-snapshot/experimental/index.ts b/types/generated-snapshot/experimental/index.ts index 22429c622fc..04e5add017c 100755 --- a/types/generated-snapshot/experimental/index.ts +++ b/types/generated-snapshot/experimental/index.ts @@ -4108,8 +4108,18 @@ export type LoopbackServiceStub< T extends Rpc.WorkerEntrypointBranded | undefined = undefined, > = Fetcher & (T extends CloudflareWorkersModule.WorkerEntrypoint - ? (opts: { props?: Props }) => Fetcher - : (opts: { props?: any }) => Fetcher); + ? (opts: { + props?: Props; + version?: { + cohort?: string | null; + }; + }) => Fetcher + : (opts: { + props?: any; + version?: { + cohort?: string | null; + }; + }) => Fetcher); export type LoopbackDurableObjectClass< T extends Rpc.DurableObjectBranded | undefined = undefined, > = DurableObjectClass &