Skip to content

Latest commit

 

History

History
234 lines (161 loc) · 11 KB

File metadata and controls

234 lines (161 loc) · 11 KB
container

Cloudflare Containers for Lunora: defineContainer, generated Container DO classes, and the ctx.containers action surface


typescript-image FSL-1.1-Apache-2.0 licence npm version npm downloads PRs Welcome


Daniel Bannert's open source work is supported by the community on GitHub Sponsors


Cloudflare Containers for Lunora: defineContainer, generated Container Durable Object classes, and the ctx.containers action surface.

Part of the Lunora framework — a type-safe, real-time backend on Cloudflare Workers + Durable Objects with a Vite-first DX.

Install

npm install @lunora/container
yarn add @lunora/container
pnpm add @lunora/container

Usage

Declare containers in lunora/containers.ts:

import { defineContainer } from "@lunora/container";

export const transcoder = defineContainer({
    image: "./containers/transcoder", // dir with a Dockerfile, or { registry: "docker.io/acme/transcoder:1.4" }
    defaultPort: 8080,
    instanceType: "standard-1",
    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
});

Codegen emits the Container Durable Object class into _generated/containers.ts (re-export it from your worker entry) and wires a typed handle onto ActionCtx:

// lunora/transcode.ts — `action` and `v` come from your generated server module.
import { action, v } from "@/lunora/_generated/server";

export const transcode = action.input({ videoId: v.id("videos") }).action(async ({ args: { videoId }, ctx }) => {
    // one instance per entity (same id always routes to the same container)
    const res = await ctx.containers.transcoder.get(videoId).fetch("/transcode", { method: "POST" });

    // a random instance from a fixed pool, for stateless work
    const probe = await ctx.containers.transcoder.any().fetch("/healthz");

    // .pool() is like .any() but retries on another instance on a 5xx / thrown error
    const out = await ctx.containers.transcoder.pool({ attempts: 3 }).fetch("/transcode", { method: "POST" });

    return res.json();
});

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():

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.

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.

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.

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.

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:

import { createContainerBridge } from "@lunora/container/bridge";

const lunora = createContainerBridge({ baseUrl: process.env.LUNORA_URL!, token: process.env.LUNORA_TOKEN });

const pending = await lunora.query("jobs:listPending", { limit: 10 });
await lunora.mutation("jobs:markDone", { id: pending[0].id });

The token is a bearer your Worker's resolveIdentity recognizes — pass it to the container as a secret. Non-JS containers can POST /_lunora/rpc with { functionPath, args } directly.

Secure the bridge in resolveIdentity: read request.headers.get("authorization"), strip the Bearer prefix, and compare the token against a Worker secret (e.g. env.LUNORA_CONTAINER_TOKEN) you also forward to the container. Return a { userId } identity only on a match and null otherwise — an unrecognised request then runs anonymously and is rejected by your functions' own authorization checks. See Securing the bridge for the full example.

Entry points

  • @lunora/container — Node-safe: defineContainer, naming/normalization helpers, createContainerContext, and the Docker-free createContainerTestContext test double.
  • @lunora/container/do — workerd-only: the LunoraContainer base class the generated DO classes extend (pulls in @cloudflare/containers).
  • @lunora/container/bridge — runtime-agnostic: createContainerBridge for calling Lunora functions from inside a container.

This README covers the basics. For the full API, options, and guides, see the documentation.

Related

  • @lunora/server — defines the actions that drive containers via ctx.containers.
  • @lunora/config — reconciles the wrangler containers[] entry and Durable Object binding.
  • @lunora/runtime — the Worker runtime the bridge client calls back into.

Supported Node.js Versions

Libraries in this ecosystem make the best effort to track Node.js' release schedule. Here's a post on why we think this is important.

Contributing

If you would like to help take a look at the list of issues and check our Contributing guidelines.

Note: please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.

Credits

Made with ❤️ at Anolilab

This is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. Anolilab is a Development and AI Studio. Contact us at hello@anolilab.com if you need any help with these technologies or just want to say hi!

License

The Lunora container package is open-sourced software licensed under the FSL-1.1-Apache-2.0.