Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/readiness-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@cloudflare/containers': minor
---

Add user-defined readiness checks via a `readyOn` class attribute and `addReadinessCheck` / `setReadinessChecks` instance methods. Ships with `portResponding(port, { pingEndpoint? })` and `isHealthy(path, { port?, pingEndpoint? })` helpers; arbitrary async checks are supported via `(container) => Promise<unknown>`.

`portResponding` checks for `defaultPort` and every entry in `requiredPorts` are added automatically, so you don't need to list them explicitly when declaring `readyOn`. `addReadinessCheck` preserves the auto port checks; `setReadinessChecks` replaces everything (include port checks explicitly if you need them).
48 changes: 47 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,54 @@ Build output goes to `dist/`. Do not edit files in `dist/`.
**Starting a container:**

- `start(startOptions?, waitOptions?)` — starts without waiting for ports
- `startAndWaitForPorts(args)` — starts and polls until ports are ready
- `startAndWaitForPorts(args)` — starts and runs all readiness checks (defaults to port checks)
- `waitForPort(waitOptions)` — polls a single port; returns tries used
- `waitForPath(waitOptions & { path })` — polls an HTTP path until it returns 2xx

**Readiness checks:**

Readiness checks gate fetch proxying — every check must resolve before requests flow to the container. All checks run in parallel, so ordering doesn't matter.

Checks should retry internally on transient "not ready yet" conditions rather than rejecting. A rejection is terminal and causes the parent `fetch` to return a 500. Loop cooperatively against `options.signal` (which fires on timeout) and only reject when something is genuinely broken.

`portResponding` checks for `defaultPort` and every entry in `requiredPorts` are added automatically, so you don't need to list them explicitly:

```ts
import { Container, isHealthy } from '@cloudflare/containers';

class MyApp extends Container {
defaultPort = 8080;
// portResponding(8080) is added automatically
readyOn = [isHealthy('/health')];
}
```

Add checks at runtime with `addReadinessCheck` — auto port checks are still applied:

```ts
// Effective: [portResponding(8080), isHealthy('/ready')]
container.addReadinessCheck(isHealthy('/ready'));

container.addReadinessCheck(async () => {
await warmCachesFromR2();
});
```

`setReadinessChecks` takes full control: auto port checks are NOT added, so include them explicitly if you need them. Pass `[]` to opt out entirely.

```ts
import { portResponding, isHealthy } from '@cloudflare/containers';

// Replace everything — include port checks explicitly
container.setReadinessChecks([
portResponding(8080),
isHealthy('/ready'),
async () => { await migrateDatabase(); },
]);

// Opt out — ready as soon as the process starts
container.setReadinessChecks([]);
```

**HTTP methods:**

Expand Down
138 changes: 138 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A class for interacting with Containers on Cloudflare Workers.
- Simple container lifecycle management (starting and stopping containers)
- Event hooks for container lifecycle events (onStart, onStop, onError)
- Configurable sleep timeout that renews on requests
- Readiness checks that gate request proxying until the app is ready
- Load balancing utilities

## Installation
Expand Down Expand Up @@ -68,6 +69,12 @@ The `Container` class that extends a container-enbled Durable Object to provide

Array of ports that should be checked for availability during container startup. Used by `startAndWaitForPorts` when no specific ports are provided.

- `readyOn?: ReadinessCheck[]`

