diff --git a/src/content/docs/containers/examples/container-backend.mdx b/src/content/docs/containers/examples/container-backend.mdx index fc458f7461a..3062758c36c 100644 --- a/src/content/docs/containers/examples/container-backend.mdx +++ b/src/content/docs/containers/examples/container-backend.mdx @@ -10,7 +10,13 @@ products: - containers --- -import { WranglerConfig, Details } from "~/components"; +import { + WranglerConfig, + Details, + TabItem, + Tabs, + TypeScriptExample, +} from "~/components"; A common pattern is to serve a static frontend application (e.g., React, Vue, Svelte) using Static Assets, then pass backend requests to a containerized backend application. @@ -18,15 +24,17 @@ then pass backend requests to a containerized backend application. In this example, we'll show an example using a simple `index.html` file served as a static asset, but you can select from one of many frontend frameworks. See our [Workers framework examples](/workers/framework-guides/web-apps/) for more information. -For a full example, see the [Static Frontend + Container Backend Template](https://github.com/mikenomitch/static-frontend-container-backend). - ## Configure Static Assets and a Container ```json { - "name": "cron-container", + "name": "static-frontend-container-backend", "main": "src/index.ts", + "compatibility_date": "$today", + "observability": { + "enabled": true + }, "assets": { "directory": "./dist", "binding": "ASSETS" @@ -46,14 +54,12 @@ For a full example, see the [Static Frontend + Container Backend Template](https } ] }, - "migrations": [ - { - "new_sqlite_classes": [ - "Backend" - ], - "tag": "v1" + "exports": { + "Backend": { + "type": "durable-object", + "storage": "sqlite" } - ] + } } ``` @@ -138,18 +144,113 @@ Your Worker needs to be able to both serve static assets and route requests to t In this case, we will pass requests to one of three container instances if the route starts with `/api`, and all other requests will be served as static assets. -```javascript +Because `container.start()` does not wait for readiness, this example polls `GET /health` before forwarding requests. + + + + + +```ts +import { DurableObject } from "cloudflare:workers"; + +const INSTANCE_COUNT = 3; + +interface Env { + ASSETS: Fetcher; + BACKEND: DurableObjectNamespace; +} + +export class Backend extends DurableObject { + private ready: Promise | undefined; + + async fetch(request: Request): Promise { + const container = this.ctx.container!; + if (!container.running) { + this.ready = undefined; + } + this.ready ??= this.startAndWaitForPort().catch((error: unknown) => { + this.ready = undefined; + throw error; + }); + await this.ready; + + const url = new URL(request.url); + url.protocol = "http:"; + url.host = "container"; + const forwarded = new Request(url, request); + forwarded.headers.delete("host"); + return container.getTcpPort(8080).fetch(forwarded); + } + + private async startAndWaitForPort(): Promise { + const container = this.ctx.container!; + await container.setInactivityTimeout(2 * 60 * 60 * 1000); + if (!container.running) { + container.start(); + } + this.ctx.waitUntil( + container + .monitor() + .then(() => console.log("Backend container exited")) + .catch((error: unknown) => + console.error("Backend container errored:", error), + ), + ); + + const port = container.getTcpPort(8080); + let lastError: unknown; + for (let attempt = 0; attempt < 100; attempt++) { + try { + const response = await port.fetch("http://container/health"); + if (!response.ok) { + throw new Error(`Health check returned ${response.status}`); + } + return; + } catch (error) { + lastError = error; + await scheduler.wait(200); + } + } + throw new Error("Backend did not become ready on port 8080", { + cause: lastError, + }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (new URL(request.url).pathname.startsWith("/api")) { + const index = Math.floor(Math.random() * INSTANCE_COUNT); + return env.BACKEND.getByName(`instance-${index}`).fetch(request); + } + + return env.ASSETS.fetch(request); + }, +}; +``` + + + + + + +```ts import { Container, getRandom } from "@cloudflare/containers"; const INSTANCE_COUNT = 3; -class Backend extends Container { +interface Env { + ASSETS: Fetcher; + BACKEND: DurableObjectNamespace; +} + +export class Backend extends Container { defaultPort = 8080; // pass requests to port 8080 in the container sleepAfter = "2h"; // only sleep a container if it hasn't gotten requests in 2 hours } export default { - async fetch(request, env) { + async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); if (url.pathname.startsWith("/api")) { const containerInstance = await getRandom(env.BACKEND, INSTANCE_COUNT); @@ -160,15 +261,17 @@ export default { }, }; ``` + -:::note -This example uses `getRandom`, which randomly selects one of a fixed number of Container -instances for each request. +For a full Container class example, see the [Static Frontend + Container Backend Template](https://github.com/mikenomitch/static-frontend-container-backend). + + + -In the future, we will provide improved latency-aware load balancing and autoscaling. +:::note +Both examples randomly select one of a fixed number of Container instances for each request. The `Container` class provides `getRandom()` as a helper. -This will make scaling stateless instances simple and routing more efficient. See the -[autoscaling documentation](/containers/configuration/scaling-and-routing) for more details. +See the [autoscaling documentation](/containers/configuration/scaling-and-routing) for more details about scaling stateless instances and routing. ::: ## Define a backend container @@ -188,7 +291,7 @@ import ( "net/http" ) -func handler(w http.ResponseWriter, r \*http.Request) { +func handler(w http.ResponseWriter, r *http.Request) { widgets := []map[string]interface{}{ {"id": 1, "name": "Widget A"}, {"id": 2, "name": "Sprocket B"}, @@ -202,6 +305,9 @@ func handler(w http.ResponseWriter, r \*http.Request) { } func main() { + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) http.HandleFunc("/api/widgets", handler) log.Fatal(http.ListenAndServe(":8080", nil)) } @@ -209,3 +315,21 @@ func main() { ``` + +The health endpoint lets the Worker wait for the backend before forwarding requests. Build the backend image with this Dockerfile in the project root: + +
+ +```dockerfile +FROM golang:1.25-alpine AS build +WORKDIR /app +COPY server.go . +RUN CGO_ENABLED=0 go build -o /server server.go + +FROM alpine:3.20 +COPY --from=build /server /server +EXPOSE 8080 +CMD ["/server"] +``` + +
diff --git a/src/content/docs/containers/examples/cron.mdx b/src/content/docs/containers/examples/cron.mdx index beb5c71d036..61567becda1 100644 --- a/src/content/docs/containers/examples/cron.mdx +++ b/src/content/docs/containers/examples/cron.mdx @@ -10,20 +10,22 @@ products: - containers --- -import { WranglerConfig } from "~/components"; +import { TabItem, Tabs, TypeScriptExample, WranglerConfig } from "~/components"; To launch a container on a schedule, you can use a Workers [Cron Trigger](/workers/configuration/cron-triggers/). -For a full example, see the [Cron Container Template](https://github.com/mikenomitch/cron-container/tree/main). - Use a cron expression in your Wrangler config to specify the schedule: -```json +```jsonc { "name": "cron-container", "main": "src/index.ts", + "compatibility_date": "$today", + "observability": { + "enabled": true + }, "triggers": { "crons": [ "*/2 * * * *" // Run every 2 minutes @@ -43,21 +45,70 @@ Use a cron expression in your Wrangler config to specify the schedule: } ] }, - "migrations": [ - { - "new_sqlite_classes": ["CronContainer"], - "tag": "v1" + "exports": { + "CronContainer": { + "type": "durable-object", + "storage": "sqlite" } - ] + } } ``` -Then in your Worker, call your Container from the "scheduled" handler: +Call the Container from the Worker's `scheduled()` handler. The container image runs the task on startup and exits when finished. Await `monitor()` to observe completion or failure. + + + + + +```ts +import { DurableObject } from "cloudflare:workers"; + +interface Env { + CRON_CONTAINER: DurableObjectNamespace; +} + +export class CronContainer extends DurableObject { + private currentRun: Promise | undefined; + + run(startTime: string): Promise { + this.currentRun ??= this.runOnce(startTime).finally(() => { + this.currentRun = undefined; + }); + return this.currentRun; + } + + private async runOnce(startTime: string): Promise { + const container = this.ctx.container!; + if (!container.running) { + console.log("Starting scheduled task:", startTime); + container.start(); + } + await container.monitor(); + } +} + +export default { + async fetch(): Promise { + return new Response("This Worker runs a scheduled Container task."); + }, + + async scheduled(controller: ScheduledController, env: Env): Promise { + await env.CRON_CONTAINER + .getByName("cron") + .run(new Date(controller.scheduledTime).toISOString()); + }, +}; +``` + + + + + ```ts -import { Container, getContainer } from '@cloudflare/containers'; +import { Container, getContainer } from "@cloudflare/containers"; export class CronContainer extends Container { sleepAfter = '10s'; @@ -72,17 +123,26 @@ export class CronContainer extends Container { } export default { - async fetch(): Promise { - return new Response("This Worker runs a cron job to execute a container on a schedule."); - }, - - async scheduled(_controller: any, env: { CRON_CONTAINER: DurableObjectNamespace }) { - let container = getContainer(env.CRON_CONTAINER); - await container.start({ - envVars: { - MESSAGE: "Start Time: " + new Date().toISOString(), - } - }) - }, + async fetch(): Promise { + return new Response("This Worker runs a cron job to execute a container on a schedule."); + }, + + async scheduled( + controller: ScheduledController, + env: { CRON_CONTAINER: DurableObjectNamespace }, + ): Promise { + const container = getContainer(env.CRON_CONTAINER); + await container.start({ + envVars: { + MESSAGE: `Start Time: ${new Date(controller.scheduledTime).toISOString()}`, + }, + }); + }, }; ``` + + +For a full Container class example, see the [Cron Container Template](https://github.com/mikenomitch/cron-container/tree/main). + + + diff --git a/src/content/docs/containers/examples/durable-object-interface.mdx b/src/content/docs/containers/examples/durable-object-interface.mdx index f957451fa12..2b3c2663a36 100644 --- a/src/content/docs/containers/examples/durable-object-interface.mdx +++ b/src/content/docs/containers/examples/durable-object-interface.mdx @@ -2,7 +2,7 @@ summary: Various examples calling Containers directly from Durable Objects pcx_content_type: example -title: Using Durable Objects Directly +title: More examples external_link: https://github.com/cloudflare/containers-demos sidebar: order: 10 diff --git a/src/content/docs/containers/examples/env-vars-and-secrets.mdx b/src/content/docs/containers/examples/env-vars-and-secrets.mdx index 4dfa0e739bf..56e96741ab1 100644 --- a/src/content/docs/containers/examples/env-vars-and-secrets.mdx +++ b/src/content/docs/containers/examples/env-vars-and-secrets.mdx @@ -81,6 +81,9 @@ in Wrangler configuration. ```json { "name": "my-container-worker", + "observability": { + "enabled": true + }, "vars": { "ENV_VAR": "my-env-var" }, diff --git a/src/content/docs/containers/examples/stateless.mdx b/src/content/docs/containers/examples/stateless.mdx index 8cf6275a0df..285fc1815ca 100644 --- a/src/content/docs/containers/examples/stateless.mdx +++ b/src/content/docs/containers/examples/stateless.mdx @@ -10,14 +10,97 @@ products: - containers --- -To simply proxy requests to one of multiple instances of a container, you can use the `getRandom` function: +import { TabItem, Tabs, TypeScriptExample } from "~/components"; +To proxy requests across a fixed number of Container instances, select an instance name and forward the request through its Durable Object. + +`container.start()` returns before the container is ready. The Durable Object Container API example polls `GET /health` on port `8080` before forwarding requests. The container must return a successful response only when it is ready to receive traffic. + + + + + +```ts +import { DurableObject } from "cloudflare:workers"; + +const INSTANCE_COUNT = 3; + +interface Env { + BACKEND: DurableObjectNamespace; +} + +export class Backend extends DurableObject { + private ready: Promise | undefined; + + async fetch(request: Request): Promise { + const container = this.ctx.container!; + if (!container.running) { + this.ready = undefined; + } + this.ready ??= this.startAndWaitForPort().catch((error: unknown) => { + this.ready = undefined; + throw error; + }); + await this.ready; + + const url = new URL(request.url); + url.protocol = "http:"; + url.host = "container"; + const forwarded = new Request(url, request); + forwarded.headers.delete("host"); + return container.getTcpPort(8080).fetch(forwarded); + } + + private async startAndWaitForPort(): Promise { + const container = this.ctx.container!; + await container.setInactivityTimeout(2 * 60 * 60 * 1000); + if (!container.running) { + container.start(); + } + + const port = container.getTcpPort(8080); + let lastError: unknown; + for (let attempt = 0; attempt < 100; attempt++) { + try { + const response = await port.fetch("http://container/health"); + if (!response.ok) { + throw new Error(`Health check returned ${response.status}`); + } + return; + } catch (error) { + lastError = error; + await scheduler.wait(200); + } + } + throw new Error("Container did not become ready on port 8080", { + cause: lastError, + }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const index = Math.floor(Math.random() * INSTANCE_COUNT); + return env.BACKEND.getByName(`instance-${index}`).fetch(request); + }, +}; +``` + + + + + + ```ts import { Container, getRandom } from "@cloudflare/containers"; const INSTANCE_COUNT = 3; -class Backend extends Container { +interface Env { + BACKEND: DurableObjectNamespace; +} + +export class Backend extends Container { defaultPort = 8080; sleepAfter = "2h"; } @@ -29,13 +112,13 @@ export default { }, }; ``` + -:::note -This example uses `getRandom`, which randomly selects one of a fixed number of Container -instances for each request. + + -In the future, we will provide improved latency-aware load balancing and autoscaling. +:::note +Both examples randomly select one of a fixed number of Container instances for each request. The `Container` class provides `getRandom()` as a helper. -This will make scaling stateless instances simple and routing more efficient. See the -[autoscaling documentation](/containers/configuration/scaling-and-routing) for more details. +See the [autoscaling documentation](/containers/configuration/scaling-and-routing/) for more details about scaling stateless instances and routing. ::: diff --git a/src/content/docs/containers/examples/status-hooks.mdx b/src/content/docs/containers/examples/status-hooks.mdx index 3d38fcd9e2b..27179779ded 100644 --- a/src/content/docs/containers/examples/status-hooks.mdx +++ b/src/content/docs/containers/examples/status-hooks.mdx @@ -11,11 +11,86 @@ products: - workers --- -When a Container starts, stops, becomes idle, and errors, it can trigger code execution in a Worker -that has defined status hooks on the `Container` class. Refer to the [Container class lifecycle hooks](/containers/api/container-class/#lifecycle-hooks) for more details. +import { TabItem, Tabs, TypeScriptExample } from "~/components"; +Use `monitor()` with the Durable Object Container API to run code after the Container exits or errors. The `Container` class adds named lifecycle hooks and an inactivity callback. + +The direct API example requires the Container to expose `GET /health` on port `4000`. The endpoint must return a successful response when the application is ready. Because `container.start()` does not wait for readiness, the Worker polls this endpoint before forwarding requests. + + + + + +```ts +import { DurableObject } from "cloudflare:workers"; + +interface Env {} + +export class MyContainer extends DurableObject { + private ready: Promise | undefined; + + async fetch(request: Request): Promise { + const container = this.ctx.container!; + if (!container.running) { + this.ready = undefined; + } + this.ready ??= this.startAndMonitor().catch((error: unknown) => { + this.ready = undefined; + throw error; + }); + await this.ready; + + const url = new URL(request.url); + url.protocol = "http:"; + url.host = "container"; + const forwarded = new Request(url, request); + forwarded.headers.delete("host"); + return container.getTcpPort(4000).fetch(forwarded); + } + + private async startAndMonitor(): Promise { + const container = this.ctx.container!; + await container.setInactivityTimeout(5 * 60 * 1000); + if (!container.running) { + container.start(); + } + + this.ctx.waitUntil( + container + .monitor() + .then(() => console.log("Container stopped")) + .catch((error: unknown) => console.error("Container error:", error)), + ); + + const port = container.getTcpPort(4000); + let lastError: unknown; + for (let attempt = 0; attempt < 100; attempt++) { + try { + const response = await port.fetch("http://container/health"); + if (!response.ok) { + throw new Error(`Health check returned ${response.status}`); + } + console.log("Container successfully started"); + return; + } catch (error) { + lastError = error; + await scheduler.wait(200); + } + } + throw new Error("Container did not become ready on port 4000", { + cause: lastError, + }); + } +} +``` + + + + + + ```ts -import { Container } from "@cloudflare/containers"; +import { Container, type StopParams } from "@cloudflare/containers"; export class MyContainer extends Container { defaultPort = 4000; @@ -25,7 +100,7 @@ export class MyContainer extends Container { console.log("Container successfully started"); } - override onStop(stopParams) { + override onStop(stopParams: StopParams) { if (stopParams.exitCode === 0) { console.log("Container stopped gracefully"); } else { @@ -40,8 +115,14 @@ export class MyContainer extends Container { await this.stop(); } - override onError(error: string) { + override onError(error: unknown) { console.log("Container error:", error); } } ``` + + + + + +The `monitor()` promise resolves without a value when the container exits successfully. For nonzero exits, it rejects with an error containing the exit code. The `setInactivityTimeout()` method does not invoke a callback when the timeout expires. Use the [`Container` class lifecycle hooks](/containers/api/container-class/#lifecycle-hooks) for structured stop parameters and an inactivity callback. diff --git a/src/content/docs/containers/examples/websocket.mdx b/src/content/docs/containers/examples/websocket.mdx index 66a5968d448..71d3619472b 100644 --- a/src/content/docs/containers/examples/websocket.mdx +++ b/src/content/docs/containers/examples/websocket.mdx @@ -10,24 +10,108 @@ products: - containers --- -WebSocket requests are automatically forwarded to a container using the default `fetch` -method on the `Container` class: +import { TabItem, Tabs, TypeScriptExample } from "~/components"; -```js +Forward an incoming WebSocket upgrade request through the Durable Object to the listening port on the Container. + +Because `container.start()` does not wait for readiness, this example polls `GET /health` before forwarding requests. Keep the WebSocket endpoint separate from the health check. + + + + + +```ts +import { DurableObject } from "cloudflare:workers"; + +interface Env { + MY_CONTAINER: DurableObjectNamespace; +} + +export class MyContainer extends DurableObject { + private ready: Promise | undefined; + + async fetch(request: Request): Promise { + const container = this.ctx.container!; + if (!container.running) { + this.ready = undefined; + } + this.ready ??= this.startAndWaitForPort().catch((error: unknown) => { + this.ready = undefined; + throw error; + }); + await this.ready; + + const url = new URL(request.url); + url.protocol = "http:"; + url.host = "container"; + const forwarded = new Request(url, request); + forwarded.headers.delete("host"); + return container.getTcpPort(8080).fetch(forwarded); + } + + private async startAndWaitForPort(): Promise { + const container = this.ctx.container!; + await container.setInactivityTimeout(2 * 60 * 1000); + if (!container.running) { + container.start(); + } + + const port = container.getTcpPort(8080); + let lastError: unknown; + for (let attempt = 0; attempt < 100; attempt++) { + try { + const response = await port.fetch("http://container/health"); + if (!response.ok) { + throw new Error(`Health check returned ${response.status}`); + } + return; + } catch (error) { + lastError = error; + await scheduler.wait(200); + } + } + throw new Error("Container did not become ready on port 8080", { + cause: lastError, + }); + } +} + +export default { + fetch(request: Request, env: Env): Promise { + return env.MY_CONTAINER.getByName("default").fetch(request); + }, +}; +``` + + + + + + +```ts import { Container, getContainer } from "@cloudflare/containers"; -export class MyContainer extends Container { +interface Env { + MY_CONTAINER: DurableObjectNamespace; +} + +export class MyContainer extends Container { defaultPort = 8080; sleepAfter = "2m"; } export default { - async fetch(request, env) { + async fetch(request: Request, env: Env): Promise { // gets default instance and forwards websocket from outside Worker return getContainer(env.MY_CONTAINER).fetch(request); }, }; ``` + View a full example in the [Container class repository](https://github.com/cloudflare/containers/tree/main/examples/websocket). + + + + {/* TODO: Add more advanced examples - like kicking off a WS request then passing messages to container from the WS */}