Skip to content
Merged
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
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
91 changes: 91 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,97 @@ 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("rejects a non-boolean interceptHttps", () => {
expect.assertions(1);

expect(() => defineContainer({ image: "./app", interceptHttps: "yes" as unknown as boolean })).toThrow("`interceptHttps` must be a boolean");
});

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(4);

expect(() => defineContainer({ image: "./app", readyOn: [{ path: " " }] })).toThrow("`readyOn[].path`");
expect(() => defineContainer({ image: "./app", readyOn: [{ path: " /ready " }] })).toThrow("leading or trailing whitespace");
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