Readiness checks that must all resolve before fetch requests are proxied to the container. See [Readiness Checks](#readiness-checks) for details.

`portResponding` checks for `defaultPort` and every entry in `requiredPorts` are added automatically — you don't need to list them explicitly.

- `sleepAfter`

How long to keep the container alive without activity (format: number for seconds, or string like "5m", "30s", "1h").
Expand Down Expand Up @@ -262,6 +269,22 @@ See [this example](#http-example-with-lifecycle-hooks).

Manually renews the container activity timeout (extends container lifetime).

- `addReadinessCheck(check: ReadinessCheck)`

Appends a readiness check at runtime. Auto `portResponding` checks for `defaultPort` / `requiredPorts` are preserved.

- `setReadinessChecks(checks: ReadinessCheck[])`

Replaces the readiness check list. Auto port checks are **not** added — include them explicitly if you need them. Pass `[]` to opt out entirely.

- `waitForPort(options: WaitOptions): Promise<number>`

Polls a TCP port until it accepts an HTTP connection. Used by `portResponding`.

- `waitForPath(options: WaitOptions & { path: string; pingEndpoint?: string }): Promise<number>`

Polls an HTTP path until it returns a 2xx response. Used by `isHealthy`. The Host header defaults to the host portion of the container's `pingEndpoint`; pass `pingEndpoint` in the options to override for this call only.

##### Outbound Interception

Use outbound interception when you want to control what the container can reach, or proxy outbound requests through Worker code.
Expand Down Expand Up @@ -356,6 +379,14 @@ Processing order (first match wins):

If no name is provided, "cf-singleton-container" is used.

- `portResponding(port: number, options?: { pingEndpoint?: string }): ReadinessCheck`

Readiness check factory that waits for the given port to start accepting HTTP connections. Pass `pingEndpoint` to override the probe endpoint for this check only. See [Readiness Checks](#readiness-checks).

- `isHealthy(path: string, options?: { port?: number; pingEndpoint?: string }): ReadinessCheck`

Readiness check factory that polls an HTTP path until it returns 2xx. Falls back to `defaultPort` when `port` is omitted; falls back to the host portion of the container's `pingEndpoint` when `pingEndpoint` is omitted. See [Readiness Checks](#readiness-checks).

## Examples

### HTTP Example with Lifecycle Hooks
Expand Down Expand Up @@ -408,6 +439,113 @@ export class MyContainer extends Container {
}
```

### Readiness Checks

Readiness checks gate fetch proxying — every check must resolve before requests flow to the container. Use them to wait on health endpoints, warmup work, migrations, or anything else that has to finish before traffic is served.

Checks run in parallel, so ordering doesn't matter. If any check rejects, the container is not considered ready and `fetch` / `containerFetch` returns a 500.

**Auto port checks.** A `portResponding` check is added automatically for `defaultPort` and every entry in `requiredPorts`, so you don't need to list them in `readyOn`:

```typescript
import { Container, isHealthy } from '@cloudflare/containers';

export class MyContainer extends Container {
defaultPort = 8080;

// portResponding(8080) is added automatically.
// Effective checks: [portResponding(8080), isHealthy('/health')]
readyOn = [isHealthy('/health')];
}
```

**Custom readiness checks.** A readiness check is a function with this signature:

```typescript
type ReadinessCheck = (
container: Container,
options?: { signal?: AbortSignal }
) => Promise<unknown>;
```

The check receives two arguments:

- **`container`** — the `Container` instance itself. Use it to call `waitForPath`, `waitForPort`, read instance config like `defaultPort`, or access `container.env` for bindings.
- **`options.signal`** — an `AbortSignal` that fires if the caller aborts or the readiness timeout elapses. Long-running checks should honour it (pass it to `fetch`, `waitForPath`, etc.) so they cancel cleanly; checks that ignore it may keep running in the background after a timeout.

> **Don't reject on "not ready yet" — retry inside the check.** A rejection is terminal: the whole readiness gate rejects and the parent `fetch` / `containerFetch` returns a 500. If the condition you're waiting on is transient (upstream not up yet, file not written yet, etc.), loop with a short sleep until it's true or `options.signal` fires. The signal fires when the overall readiness timeout elapses, so cooperative loops are bounded. Only reject when something is genuinely broken or the signal aborted.

Custom checks typically do one of three things: wait on an external dependency, run inline warmup, or poll the container's own HTTP surface (use `container.waitForPath` or `container.waitForPort` rather than `containerFetch`, which itself waits for readiness and would recurse).

```typescript
import { Container, isHealthy, type ReadinessCheck } from '@cloudflare/containers';

// Example 1: wait for an external dependency. The check loops until the
// upstream returns 2xx OR the signal aborts — it does not reject on a
// transient bad response.
const upstreamReady: ReadinessCheck = async (_container, { signal } = {}) => {
while (!signal?.aborted) {
try {
const response = await fetch('https://api.example.com/ping', { signal });
if (response.ok) return;
} catch {
// connection error — try again
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error('upstream did not become ready before readiness timed out');
};

// Example 2: poll a container endpoint. `container.waitForPath` already
// retries internally until 2xx or the retry budget is exhausted, so this
// check doesn't need its own loop. It rejects only if the endpoint never
// becomes healthy within the budget — which is the correct behaviour.
const modelsLoaded: ReadinessCheck = async (container, { signal } = {}) => {
await container.waitForPath({ path: '/models', portToCheck: 8080, signal });
};

// Example 3: inline warmup. This is one-shot work (not a condition to
// poll) so rejecting on genuine failure is fine — the container really
// isn't ready if warmup failed.
const warmCaches: ReadinessCheck = async () => {
await warmCachesFromR2();
};

export class InferenceContainer extends Container {
defaultPort = 8080;

readyOn = [isHealthy('/health'), upstreamReady, modelsLoaded, warmCaches];
}
```

**Adding checks at runtime.** `addReadinessCheck` appends to the list. Auto port checks are preserved.

```typescript
// defaultPort = 8080, no `readyOn` declared on the class.
// Effective after this call: [portResponding(8080), isHealthy('/ready')]
container.addReadinessCheck(isHealthy('/ready'));

container.addReadinessCheck(async () => {
await seedDatabase();
});
```

**Replacing the list.** `setReadinessChecks` takes full control — auto port checks are **not** added, so include them explicitly if you want them. Pass `[]` to opt out of readiness checking entirely.

```typescript
import { portResponding, isHealthy } from '@cloudflare/containers';

// Replace everything. Port checks are NOT auto-added.
container.setReadinessChecks([
portResponding(8080),
isHealthy('/ready'),
async () => { await migrateDatabase(); },
]);

// Opt out — ready as soon as the process starts.
container.setReadinessChecks([]);
```

### WebSocket Support

The Container class automatically supports proxying WebSocket connections to your container. WebSocket connections are bi-directionally proxied, with messages forwarded in both directions. The Container also automatically renews the activity timeout when WebSocket messages are sent or received.
Expand Down
13 changes: 13 additions & 0 deletions examples/core-tests/container_src/server.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { createServer } from 'http';

// Time the health endpoint starts returning 2xx. Lets tests exercise
// readiness checks that need to wait for app-level warmup — not just the
// port binding.
const HEALTHY_AFTER_MS = Number(process.env.HEALTHY_AFTER_MS ?? '0');
const startupTime = Date.now();

const server = createServer(function (req, res) {
if (req.url?.startsWith('/containerFetchNoContent')) {
res.writeHead(204);
res.end();
return;
}

if (req.url === '/health') {
const ready = Date.now() - startupTime >= HEALTHY_AFTER_MS;
res.writeHead(ready ? 200 : 503, { 'Content-Type': 'text/plain' });
res.end(ready ? 'ok' : 'warming up');
return;
}

if (req.url === '/error') {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal server error');
Expand Down
48 changes: 46 additions & 2 deletions examples/core-tests/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Container } from '../../../src/lib/container';
import { Container, isHealthy } from '../../../src/lib/container';
import { getContainer, switchPort } from '../../../src/lib/utils';

/**
Expand Down Expand Up @@ -34,10 +34,40 @@ export class TestContainer extends Container {
}
}

/**
* Container with a `readyOn` list to exercise readiness checks. The
* container's /health endpoint only starts returning 2xx after
* HEALTHY_AFTER_MS has elapsed, so `isHealthy` must poll.
*
* Note: `portResponding(8080)` is added automatically because
* `defaultPort = 8080` — we only declare the path check here.
*/
export class ReadyOnContainer extends Container {
defaultPort = 8080;
sleepAfter = '3m';
readyOn = [isHealthy('/health')];

constructor(ctx: any, env: any) {
super(ctx, env);
this.envVars = {
MESSAGE: 'ready on container',
HEALTHY_AFTER_MS: '1500',
};
this.entrypoint = ['node', 'server.js'];
}

override async onStart(): Promise<void> {
console.log('readyOn onStart hook called');
}
}

export default {
async fetch(
request: Request,
env: { CONTAINER: DurableObjectNamespace<TestContainer> }
env: {
CONTAINER: DurableObjectNamespace<TestContainer>;
READY_ON_CONTAINER: DurableObjectNamespace<ReadyOnContainer>;
}
): Promise<Response> {
const url = new URL(request.url);
// get a new container instance per request
Expand Down Expand Up @@ -84,6 +114,20 @@ export default {
await container.stop();
return new Response('Container stopping');
}

if (url.pathname === '/readyOn/fetch') {
const readyOn = getContainer(env.READY_ON_CONTAINER, id);
console.log('Handling readyOn http fetch request');
const response = await readyOn.fetch(request);
const body = await response.text();
return new Response(
JSON.stringify({
status: response.status,
body,
})
);
}

return new Response('Not Found');
},
};
34 changes: 34 additions & 0 deletions examples/core-tests/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
async () => {
const res = await fetch(`${url}/fetch?id=${id}`);
if (res.status !== 200) {
throw new Error(`Expected status 200, got ${res.status}`);

Check failure on line 16 in examples/core-tests/test/index.test.ts

View workflow job for this annotation

GitHub Actions / tests

test/index.test.ts > core functionality > local > http fetch (and onStart and onStop hooks)

Error: Expected status 200, got 500 ❯ vi.waitFor.timeout test/index.test.ts:16:19
}
return res;
},
Expand Down Expand Up @@ -42,7 +42,7 @@
const res = await fetch(`${url}/containerFetch?id=${id}`);
if (res.status !== 200) {
console.log(await res.text());
throw new Error(`Expected status 200, got ${res.status}`);

Check failure on line 45 in examples/core-tests/test/index.test.ts

View workflow job for this annotation

GitHub Actions / tests

test/index.test.ts > core functionality > local > containerFetch

Error: Expected status 200, got 500 ❯ vi.waitFor.timeout test/index.test.ts:45:19
}
return res;
},
Expand All @@ -68,7 +68,7 @@
async () => {
const res = await fetch(`${url}/containerFetchNoContent?id=${id}`);
if (res.status !== 204) {
throw new Error(`Expected status 204, got ${res.status}. Body: ${await res.text()}`);

Check failure on line 71 in examples/core-tests/test/index.test.ts

View workflow job for this annotation

GitHub Actions / tests

test/index.test.ts > core functionality > local > containerFetch preserves 204 no content responses

Error: Expected status 204, got 500. Body: Failed to start container: Container failed to start ❯ vi.waitFor.timeout test/index.test.ts:71:21
}
return res;
},
Expand All @@ -91,7 +91,7 @@
const response = await fetch(`${url}/fetch?id=${id}`);
const responseText = await response.text();

expect(responseText).toBe(

Check failure on line 94 in examples/core-tests/test/index.test.ts

View workflow job for this annotation

GitHub Actions / tests

test/index.test.ts > core functionality > local > startAndWaitForPorts

AssertionError: expected 'Failed to start container: Container …' to be 'Hello from test container! process.en…' // Object.is equality - Expected + Received - Hello from test container! process.env.MESSAGE: start with startAndWaitForPorts + Failed to start container: Container failed to start ❯ test/index.test.ts:94:28
'Hello from test container! process.env.MESSAGE: start with startAndWaitForPorts'
);
await runner.destroy([id]);
Expand All @@ -113,7 +113,7 @@
const response = await fetch(`${url}/fetch?id=${id}`);
const responseText = await response.text();

expect(responseText).toBe('Hello from test container! process.env.MESSAGE: start with start');

Check failure on line 116 in examples/core-tests/test/index.test.ts

View workflow job for this annotation

GitHub Actions / tests

test/index.test.ts > core functionality > local > start

AssertionError: expected 'Failed to start container: Container …' to be 'Hello from test container! process.en…' // Object.is equality - Expected + Received - Hello from test container! process.env.MESSAGE: start with start + Failed to start container: Container failed to start ❯ test/index.test.ts:116:28

await runner.destroy([id]);
const output = runner.getStdout();
Expand All @@ -131,6 +131,40 @@
expect(fetchRequestIndex).toBeLessThan(secondOnStartIndex);
});

test('readyOn waits for pathHealthy before proxying', async () => {
const runner = new WranglerDevRunner();

const url = await runner.getUrl();
const id = randomUUID();

try {
const response = await vi.waitFor(
async () => {
const res = await fetch(`${url}/readyOn/fetch?id=${id}`);
if (res.status !== 200) {
console.log(await res.text());
throw new Error(`Expected status 200, got ${res.status}`);
}
return res;
},
{ timeout: 15000 }
);

const payload = (await response.json()) as { status: number; body: string };
// If the readiness check passed, the container responded with its
// normal message — not the 503 "warming up" response from /health.
expect(payload.status).toBe(200);
expect(payload.body).toBe(
'Hello from test container! process.env.MESSAGE: ready on container'
);
} finally {
await runner.destroy([id]);
}

const output = runner.getStdout();
expect(output).toMatch(/readyOn onStart hook called/);
});

test('stop', async () => {
const runner = new WranglerDevRunner();

Expand All @@ -142,7 +176,7 @@
const response = await fetch(`${url}/fetch?id=${id}`);
const responseText = await response.text();

expect(responseText).toBe('Hello from test container! process.env.MESSAGE: start with start');

Check failure on line 179 in examples/core-tests/test/index.test.ts

View workflow job for this annotation

GitHub Actions / tests

test/index.test.ts > core functionality > local > stop

AssertionError: expected 'Failed to start container: Container …' to be 'Hello from test container! process.en…' // Object.is equality - Expected + Received - Hello from test container! process.env.MESSAGE: start with start + Failed to start container: Container failed to start ❯ test/index.test.ts:179:28
let statusReq = await fetch(`${url}/status?id=${id}`);
let status = await statusReq.json();
expect(status.status).toBe('healthy');
Expand Down
Loading
Loading