diff --git a/src/workerd/api/hibernatable-web-socket.c++ b/src/workerd/api/hibernatable-web-socket.c++ index 564f1493017..b7606c61c43 100644 --- a/src/workerd/api/hibernatable-web-socket.c++ +++ b/src/workerd/api/hibernatable-web-socket.c++ @@ -62,7 +62,8 @@ kj::Promise HibernatableWebSocketCustomEve kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) { // Mark the request as delivered because we're about to run some JS. auto& context = incomingRequest->getContext(); incomingRequest->delivered(); @@ -83,33 +84,34 @@ kj::Promise HibernatableWebSocketCustomEve try { co_await context.run( [entrypointName = entrypointName, &context, eventParameters = kj::mv(eventParameters), - versionInfo = kj::mv(versionInfo), props = kj::mv(props)](Worker::Lock& lock) mutable { + versionInfo = kj::mv(versionInfo), props = kj::mv(props), + isDynamicDispatch](Worker::Lock& lock) mutable { KJ_SWITCH_ONEOF(eventParameters.eventType) { KJ_CASE_ONEOF(text, HibernatableSocketParams::Text) { return lock.getGlobalScope().sendHibernatableWebSocketMessage(context, kj::mv(text.message), eventParameters.eventTimeoutMs, kj::mv(eventParameters.websocketId), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); } KJ_CASE_ONEOF(data, HibernatableSocketParams::Data) { return lock.getGlobalScope().sendHibernatableWebSocketMessage(context, kj::mv(data.message), eventParameters.eventTimeoutMs, kj::mv(eventParameters.websocketId), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); } KJ_CASE_ONEOF(close, HibernatableSocketParams::Close) { return lock.getGlobalScope().sendHibernatableWebSocketClose(context, kj::mv(close), eventParameters.eventTimeoutMs, kj::mv(eventParameters.websocketId), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); } KJ_CASE_ONEOF(e, HibernatableSocketParams::Error) { return lock.getGlobalScope().sendHibernatableWebSocketError(context, kj::mv(e.error), eventParameters.eventTimeoutMs, kj::mv(eventParameters.websocketId), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); } KJ_UNREACHABLE; } diff --git a/src/workerd/api/hibernatable-web-socket.h b/src/workerd/api/hibernatable-web-socket.h index 84da6c6d9db..f17b817bc57 100644 --- a/src/workerd/api/hibernatable-web-socket.h +++ b/src/workerd/api/hibernatable-web-socket.h @@ -68,7 +68,8 @@ class HibernatableWebSocketCustomEvent final: public WorkerInterface::CustomEven kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/api/queue.c++ b/src/workerd/api/queue.c++ index f4a8846cb44..c9191953521 100644 --- a/src/workerd/api/queue.c++ +++ b/src/workerd/api/queue.c++ @@ -825,7 +825,8 @@ kj::Promise QueueCustomEvent::run( kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) { // This method has three main chunks of logic: // 1. Do all necessary setup work. This starts right below this comment. // 2. Call into the worker's queue event handler. @@ -846,14 +847,14 @@ kj::Promise QueueCustomEvent::run( auto runProm = context.run( [this, entrypointName = entrypointName, &context, queueEvent = kj::addRef(*queueEventHolder), &metrics = incomingRequest->getMetrics(), versionInfo = kj::mv(versionInfo), - props = kj::mv(props)](Worker::Lock& lock) mutable { + props = kj::mv(props), isDynamicDispatch](Worker::Lock& lock) mutable { jsg::AsyncContextFrame::StorageScope traceScope = context.makeAsyncTraceScope(lock); auto& typeHandler = lock.getWorker().getIsolate().getApi().getQueueTypeHandler(lock); auto startResp = startQueueEvent(lock.getGlobalScope(), context, kj::mv(params), context.addObject(result), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor()), + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch), typeHandler); queueEvent->event = kj::mv(startResp.event); queueEvent->exportedHandlerProm = kj::mv(startResp.exportedHandlerProm); diff --git a/src/workerd/api/queue.h b/src/workerd/api/queue.h index d2543ecb3d3..781a5eaef99 100644 --- a/src/workerd/api/queue.h +++ b/src/workerd/api/queue.h @@ -473,7 +473,8 @@ class QueueCustomEvent final: public WorkerInterface::CustomEvent, public kj::Re kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/api/trace.c++ b/src/workerd/api/trace.c++ index d5ecb2fbeed..585a212a5e5 100644 --- a/src/workerd/api/trace.c++ +++ b/src/workerd/api/trace.c++ @@ -651,7 +651,8 @@ kj::Promise sendTracesToExportedHandler(kj::Own entrypointNamePtr, kj::Maybe versionInfo, Frankenvalue props, - kj::ArrayPtr> traces) { + kj::ArrayPtr> traces, + bool isDynamicDispatch) { // Mark the request as delivered because we're about to run some JS. incomingRequest->delivered(); @@ -672,11 +673,12 @@ kj::Promise sendTracesToExportedHandler(kj::Own incomingRequest, kj::Maybe entrypointNamePtr, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) -> kj::Promise { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) -> kj::Promise { // Don't bother to wait around for the handler to run, just hand it off to the waitUntil tasks. - waitUntilTasks.add(sendTracesToExportedHandler( - kj::mv(incomingRequest), entrypointNamePtr, kj::mv(versionInfo), kj::mv(props), traces)); + waitUntilTasks.add(sendTracesToExportedHandler(kj::mv(incomingRequest), entrypointNamePtr, + kj::mv(versionInfo), kj::mv(props), traces, isDynamicDispatch)); // Reporting a proper outcome and return event here would be nice, but for that we'd need to await // running the tail handler... diff --git a/src/workerd/api/trace.h b/src/workerd/api/trace.h index eb47438459c..a8258e72e70 100644 --- a/src/workerd/api/trace.h +++ b/src/workerd/api/trace.h @@ -655,7 +655,8 @@ class TraceCustomEvent final: public WorkerInterface::CustomEvent { kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/api/worker-rpc.c++ b/src/workerd/api/worker-rpc.c++ index 17bf9f3a261..ace2c41a905 100644 --- a/src/workerd/api/worker-rpc.c++ +++ b/src/workerd/api/worker-rpc.c++ @@ -1982,7 +1982,8 @@ class EntrypointJsRpcTarget final: public JsRpcTargetBase { kj::Maybe versionInfo, Frankenvalue props, kj::Maybe wrapperModule, - kj::Maybe> tracer) + kj::Maybe> tracer, + bool isDynamicDispatch) : JsRpcTargetBase(ioCtx, CantOutliveIncomingRequest()), ioCtx(ioCtx), // Most of the time we don't really have to clone this but it's hard to fully prove, so @@ -1991,7 +1992,8 @@ class EntrypointJsRpcTarget final: public JsRpcTargetBase { versionInfo(kj::mv(versionInfo)), props(kj::mv(props)), wrapperModule(kj::mv(wrapperModule)), - tracer(kj::mv(tracer)) {} + tracer(kj::mv(tracer)), + isDynamicDispatch(isDynamicDispatch) {} // Override call() to emit the Return event when the top-level RPC call completes. // This marks when the handler returned a value, NOT when all data has been streamed or all @@ -2008,7 +2010,7 @@ class EntrypointJsRpcTarget final: public JsRpcTargetBase { jsg::Lock& js = lock; auto handler = KJ_REQUIRE_NONNULL(lock.getExportedHandler(entrypointName, kj::mv(versionInfo), - kj::mv(props), ioCtx.getActor()), + kj::mv(props), ioCtx.getActor(), isDynamicDispatch), "Failed to get handler to worker."); if (handler->missingSuperclass && wrapperModule == kj::none) { @@ -2077,6 +2079,7 @@ class EntrypointJsRpcTarget final: public JsRpcTargetBase { Frankenvalue props; kj::Maybe wrapperModule; kj::Maybe> tracer; + bool isDynamicDispatch; bool isReservedName(kj::StringPtr name) override { if ( // "fetch" and "connect" are treated specially on entrypoints. @@ -2165,7 +2168,8 @@ kj::Promise JsRpcSessionCustomEvent::run( kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) { IoContext& ioctx = incomingRequest->getContext(); incomingRequest->delivered(); @@ -2177,7 +2181,7 @@ kj::Promise JsRpcSessionCustomEvent::run( }); EntrypointJsRpcTarget target(ioctx, entrypointName, kj::mv(versionInfo), kj::mv(props), - kj::mv(wrapperModule), mapAddRef(incomingRequest->getWorkerTracer())); + kj::mv(wrapperModule), mapAddRef(incomingRequest->getWorkerTracer()), isDynamicDispatch); capnp::RevocableServer revcableTarget(target); try { diff --git a/src/workerd/api/worker-rpc.h b/src/workerd/api/worker-rpc.h index 263d686cda6..c5db58bbb2d 100644 --- a/src/workerd/api/worker-rpc.h +++ b/src/workerd/api/worker-rpc.h @@ -476,7 +476,8 @@ class JsRpcSessionCustomEvent final: public WorkerInterface::CustomEvent { kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/io/BUILD.bazel b/src/workerd/io/BUILD.bazel index 669d3e827f7..978f9d96728 100644 --- a/src/workerd/io/BUILD.bazel +++ b/src/workerd/io/BUILD.bazel @@ -526,3 +526,11 @@ kj_test( "//src/workerd/tests:test-fixture", ], ) + +kj_test( + src = "worker-getexportedhandler-test.c++", + deps = [ + ":io", + "//src/workerd/tests:test-fixture", + ], +) diff --git a/src/workerd/io/trace-stream.c++ b/src/workerd/io/trace-stream.c++ index cb7dcae0dae..a3716a15ddf 100644 --- a/src/workerd/io/trace-stream.c++ +++ b/src/workerd/io/trace-stream.c++ @@ -620,12 +620,14 @@ class TailStreamTarget final: public rpc::TailStreamTarget::Server { kj::Maybe entrypointNamePtr, kj::Maybe versionInfo, Frankenvalue props, - kj::Own> doneFulfiller) + kj::Own> doneFulfiller, + bool isDynamicDispatch) : weakIoContext(ioContext.getWeakRef()), entrypointNamePtr(kj::mv(entrypointNamePtr)), versionInfo(kj::mv(versionInfo)), props(kj::mv(props)), - doneFulfiller(kj::mv(doneFulfiller)) {} + doneFulfiller(kj::mv(doneFulfiller)), + isDynamicDispatch(isDynamicDispatch) {} KJ_DISALLOW_COPY_AND_MOVE(TailStreamTarget); ~TailStreamTarget() { @@ -735,9 +737,10 @@ class TailStreamTarget final: public rpc::TailStreamTarget::Server { events.size() == 1 && events[0].event.is(), "Expected only a single onset event"); auto& event = events[0]; - auto handler = KJ_REQUIRE_NONNULL(lock.getExportedHandler(entrypointNamePtr, - kj::mv(versionInfo), kj::mv(props), ioContext.getActor()), - "Failed to get handler to worker."); + auto handler = + KJ_REQUIRE_NONNULL(lock.getExportedHandler(entrypointNamePtr, kj::mv(versionInfo), + kj::mv(props), ioContext.getActor(), isDynamicDispatch), + "Failed to get handler to worker."); StringCache stringCache; jsg::Lock& js = lock; @@ -934,6 +937,7 @@ class TailStreamTarget final: public rpc::TailStreamTarget::Server { // or rejected if the capability is dropped before receiving the outcome // event. kj::Own> doneFulfiller; + bool isDynamicDispatch; // The maybeHandler will be empty until we receive and process the // onset event. @@ -954,13 +958,14 @@ kj::Promise TailStreamCustomEvent::run( kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) { IoContext& ioContext = incomingRequest->getContext(); incomingRequest->delivered(); auto [donePromise, doneFulfiller] = kj::newPromiseAndFulfiller(); capFulfiller->fulfill(kj::heap(ioContext, kj::mv(entrypointName), - kj::mv(versionInfo), kj::mv(props), kj::mv(doneFulfiller))); + kj::mv(versionInfo), kj::mv(props), kj::mv(doneFulfiller), isDynamicDispatch)); donePromise = donePromise.attach(ioContext.registerPendingEvent()); diff --git a/src/workerd/io/trace-stream.h b/src/workerd/io/trace-stream.h index 7d76c187dbf..4260887bf2f 100644 --- a/src/workerd/io/trace-stream.h +++ b/src/workerd/io/trace-stream.h @@ -30,7 +30,8 @@ class TailStreamCustomEvent final: public WorkerInterface::CustomEvent { kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/io/worker-entrypoint.c++ b/src/workerd/io/worker-entrypoint.c++ index 17fc33247a7..e7371cddac3 100644 --- a/src/workerd/io/worker-entrypoint.c++ +++ b/src/workerd/io/worker-entrypoint.c++ @@ -60,7 +60,8 @@ class WorkerEntrypoint final: public WorkerInterface { kj::Maybe> workerTracer, kj::Maybe cfBlobJson, kj::Maybe versionInfo, - kj::Maybe maybeTriggerInvocationSpan); + kj::Maybe maybeTriggerInvocationSpan, + bool isDynamicDispatch); kj::Promise request(kj::HttpMethod method, kj::StringPtr url, @@ -87,6 +88,7 @@ class WorkerEntrypoint final: public WorkerInterface { kj::TaskSet& waitUntilTasks; kj::Maybe> incomingRequest; bool tunnelExceptions; + bool isDynamicDispatch; kj::Maybe entrypointName; Frankenvalue props; kj::Maybe cfBlobJson; @@ -122,6 +124,7 @@ class WorkerEntrypoint final: public WorkerInterface { ThreadContext& threadContext, kj::TaskSet& waitUntilTasks, bool tunnelExceptions, + bool isDynamicDispatch, kj::Maybe entrypointName, Frankenvalue props, kj::Maybe cfBlobJson, @@ -179,12 +182,13 @@ kj::Own WorkerEntrypoint::construct(ThreadContext& threadContex kj::Maybe> workerTracer, kj::Maybe cfBlobJson, kj::Maybe versionInfo, - kj::Maybe maybeTriggerInvocationSpan) { + kj::Maybe maybeTriggerInvocationSpan, + bool isDynamicDispatch) { TRACE_EVENT("workerd", "WorkerEntrypoint::construct()"); - auto obj = - kj::heap(kj::Badge(), threadContext, waitUntilTasks, - tunnelExceptions, entrypointName, kj::mv(props), kj::mv(cfBlobJson), kj::mv(versionInfo)); + auto obj = kj::heap(kj::Badge(), threadContext, + waitUntilTasks, tunnelExceptions, isDynamicDispatch, entrypointName, kj::mv(props), + kj::mv(cfBlobJson), kj::mv(versionInfo)); obj->init(kj::mv(worker), kj::mv(actor), kj::mv(limitEnforcer), kj::mv(ioContextDependency), kj::mv(ioChannelFactory), kj::addRef(*metrics), kj::mv(workerTracer), kj::mv(maybeTriggerInvocationSpan)); @@ -196,6 +200,7 @@ WorkerEntrypoint::WorkerEntrypoint(kj::Badge badge, ThreadContext& threadContext, kj::TaskSet& waitUntilTasks, bool tunnelExceptions, + bool isDynamicDispatch, kj::Maybe entrypointName, Frankenvalue props, kj::Maybe cfBlobJson, @@ -203,6 +208,7 @@ WorkerEntrypoint::WorkerEntrypoint(kj::Badge badge, : threadContext(threadContext), waitUntilTasks(waitUntilTasks), tunnelExceptions(tunnelExceptions), + isDynamicDispatch(isDynamicDispatch), entrypointName(entrypointName), props(kj::mv(props)), cfBlobJson(kj::mv(cfBlobJson)), @@ -352,8 +358,8 @@ kj::Promise WorkerEntrypoint::request(kj::HttpMethod method, return lock.getGlobalScope().request(method, url, headers, requestBody, wrappedResponse, cfBlobJson, lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor()), + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch), kj::mv(signal)); }) .then([this, &context, &wrappedResponse = *wrappedResponse, workerTracer]( @@ -587,8 +593,8 @@ kj::Promise WorkerEntrypoint::connect(kj::StringPtr host, jsg::AsyncContextFrame::StorageScope traceScope = context.makeAsyncTraceScope(lock); return lock.getGlobalScope().connect(kj::mv(host), headers, connection, response, lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); }) .then([&context, workerTracer]() { KJ_IF_SOME(t, workerTracer) { @@ -919,7 +925,7 @@ kj::Promise WorkerEntrypoint::customEvent( auto promise = event ->run(kj::mv(incomingRequest), entrypointName, kj::mv(versionInfo), - kj::mv(props), waitUntilTasks) + kj::mv(props), waitUntilTasks, isDynamicDispatch) .attach(kj::mv(event)); // TODO(cleanup): In theory `context` may have been destroyed by now if `event->run()` dropped @@ -981,12 +987,13 @@ kj::Own newWorkerEntrypoint(ThreadContext& threadContext, kj::Maybe> workerTracer, kj::Maybe cfBlobJson, kj::Maybe versionInfo, - kj::Maybe maybeTriggerInvocationSpan) { + kj::Maybe maybeTriggerInvocationSpan, + bool isDynamicDispatch) { return WorkerEntrypoint::construct(threadContext, kj::mv(worker), kj::mv(entrypointName), kj::mv(props), kj::mv(actor), kj::mv(limitEnforcer), kj::mv(ioContextDependency), kj::mv(ioChannelFactory), kj::mv(metrics), waitUntilTasks, tunnelExceptions, kj::mv(workerTracer), kj::mv(cfBlobJson), kj::mv(versionInfo), - kj::mv(maybeTriggerInvocationSpan)); + kj::mv(maybeTriggerInvocationSpan), isDynamicDispatch); } } // namespace workerd diff --git a/src/workerd/io/worker-entrypoint.h b/src/workerd/io/worker-entrypoint.h index 7bf41b1a06f..e7ed1c1dfa8 100644 --- a/src/workerd/io/worker-entrypoint.h +++ b/src/workerd/io/worker-entrypoint.h @@ -46,6 +46,7 @@ kj::Own newWorkerEntrypoint(ThreadContext& threadContext, // the implication is that this worker entrypoint is being created as a subrequest or // subtask of another request. If it is kj::none, then this invocation is a top-level // invocation. - kj::Maybe maybeTriggerInvocationSpan = kj::none); + kj::Maybe maybeTriggerInvocationSpan = kj::none, + bool isDynamicDispatch = false); } // namespace workerd diff --git a/src/workerd/io/worker-getexportedhandler-test.c++ b/src/workerd/io/worker-getexportedhandler-test.c++ new file mode 100644 index 00000000000..be86a383844 --- /dev/null +++ b/src/workerd/io/worker-getexportedhandler-test.c++ @@ -0,0 +1,95 @@ +// Copyright (c) 2024 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Tests for Worker::Lock::getExportedHandler() error behaviour, specifically +// the isDynamicDispatch path which surfaces user-configuration mistakes as JSG +// TypeErrors rather than internal log-only errors. + +#include +#include +#include +#include + +#include + +namespace workerd { +namespace { + +// --------------------------------------------------------------------------- +// isDynamicDispatch = true: both error cases must throw a JSG TypeError. +// --------------------------------------------------------------------------- + +KJ_TEST("getExportedHandler: DO class via dynamic dispatch throws JSG TypeError") { + TestFixture fixture({ + .mainModuleSource = R"( + import { DurableObject } from "cloudflare:workers"; + export class SomeActor extends DurableObject {} + export default { async fetch(req) { return new Response("ok"); } } + )"_kj, + }); + + fixture.runInIoContext([&](const TestFixture::Environment& env) { + KJ_EXPECT_THROW_MESSAGE( + "jsg.TypeError: The entrypoint name SomeActor refers to a Durable Object class, but the " + "incoming request is trying to invoke it as a stateless worker.", + env.lock.getExportedHandler("SomeActor"_kj, kj::none, Frankenvalue{}, kj::none, true)); + }); +} + +KJ_TEST("getExportedHandler: missing entrypoint via dynamic dispatch throws JSG TypeError") { + TestFixture fixture({ + .mainModuleSource = R"( + export default { async fetch(req) { return new Response("ok"); } } + )"_kj, + }); + + fixture.runInIoContext([&](const TestFixture::Environment& env) { + KJ_EXPECT_THROW_MESSAGE( + "jsg.TypeError: The entrypoint name nonExistent was not found in this worker. Ensure the " + "worker exports an entrypoint with that name.", + env.lock.getExportedHandler("nonExistent"_kj, kj::none, Frankenvalue{}, kj::none, true)); + }); +} + +// --------------------------------------------------------------------------- +// isDynamicDispatch = false: both error cases must NOT throw a JSG TypeError +// (they log and then throw a non-JSG internal error via KJ_FAIL_ASSERT). +// --------------------------------------------------------------------------- + +KJ_TEST("getExportedHandler: DO class via static dispatch throws internal error") { + TestFixture fixture({ + .mainModuleSource = R"( + import { DurableObject } from "cloudflare:workers"; + export class SomeActor extends DurableObject {} + export default { async fetch(req) { return new Response("ok"); } } + )"_kj, + }); + + fixture.runInIoContext([&](const TestFixture::Environment& env) { + // LOG_ERROR_PERIODICALLY fires, then KJ_FAIL_ASSERT throws. + // No JSG TypeError — the error is treated as internal. + KJ_EXPECT_LOG(ERROR, "worker is not an actor but class name was requested; n = SomeActor"); + KJ_EXPECT_THROW_MESSAGE("worker_do_not_log; Unable to get exported handler", + env.lock.getExportedHandler("SomeActor"_kj, kj::none, Frankenvalue{}, kj::none, false)); + }); +} + +KJ_TEST("getExportedHandler: missing entrypoint via static dispatch throws internal error") { + TestFixture fixture({ + .mainModuleSource = R"( + export default { async fetch(req) { return new Response("ok"); } } + )"_kj, + }); + + fixture.runInIoContext([&](const TestFixture::Environment& env) { + // LOG_ERROR_PERIODICALLY fires, then KJ_FAIL_ASSERT throws. + // No JSG TypeError — the error is treated as internal. + KJ_EXPECT_LOG(ERROR, "worker has no such named entrypoint; n = nonExistent"); + KJ_EXPECT_THROW_MESSAGE("worker_do_not_log; Unable to get exported handler", + env.lock.getExportedHandler("nonExistent"_kj, kj::none, Frankenvalue{}, kj::none, false)); + }); +} + +} // namespace +} // namespace workerd diff --git a/src/workerd/io/worker-interface.h b/src/workerd/io/worker-interface.h index 00868c9462b..32cb3bf77f4 100644 --- a/src/workerd/io/worker-interface.h +++ b/src/workerd/io/worker-interface.h @@ -131,7 +131,8 @@ class WorkerInterface: public kj::HttpService { kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) = 0; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch = false) = 0; // Forward the event over RPC. virtual kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index 530b4c9dc9d..7da8f7f4670 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -2318,7 +2318,8 @@ kj::Maybe> Worker::Lock::getExportedHandler( kj::Maybe name, kj::Maybe versionInfo, Frankenvalue props, - kj::Maybe actor) { + kj::Maybe actor, + bool isDynamicDispatch) { KJ_IF_SOME(a, actor) { KJ_IF_SOME(h, a.getHandler()) { return fakeOwn(h); @@ -2370,7 +2371,16 @@ kj::Maybe> Worker::Lock::getExportedHandler( return kj::none; } else { if (worker.impl->actorClasses.find(n) != kj::none) { - LOG_ERROR_PERIODICALLY("worker is not an actor but class name was requested", n); + if (isDynamicDispatch) { + JSG_FAIL_REQUIRE(TypeError, "The entrypoint name ", n, + " refers to a Durable Object class, but the incoming request is trying to invoke it as" + " a stateless worker."); + } else { + LOG_ERROR_PERIODICALLY("worker is not an actor but class name was requested", n); + } + } else if (isDynamicDispatch) { + JSG_FAIL_REQUIRE(TypeError, "The entrypoint name ", n, + " was not found in this worker. Ensure the worker exports an entrypoint with that name."); } else { LOG_ERROR_PERIODICALLY("worker has no such named entrypoint", n); } diff --git a/src/workerd/io/worker.h b/src/workerd/io/worker.h index f0301b5a794..31da3c54374 100644 --- a/src/workerd/io/worker.h +++ b/src/workerd/io/worker.h @@ -758,11 +758,17 @@ class Worker::Lock { // // If running in an actor, the name and props are ignored and the entrypoint originally used to // construct the actor is returned. + // + // `isDynamicDispatch` indicates the entrypoint name was supplied at request time (e.g. by the + // Workflows engine via dynamic dispatch) rather than baked into the pipeline at config time. When + // true, a missing entrypoint is surfaced as a JSG TypeError to the caller rather than being + // logged as an internal error, since the mismatch is attributable to user configuration. kj::Maybe> getExportedHandler( kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::Maybe actor); + kj::Maybe actor, + bool isDynamicDispatch = false); // Get the C++ object representing the global scope. api::ServiceWorkerGlobalScope& getGlobalScope();