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 &