diff --git a/src/cloudflare/internal/workers.d.ts b/src/cloudflare/internal/workers.d.ts index 607f29b8a73..7cc4f38a4cb 100644 --- a/src/cloudflare/internal/workers.d.ts +++ b/src/cloudflare/internal/workers.d.ts @@ -19,6 +19,13 @@ export class WorkflowEntrypoint { env: unknown; } +export class ContainerEntrypoint { + constructor(ctx: unknown, env: unknown); + + ctx: unknown; + env: unknown; +} + export class RpcStub { constructor(server: object); } diff --git a/src/cloudflare/workers.ts b/src/cloudflare/workers.ts index 683a2b8bba8..410cf144595 100644 --- a/src/cloudflare/workers.ts +++ b/src/cloudflare/workers.ts @@ -10,6 +10,16 @@ import innerEnv from 'cloudflare-internal:env'; export const WorkerEntrypoint = entrypoints.WorkerEntrypoint; export const DurableObject = entrypoints.DurableObject; + +// Add ping method to ContainerEntrypoint prototype +const ContainerEntrypointBase = entrypoints.ContainerEntrypoint; +class ContainerEntrypointWithMethods extends ContainerEntrypointBase { + ping(): string { + return 'pong'; + } +} +export const ContainerEntrypoint = ContainerEntrypointWithMethods; + export const RpcStub = entrypoints.RpcStub; export const RpcPromise = entrypoints.RpcPromise; export const RpcProperty = entrypoints.RpcProperty; diff --git a/src/workerd/api/BUILD.bazel b/src/workerd/api/BUILD.bazel index 86b3afa8ea5..3ae6fac4b62 100644 --- a/src/workerd/api/BUILD.bazel +++ b/src/workerd/api/BUILD.bazel @@ -377,6 +377,12 @@ wd_test( data = ["actor-alarms-test.js"], ) +wd_test( + src = "container-entrypoint-test.wd-test", + args = ["--experimental"], + data = ["container-entrypoint-test.js"], +) + wd_test( src = "tail-worker-test.wd-test", args = [ diff --git a/src/workerd/api/container-entrypoint-test.js b/src/workerd/api/container-entrypoint-test.js new file mode 100644 index 00000000000..e06b5518d1b --- /dev/null +++ b/src/workerd/api/container-entrypoint-test.js @@ -0,0 +1,27 @@ +// Copyright (c) 2017-2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { ContainerEntrypoint } from 'cloudflare:workers'; +import assert from 'node:assert'; + +// Example ContainerEntrypoint that extends the base class +export class MyContainer extends ContainerEntrypoint { + async customMethod() { + return 'custom response'; + } +} + +// Test that ContainerEntrypoint can be instantiated and ping method works +export const testPingMethod = { + async test(ctrl, env, ctx) { + // Get a stub to MyContainer + const id = env.MY_CONTAINER.idFromName('test-container'); + const stub = env.MY_CONTAINER.get(id); + + // Call the built-in ping method + const pingResult = await stub.ping(); + assert.strictEqual(pingResult, 'pong', 'ping() should return "pong"'); + }, +}; + diff --git a/src/workerd/api/container-entrypoint-test.wd-test b/src/workerd/api/container-entrypoint-test.wd-test new file mode 100644 index 00000000000..b836e292f0b --- /dev/null +++ b/src/workerd/api/container-entrypoint-test.wd-test @@ -0,0 +1,27 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .mainWorker), + (name = "TEST_TMPDIR", disk = (writable = true)), + ], +); + +const mainWorker :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + compatibilityFlags = ["experimental", "nodejs_compat"], + + modules = [ + (name = "worker", esModule = embed "container-entrypoint-test.js"), + ], + + durableObjectNamespaces = [ + (className = "MyContainer", uniqueKey = "container-test-unique-key-123"), + ], + + durableObjectStorage = (localDisk = "TEST_TMPDIR"), + + bindings = [ + (name = "MY_CONTAINER", durableObjectNamespace = "MyContainer"), + ], +); diff --git a/src/workerd/api/workers-module.c++ b/src/workerd/api/workers-module.c++ index 640f3ea6d59..5a1ccf4353a 100644 --- a/src/workerd/api/workers-module.c++ +++ b/src/workerd/api/workers-module.c++ @@ -52,6 +52,18 @@ jsg::Ref WorkflowEntrypoint::constructor( return js.alloc(); } +jsg::Ref ContainerEntrypoint::constructor( + const v8::FunctionCallbackInfo& args, + jsg::Ref ctx, + jsg::JsObject env) { + jsg::Lock& js = jsg::Lock::from(args.GetIsolate()); + + jsg::JsObject self(args.This()); + self.set(js, "ctx", jsg::JsValue(args[0])); + self.set(js, "env", jsg::JsValue(args[1])); + return js.alloc(); +} + void EntrypointsModule::waitUntil(kj::Promise promise) { // No need to check if IoContext::hasCurrent since current() will throw // if there is no active request. diff --git a/src/workerd/api/workers-module.h b/src/workerd/api/workers-module.h index 1396b50407a..28d35f54b77 100644 --- a/src/workerd/api/workers-module.h +++ b/src/workerd/api/workers-module.h @@ -70,6 +70,27 @@ class WorkflowEntrypoint: public jsg::Object { JSG_RESOURCE_TYPE(WorkflowEntrypoint) {} }; +// Base class for Containers +// +// When the worker's top-level module exports a class that extends this class, it means that it +// is a Container entrypoint, similar to Durable Objects but with container-specific functionality. +// +// import { ContainerEntrypoint } from "cloudflare:workers"; +// export class MyContainer extends ContainerEntrypoint { +// // ping() is provided by the base class +// } +// +// `env` and `ctx` are automatically available as `this.env` and `this.ctx`, without the need to +// define a constructor. +class ContainerEntrypoint: public jsg::Object { + public: + static jsg::Ref constructor(const v8::FunctionCallbackInfo& args, + jsg::Ref ctx, + jsg::JsObject env); + + JSG_RESOURCE_TYPE(ContainerEntrypoint) {} +}; + // The "cloudflare:workers" module, which exposes the WorkerEntrypoint, WorkflowEntrypoint and DurableObject types // for extending. class EntrypointsModule: public jsg::Object { @@ -82,6 +103,7 @@ class EntrypointsModule: public jsg::Object { JSG_RESOURCE_TYPE(EntrypointsModule) { JSG_NESTED_TYPE(WorkerEntrypoint); JSG_NESTED_TYPE(WorkflowEntrypoint); + JSG_NESTED_TYPE(ContainerEntrypoint); JSG_NESTED_TYPE_NAMED(DurableObjectBase, DurableObject); JSG_NESTED_TYPE_NAMED(JsRpcPromise, RpcPromise); JSG_NESTED_TYPE_NAMED(JsRpcProperty, RpcProperty); @@ -94,7 +116,7 @@ class EntrypointsModule: public jsg::Object { }; #define EW_WORKERS_MODULE_ISOLATE_TYPES \ - api::WorkerEntrypoint, api::WorkflowEntrypoint, api::DurableObjectBase, api::EntrypointsModule + api::WorkerEntrypoint, api::WorkflowEntrypoint, api::ContainerEntrypoint, api::DurableObjectBase, api::EntrypointsModule template void registerWorkersModule(Registry& registry, CompatibilityFlags::Reader flags) { diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index 5519855b5b1..01cb773273e 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -1866,6 +1866,13 @@ void Worker::processEntrypointClass(jsg::Lock& js, .missingSuperclass = false, }); return; + } else if (handle == entrypointClasses.containerEntrypoint) { + impl->actorClasses.insert(kj::mv(handlerName), + ActorClassInfo{ + .cls = kj::mv(cls), + .missingSuperclass = false, + }); + return; } else if (handle == entrypointClasses.workerEntrypoint) { impl->statelessClasses.insert(kj::mv(handlerName), kj::mv(cls)); return; diff --git a/src/workerd/io/worker.h b/src/workerd/io/worker.h index 4e0f4615a70..eead55b3b9c 100644 --- a/src/workerd/io/worker.h +++ b/src/workerd/io/worker.h @@ -76,6 +76,9 @@ struct EntrypointClasses { // Class constructor for DurableObject (aka api::DurableObjectBase). jsg::JsObject durableObject; + // Class constructor for ContainerEntrypoint + jsg::JsObject containerEntrypoint; + // Class constructor for WorkflowEntrypoint jsg::JsObject workflowEntrypoint; }; diff --git a/src/workerd/server/tests/container-client/BUILD.bazel b/src/workerd/server/tests/container-client/BUILD.bazel index 31455372be4..5e1a55ce3a2 100644 --- a/src/workerd/server/tests/container-client/BUILD.bazel +++ b/src/workerd/server/tests/container-client/BUILD.bazel @@ -7,3 +7,11 @@ wd_test( data = ["test.js"], tags = ["off-by-default"], ) + +wd_test( + size = "enormous", + src = "test-entrypoint.wd-test", + args = ["--experimental"], + data = ["test-entrypoint.js"], + tags = ["off-by-default"], +) diff --git a/src/workerd/server/tests/container-client/test-entrypoint.js b/src/workerd/server/tests/container-client/test-entrypoint.js new file mode 100644 index 00000000000..9154ae28a50 --- /dev/null +++ b/src/workerd/server/tests/container-client/test-entrypoint.js @@ -0,0 +1,293 @@ +import { ContainerEntrypoint } from 'cloudflare:workers'; +import assert from 'node:assert'; +import { scheduler } from 'node:timers/promises'; + +export class ContainerEntrypointExample extends ContainerEntrypoint { + async testExitCode() { + const container = this.ctx.container; + if (container.running) { + let monitor = container.monitor().catch((_err) => {}); + await container.destroy(); + await monitor; + } + assert.strictEqual(container.running, false); + + // Start container with valid configuration + await container.start({ + entrypoint: ['node', 'nonexistant.js'], + }); + + let exitCode = undefined; + await container.monitor().catch((err) => { + exitCode = err.exitCode; + }); + + assert.strictEqual(typeof exitCode, 'number'); + assert.notEqual(0, exitCode); + } + + async testBasics() { + const container = this.ctx.container; + if (container.running) { + let monitor = container.monitor().catch((_err) => {}); + await container.destroy(); + await monitor; + } + assert.strictEqual(container.running, false); + + // Start container with valid configuration + await container.start({ + entrypoint: ['node', 'app.js'], + env: { A: 'B', C: 'D', L: 'F' }, + enableInternet: true, + }); + + const monitor = container.monitor().catch((_err) => {}); + + // Test HTTP requests to container + { + let resp; + for (let i = 0; i < 6; i++) { + try { + resp = await container + .getTcpPort(8080) + .fetch('http://foo/bar/baz', { method: 'POST', body: 'hello' }); + break; + } catch (e) { + await scheduler.wait(500); + if (i === 5) { + console.error( + `Failed to connect to container ${container.id}. Retried ${i} times` + ); + throw e; + } + } + } + + assert.equal(resp.status, 200); + assert.equal(resp.statusText, 'OK'); + assert.strictEqual(await resp.text(), 'Hello World!'); + } + await container.destroy(); + await monitor; + assert.strictEqual(container.running, false); + } + + async leaveRunning() { + // Start container and leave it running + const container = this.ctx.container; + if (!container.running) { + await container.start({ + entrypoint: ['leave-running'], + }); + } + + assert.strictEqual(container.running, true); + } + + async checkRunning() { + // Check container was started using leaveRunning() + const container = this.ctx.container; + + // Let's guard in case the test assumptions are wrong. + if (!container.running) { + return; + } + + await container.destroy(); + } + + async abort() { + await this.ctx.storage.put('aborted', true); + await this.ctx.storage.sync(); + this.ctx.abort(); + } + + async alarm() { + const alarmValue = (await this.ctx.storage.get('alarm')) ?? 0; + + const aborted = await this.ctx.storage.get('aborted'); + assert.strictEqual(!!this.ctx.container, true); + // assert.strictEqual(this.ctx.container.running, true); + if (aborted) { + await this.ctx.storage.put('aborted-confirmed', true); + } + + await this.ctx.storage.put('alarm', alarmValue + 1); + } + + async getAlarmIndex() { + return (await this.ctx.storage.get('alarm')) ?? 0; + } + + async startAlarm(start, ms) { + if (start && !this.ctx.container.running) { + await this.ctx.container.start(); + } + await this.ctx.storage.setAlarm(Date.now() + ms); + } + + async checkAlarmAbortConfirmation() { + const abortConfirmation = await this.ctx.storage.get('aborted-confirmed'); + if (!abortConfirmation) { + throw new Error( + `Abort confirmation did not get inserted: ${abortConfirmation}` + ); + } + } + + async testWs() { + const { container } = this.ctx; + + if (!container.running) { + await container.start({ + entrypoint: ['node', 'app.js'], + env: { WS_ENABLED: 'true' }, + enableInternet: true, + }); + } + + // Wait for container to be ready + await scheduler.wait(2000); + + // Test WebSocket upgrade request + const res = await container.getTcpPort(8080).fetch('http://foo/ws', { + headers: { + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Key': 'x3JJHMbDL1EzLkh9GBhXDw==', + 'Sec-WebSocket-Version': '13', + }, + }); + + // Should get WebSocket upgrade response + assert.strictEqual(res.status, 101); + assert.strictEqual(res.headers.get('upgrade'), 'websocket'); + assert.strictEqual(!!res.webSocket, true); + + // Test basic WebSocket communication + const ws = res.webSocket; + ws.accept(); + + // Listen for response + const messagePromise = new Promise((resolve) => { + ws.addEventListener( + 'message', + (event) => { + resolve(event.data); + }, + { once: true } + ); + }); + + // Send a test message + ws.send('Hello WebSocket!'); + + assert.strictEqual(await messagePromise, 'Echo: Hello WebSocket!'); + + ws.close(); + await container.destroy(); + } + + getStatus() { + return this.ctx.container.running; + } +} + +export class ContainerEntrypointExample2 extends ContainerEntrypointExample {} + +// Test basic container status +export const testStatus = { + async test(_ctrl, env) { + for (const CONTAINER of [env.MY_CONTAINER, env.MY_DUPLICATE_CONTAINER]) { + for (const name of ['testStatus', 'testStatus2']) { + const id = CONTAINER.idFromName(name); + const stub = CONTAINER.get(id); + assert.strictEqual(await stub.getStatus(), false); + } + } + }, +}; + +// Test basic container functionality +export const testBasics = { + async test(_ctrl, env) { + for (const CONTAINER of [env.MY_CONTAINER, env.MY_DUPLICATE_CONTAINER]) { + const id = CONTAINER.idFromName('testBasics'); + const stub = CONTAINER.get(id); + await stub.testBasics(); + } + }, +}; + +// Test exit code monitor functionality +export const testExitCode = { + async test(_ctrl, env) { + const id = env.MY_CONTAINER.idFromName('testExitCode'); + const stub = env.MY_CONTAINER.get(id); + await stub.testExitCode(); + }, +}; + +// Test container persistence across durable object instances +export const testAlreadyRunning = { + async test(_ctrl, env) { + const id = env.MY_CONTAINER.idFromName('testAlreadyRunning'); + let stub = env.MY_CONTAINER.get(id); + + await stub.leaveRunning(); + + await assert.rejects(() => stub.abort(), { + name: 'Error', + message: 'Application called abort() to reset Durable Object.', + }); + + // Recreate stub to get a new instance + stub = env.MY_CONTAINER.get(id); + await stub.checkRunning(); + }, +}; + +// Test WebSocket functionality +export const testWebSockets = { + async test(_ctrl, env) { + const id = env.MY_CONTAINER.idFromName('testWebsockets'); + const stub = env.MY_CONTAINER.get(id); + await stub.testWs(); + }, +}; + +// // Test alarm functionality with containers +export const testAlarm = { + async test(_ctrl, env) { + // Test that we can recover the use_containers flag correctly in setAlarm + // after a DO has been evicted + const id = env.MY_CONTAINER.idFromName('testAlarm'); + let stub = env.MY_CONTAINER.get(id); + + // Start immediate alarm + await stub.startAlarm(true, 0); + + // Wait for alarm to trigger + let retries = 0; + while ((await stub.getAlarmIndex()) === 0 && retries < 50) { + await scheduler.wait(20); + retries++; + } + + // Set alarm for future and abort + await stub.startAlarm(false, 1000); + + try { + await stub.abort(); + } catch { + // Expected to throw + } + + // Wait for alarm to run after abort + await scheduler.wait(1500); + + stub = env.MY_CONTAINER.get(id); + await stub.checkAlarmAbortConfirmation(); + }, +}; diff --git a/src/workerd/server/tests/container-client/test-entrypoint.wd-test b/src/workerd/server/tests/container-client/test-entrypoint.wd-test new file mode 100644 index 00000000000..4ee773fcb52 --- /dev/null +++ b/src/workerd/server/tests/container-client/test-entrypoint.wd-test @@ -0,0 +1,31 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "internet", network = ( allow = ["private"] ) ), + ( name = "container-client-test-entrypoint", + worker = ( + modules = [ + (name = "worker", esModule = embed "test-entrypoint.js") + ], + compatibilityDate = "2025-01-09", + compatibilityFlags = ["nodejs_compat", "experimental"], + containerEngine = (localDocker = (socketPath = "unix:///Users/emilyshen/.docker/run/docker.sock")), + durableObjectNamespaces = [ + ( className = "ContainerEntrypointExample", + uniqueKey = "container-client-test-ContainerEntrypointExample", + container = (imageName = "cf-container-client-test") ), + ( className = "ContainerEntrypointExample2", + uniqueKey = "container-client-test-ContainerEntrypointExample2", + container = (imageName = "cf-container-client-test") ), + ], + durableObjectStorage = (localDisk = "TEST_TMPDIR"), + bindings = [ + ( name = "MY_CONTAINER", durableObjectNamespace = "ContainerEntrypointExample" ), + ( name = "MY_DUPLICATE_CONTAINER", durableObjectNamespace = "ContainerEntrypointExample2" ), + ], + ) + ), + ( name = "TEST_TMPDIR", disk = (writable = true) ), + ], +); diff --git a/src/workerd/server/workerd-api.c++ b/src/workerd/server/workerd-api.c++ index 1f2003987aa..9fcec349e0e 100644 --- a/src/workerd/server/workerd-api.c++ +++ b/src/workerd/server/workerd-api.c++ @@ -357,6 +357,7 @@ EntrypointClasses WorkerdApi::getEntrypointClasses(jsg::Lock& lock) const { return { .workerEntrypoint = typedLock.getConstructor(lock.v8Context()), .durableObject = typedLock.getConstructor(lock.v8Context()), + .containerEntrypoint = typedLock.getConstructor(lock.v8Context()), .workflowEntrypoint = typedLock.getConstructor(lock.v8Context()), }; }