Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 3 additions & 2 deletions packages/codegen/__tests__/discover-containers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,13 @@ describe("emit (containers)", () => {
};

it("emitContainers renders one thin DO class per definition", () => {
expect.assertions(5);
expect.assertions(6);

const content = emitContainers(discover());

expect(content).toContain('import LunoraContainer from "@lunora/container/do";');
expect(content).toContain('import { LunoraContainer } from "@lunora/container/do";');
expect(content).toContain('import { transcoder } from "../containers.js";');
expect(content).toContain('export { ContainerProxy } from "@lunora/container/do";');
expect(content).toContain("export class TranscoderContainer extends LunoraContainer {");
expect(content).toContain('super(ctx, env, transcoder, "transcoder");');
expect(content).toContain("Re-export them from your worker entry");
Expand Down
9 changes: 8 additions & 1 deletion packages/codegen/src/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2040,11 +2040,18 @@ export class ${container.className} extends LunoraContainer {
* requires each \`containers[].class_name\` to be exported by the worker:
*
* \`export * from "./lunora/_generated/containers.js";\`
*
* \`ContainerProxy\` is re-exported alongside them: the egress-interception path
* (\`allowedHosts\`/\`deniedHosts\`/\`interceptHttps\` and the runtime
* \`handle.egress\` controls) routes container outbound traffic through this
* WorkerEntrypoint, so it too must be exported by the deployed worker.
*/
import LunoraContainer from "@lunora/container/do";
import { LunoraContainer } from "@lunora/container/do";

import { ${imports} } from "../containers.js";

export { ContainerProxy } from "@lunora/container/do";

${classes}`;
};

Expand Down
74 changes: 73 additions & 1 deletion packages/container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const transcoder = defineContainer({
maxInstances: 5,
sleepAfter: "5m",
secrets: ["TRANSCODER_API_KEY"], // forwarded from Worker secrets / .dev.vars
labels: { team: "media" }, // metadata attached to every instance for metrics/observability
});
```

Expand All @@ -89,10 +90,81 @@ export const transcode = action.input({ videoId: v.id("videos") }).action(async
});
```

`ctx.containers` is action-only (container calls are external I/O, like `ctx.fetch`); `.get(name)` handles also expose `start`/`stop`/`destroy`/`getState` lifecycle control.
`ctx.containers` is action-only (container calls are external I/O, like `ctx.fetch`); `.get(name)` handles also expose `start`/`stop`/`destroy`/`getState` lifecycle control plus `renewActivityTimeout()` (keep a busy WebSocket's container awake) and `egress.*` (adjust the allow/deny lists at runtime).

The config layer (`lunora dev` / `lunora deploy`) reconciles the wrangler `containers[]` entry, the `CONTAINER_*` Durable Object binding, and the SQLite-class migration automatically; `wrangler deploy` builds the Dockerfile with local Docker and pushes it to the Cloudflare Registry.

### Multi-port containers

Declare every port the container must be listening on with `requiredPorts` (start-up waits for all of them); `defaultPort` is the target when a request doesn't pick one. Route a single request to another port with `.port(n)` — it composes with `.get()`, `.any()`, and `.pool()`:

```ts
export const app = defineContainer({
image: "./containers/app",
defaultPort: 8080,
requiredPorts: [8080, 9090], // app + admin
});

// in an action:
await ctx.containers.app.get(tenantId).fetch("/work"); // → 8080
await ctx.containers.app.get(tenantId).port(9090).fetch("/admin"); // → 9090
```

### Build-time args

`env` and `secrets` are runtime values; for build-time `docker build --build-arg` values (wrangler `image_vars`, exposed to the Dockerfile as `ARG`) use `buildArgs`. They apply only to an image Lunora builds and are ignored for a pre-built `{ registry }` image.

```ts
export const worker = defineContainer({
image: "./containers/worker",
buildArgs: { NODE_VERSION: "22", BUILD_TARGET: "production" },
});
```

### Egress firewall

Pair `enableInternet: false` with an `allowedHosts` allow-list (or layer a `deniedHosts` deny-list that overrides everything) to constrain a container's outbound traffic; `interceptHttps: true` extends the lists to TLS connections (the image must trust the Cloudflare CA). Codegen re-exports the `ContainerProxy` worker entrypoint the interception path needs automatically.

```ts
export const fetcher = defineContainer({
image: "./containers/fetcher",
enableInternet: false,
allowedHosts: ["*.stripe.com", "api.github.com"],
deniedHosts: ["*.evil.com"],
});

// tighten or relax one running instance at runtime:
await ctx.containers.fetcher.get(tenantId).egress.allow("hooks.slack.com");
```

For advanced egress rewriting in worker code, `@lunora/container/do` re-exports Cloudflare's custom outbound-handler types (`OutboundHandler`, `OutboundHandlers`, `outboundParams`) — wire them onto a hand-authored `LunoraContainer` subclass to inject auth, route, or mock a container's outbound calls.

### Readiness gating

The platform health check waits for an open port, not necessarily a _ready_ app. `readyOn` adds application-level probes that gate request proxying: a `ctx.containers.<name>` fetch holds until every probe responds with its expected status, so callers never hit a container still applying migrations or warming caches. Probes are declarative data (path + optional `port`/`status`), run in parallel at start, and probe the container's TCP port directly.

```ts
export const api = defineContainer({
image: "./containers/api",
defaultPort: 8080,
readyOn: [
{ path: "/ready" }, // expect 200 on defaultPort
{ path: "/live", port: 9090, status: 204 }, // own port + expected status
],
});
```

### Hard timeout

`sleepAfter` caps _idle_ time; `hardTimeout` caps _total_ lifetime — a runaway-cost backstop measured from start, regardless of activity (same grammar as `sleepAfter`). When it elapses the generated class's `onHardTimeoutExpired` hook runs (default: `stop()`); the timer is run-generation-stamped so a stale timer from a slept/crashed run can't kill a fresh one.

```ts
export const job = defineContainer({
image: "./containers/job",
hardTimeout: "1h", // never run longer than an hour, busy or not
});
```

### Calling Lunora from inside a container

Container code calls back into your app's functions with the bridge client (any JS runtime), over the Worker's HTTP RPC endpoint:
Expand Down
69 changes: 69 additions & 0 deletions packages/container/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,75 @@ describe("ctx.containers.<name>.get() lifecycle controls", () => {

await expect(handle.stop()).rejects.toThrow("does not expose stop()");
});

it("forwards renewActivityTimeout and the egress controls to the DO with the right args", async () => {
expect.assertions(2);

const calls: { arg: unknown; method: string }[] = [];
const recordVoid =
(method: string) =>
async (arg?: unknown): Promise<void> => {
calls.push({ arg, method });
};
const egressNamespace: ContainerNamespaceLike = {
get: () => {
return {
allowHost: recordVoid("allowHost"),
denyHost: recordVoid("denyHost"),
fetch: async () => new Response("ok"),
removeAllowedHost: recordVoid("removeAllowedHost"),
removeDeniedHost: recordVoid("removeDeniedHost"),
renewActivityTimeout: recordVoid("renewActivityTimeout"),
setAllowedHosts: recordVoid("setAllowedHosts"),
setDeniedHosts: recordVoid("setDeniedHosts"),
};
},
idFromName: (name) => name,
};

const handle = createContainerContext({ CONTAINER_TRANSCODER: egressNamespace }, [
{ binding: "CONTAINER_TRANSCODER", exportName: "transcoder" },
]).transcoder!.get("video-1");

await handle.renewActivityTimeout();
await handle.egress.allow("api.stripe.com");
await handle.egress.deny("evil.com");
await handle.egress.setAllowed(["a.com", "b.com"]);
await handle.egress.setDenied(["c.com"]);
await handle.egress.removeAllowed("a.com");
await handle.egress.removeDenied("c.com");

expect(calls.map((call) => call.method)).toStrictEqual([
"renewActivityTimeout",
"allowHost",
"denyHost",
"setAllowedHosts",
"setDeniedHosts",
"removeAllowedHost",
"removeDeniedHost",
]);
// ReadonlyArray args are copied to a fresh mutable array before the RPC.
expect(calls.find((call) => call.method === "setAllowedHosts")!.arg).toStrictEqual(["a.com", "b.com"]);
});

it("routes .port(n) requests with the cf-container-target-port header across get/any/pool", async () => {
expect.assertions(4);

const { namespace, requests } = fakeNamespace();
const containers = createContainerContext({ CONTAINER_TRANSCODER: namespace }, [
{ binding: "CONTAINER_TRANSCODER", exportName: "transcoder", maxInstances: 3 },
]);

await containers.transcoder!.get("video-1").port(9090).fetch("/admin");
await containers.transcoder!.any().port(7000).fetch("/admin");
await containers.transcoder!.pool().port(6000).fetch("/admin");
await containers.transcoder!.get("video-1").fetch("/no-port");

expect(requests[0]!.headers.get("cf-container-target-port")).toBe("9090");
expect(requests[1]!.headers.get("cf-container-target-port")).toBe("7000");
expect(requests[2]!.headers.get("cf-container-target-port")).toBe("6000");
expect(requests[3]!.headers.get("cf-container-target-port")).toBeNull();
});
});

/** A namespace whose every `fetch` runs the next scripted step (response or throw). */
Expand Down
84 changes: 84 additions & 0 deletions packages/container/__tests__/define-container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,90 @@ describe(defineContainer, () => {
expect(() => defineContainer({ image: { build: "" } })).toThrow("`image.build` must be a non-empty");
});

it("accepts multi-port, egress-firewall, and labels config", () => {
expect.assertions(7);

const definition = defineContainer({
allowedHosts: ["*.stripe.com"],
deniedHosts: ["*.evil.com"],
entrypoint: ["node", "server.js"],
image: "./app",
interceptHttps: true,
labels: { env: "prod", tenant: "acme" },
pingEndpoint: "/healthz",
requiredPorts: [8080, 9090],
});

expect(definition.requiredPorts).toStrictEqual([8080, 9090]);
expect(definition.entrypoint).toStrictEqual(["node", "server.js"]);
expect(definition.interceptHttps).toBe(true);
expect(definition.allowedHosts).toStrictEqual(["*.stripe.com"]);
expect(definition.deniedHosts).toStrictEqual(["*.evil.com"]);
expect(definition.pingEndpoint).toBe("/healthz");
expect(definition.labels).toStrictEqual({ env: "prod", tenant: "acme" });
});

it("rejects a blank entrypoint part, hostname, or label key", () => {
expect.assertions(3);

expect(() => defineContainer({ entrypoint: ["node", " "], image: "./app" })).toThrow("`entrypoint` must be a non-empty");
expect(() => defineContainer({ allowedHosts: [" "], image: "./app" })).toThrow("`allowedHosts` must be an array of non-empty");
expect(() => defineContainer({ image: "./app", labels: { " ": "x" } })).toThrow("`labels` must be a record of non-empty");
});

it("accepts hardTimeout and readyOn config", () => {
expect.assertions(3);

const definition = defineContainer({
defaultPort: 8080,
hardTimeout: "1h",
image: "./app",
readyOn: [{ path: "/ready" }, { path: "migrations", port: 9090, status: 204 }],
});

expect(definition.hardTimeout).toBe("1h");
expect(definition.readyOn).toStrictEqual([{ path: "/ready" }, { path: "migrations", port: 9090, status: 204 }]);
expect(defineContainer({ hardTimeout: 600, image: "./app" }).hardTimeout).toBe(600);
});

it("rejects an invalid hardTimeout", () => {
expect.assertions(3);

expect(() => defineContainer({ hardTimeout: "5 minutes", image: "./app" })).toThrow("`hardTimeout`");
expect(() => defineContainer({ hardTimeout: 0, image: "./app" })).toThrow("`hardTimeout`");
expect(() => defineContainer({ hardTimeout: -5, image: "./app" })).toThrow("`hardTimeout`");
});

it("rejects an invalid readyOn check", () => {
expect.assertions(3);

expect(() => defineContainer({ image: "./app", readyOn: [{ path: " " }] })).toThrow("`readyOn[].path`");
expect(() => defineContainer({ image: "./app", readyOn: [{ path: "/ready", port: 70_000 }] })).toThrow("readyOn[].port");
expect(() => defineContainer({ image: "./app", readyOn: [{ path: "/ready", status: 700 }] })).toThrow("`readyOn[].status`");
});

it("rejects an empty or out-of-range requiredPorts", () => {
expect.assertions(2);

expect(() => defineContainer({ image: "./app", requiredPorts: [] })).toThrow("`requiredPorts` must be a non-empty");
expect(() => defineContainer({ image: "./app", requiredPorts: [70_000] })).toThrow("requiredPorts[]");
});

it("rejects an empty entrypoint and an empty-string entrypoint part", () => {
expect.assertions(2);

expect(() => defineContainer({ entrypoint: [], image: "./app" })).toThrow("`entrypoint` must be a non-empty");
expect(() => defineContainer({ entrypoint: ["node", ""], image: "./app" })).toThrow("`entrypoint` must be a non-empty");
});

it("rejects an empty hostname in an egress list and an empty pingEndpoint", () => {
expect.assertions(3);

expect(() => defineContainer({ allowedHosts: [""], image: "./app" })).toThrow("`allowedHosts` must be an array of non-empty");
expect(() => defineContainer({ deniedHosts: [""], image: "./app" })).toThrow("`deniedHosts` must be an array of non-empty");
expect(() => defineContainer({ image: "./app", pingEndpoint: "" })).toThrow("`pingEndpoint` must be a non-empty");
});

it("does not brand arbitrary objects", () => {
expect.assertions(2);

Expand Down
Loading
Loading