@lunora/container 1.0.0-alpha.3 → 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,7 @@ export const transcoder = defineContainer({
66
66
  maxInstances: 5,
67
67
  sleepAfter: "5m",
68
68
  secrets: ["TRANSCODER_API_KEY"], // forwarded from Worker secrets / .dev.vars
69
+ labels: { team: "media" }, // metadata attached to every instance for metrics/observability
69
70
  });
70
71
  ```
71
72
 
@@ -89,10 +90,81 @@ export const transcode = action.input({ videoId: v.id("videos") }).action(async
89
90
  });
90
91
  ```
91
92
 
92
- `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.
93
+ `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).
93
94
 
94
95
  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.
95
96
 
97
+ ### Multi-port containers
98
+
99
+ 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()`:
100
+
101
+ ```ts
102
+ export const app = defineContainer({
103
+ image: "./containers/app",
104
+ defaultPort: 8080,
105
+ requiredPorts: [8080, 9090], // app + admin
106
+ });
107
+
108
+ // in an action:
109
+ await ctx.containers.app.get(tenantId).fetch("/work"); // → 8080
110
+ await ctx.containers.app.get(tenantId).port(9090).fetch("/admin"); // → 9090
111
+ ```
112
+
113
+ ### Build-time args
114
+
115
+ `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.
116
+
117
+ ```ts
118
+ export const worker = defineContainer({
119
+ image: "./containers/worker",
120
+ buildArgs: { NODE_VERSION: "22", BUILD_TARGET: "production" },
121
+ });
122
+ ```
123
+
124
+ ### Egress firewall
125
+
126
+ 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.
127
+
128
+ ```ts
129
+ export const fetcher = defineContainer({
130
+ image: "./containers/fetcher",
131
+ enableInternet: false,
132
+ allowedHosts: ["*.stripe.com", "api.github.com"],
133
+ deniedHosts: ["*.evil.com"],
134
+ });
135
+
136
+ // tighten or relax one running instance at runtime:
137
+ await ctx.containers.fetcher.get(tenantId).egress.allow("hooks.slack.com");
138
+ ```
139
+
140
+ 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.
141
+
142
+ ### Readiness gating
143
+
144
+ 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.
145
+
146
+ ```ts
147
+ export const api = defineContainer({
148
+ image: "./containers/api",
149
+ defaultPort: 8080,
150
+ readyOn: [
151
+ { path: "/ready" }, // expect 200 on defaultPort
152
+ { path: "/live", port: 9090, status: 204 }, // own port + expected status
153
+ ],
154
+ });
155
+ ```
156
+
157
+ ### Hard timeout
158
+
159
+ `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.
160
+
161
+ ```ts
162
+ export const job = defineContainer({
163
+ image: "./containers/job",
164
+ hardTimeout: "1h", // never run longer than an hour, busy or not
165
+ });
166
+ ```
167
+
96
168
  ### Calling Lunora from inside a container
97
169
 
98
170
  Container code calls back into your app's functions with the bridge client (any JS runtime), over the Worker's HTTP RPC endpoint:
@@ -1,5 +1,6 @@
1
1
  import { Container, StopParams } from '@cloudflare/containers';
2
- import { C as ContainerDefinition } from "../packem_shared/types.d-D2l2SYol.mjs";
2
+ export { ContainerProxy, type OutboundHandler, type OutboundHandlerContext, type OutboundHandlerParams, type OutboundHandlerParamsOf, type OutboundHandlers, outboundParams } from '@cloudflare/containers';
3
+ import { C as ContainerDefinition } from "../packem_shared/types.d-DAOLRiZQ.mjs";
3
4
  /**
4
5
  * Cloudflare Durable Object data-residency jurisdiction. Widening union —
5
6
  * Cloudflare adds values over time.
@@ -41,11 +42,44 @@ declare class LunoraContainer<Env = unknown> extends Container<Env> {
41
42
  private readonly lunoraJurisdiction?;
42
43
  /** The `lunora/containers.ts` export name, for lifecycle log correlation. */
43
44
  private readonly lunoraName;
45
+ /** Default port the readiness probes target when a check omits its own `port`. */
46
+ private readonly lunoraDefaultPort?;
47
+ /** Hard-cap lifetime in whole seconds (from the `hardTimeout` config), or `undefined`. */
48
+ private readonly lunoraHardTimeoutSeconds?;
49
+ /** Declarative readiness probes that gate request proxying (from the `readyOn` config). */
50
+ private readonly lunoraReadyOn;
44
51
  constructor(context: DurableObjectContext, env: Env, definition: ContainerDefinition, exportName?: string, jurisdiction?: DurableObjectJurisdiction);
52
+ override onActivityExpired(): Promise<void>;
45
53
  override onError(error: unknown): unknown;
46
54
  override onStart(): Promise<void>;
55
+ /**
56
+ * Hook run when the container's `hardTimeout` elapses (dispatched by the base
57
+ * scheduler via the run-generation-stamped schedule armed in
58
+ * {@link onStart}). Default: stop the instance. Override to drain/checkpoint
59
+ * first. A stale schedule from a previous run, or an already-stopped
60
+ * instance, is ignored (upstream cloudflare/containers#85).
61
+ */
62
+ onHardTimeoutExpired(payload?: {
63
+ generation?: number;
64
+ }): Promise<void>;
47
65
  override onStop(parameters: StopParams): Promise<void>;
48
66
  /**
67
+ * Arm the hard-timeout kill via the base scheduler (so it integrates with
68
+ * the container's own alarm machinery instead of fighting it). Bumps the run
69
+ * generation and stamps the schedule with it, so {@link onHardTimeoutExpired}
70
+ * can tell a fresh schedule from a stale one. No-op without a `hardTimeout`.
71
+ */
72
+ private armHardTimeout;
73
+ /**
74
+ * Block until every `readyOn` probe responds with its expected status, or
75
+ * throw once the readiness budget is spent. Probes run in parallel and hit
76
+ * the container's TCP port directly (NOT `containerFetch`, which would
77
+ * recurse back into the start path). No-op without `readyOn`.
78
+ */
79
+ private awaitContainerReadiness;
80
+ /** Poll one readiness probe until it returns its expected status or the shared deadline passes. */
81
+ private awaitReadinessCheck;
82
+ /**
49
83
  * Best-effort push of `envelope` into the root ShardDO's log buffer so it
50
84
  * also appears in the Studio Logs panel (the terminal already has it via
51
85
  * `emitContainerLifecycle`). Fire-and-forget and fully swallowed: a missing
@@ -60,4 +94,4 @@ declare class LunoraContainer<Env = unknown> extends Container<Env> {
60
94
  */
61
95
  private instanceId;
62
96
  }
63
- export { LunoraContainer as default };
97
+ export { LunoraContainer };
@@ -1,5 +1,6 @@
1
1
  import { Container, StopParams } from '@cloudflare/containers';
2
- import { C as ContainerDefinition } from "../packem_shared/types.d-D2l2SYol.js";
2
+ export { ContainerProxy, type OutboundHandler, type OutboundHandlerContext, type OutboundHandlerParams, type OutboundHandlerParamsOf, type OutboundHandlers, outboundParams } from '@cloudflare/containers';
3
+ import { C as ContainerDefinition } from "../packem_shared/types.d-DAOLRiZQ.js";
3
4
  /**
4
5
  * Cloudflare Durable Object data-residency jurisdiction. Widening union —
5
6
  * Cloudflare adds values over time.
@@ -41,11 +42,44 @@ declare class LunoraContainer<Env = unknown> extends Container<Env> {
41
42
  private readonly lunoraJurisdiction?;
42
43
  /** The `lunora/containers.ts` export name, for lifecycle log correlation. */
43
44
  private readonly lunoraName;
45
+ /** Default port the readiness probes target when a check omits its own `port`. */
46
+ private readonly lunoraDefaultPort?;
47
+ /** Hard-cap lifetime in whole seconds (from the `hardTimeout` config), or `undefined`. */
48
+ private readonly lunoraHardTimeoutSeconds?;
49
+ /** Declarative readiness probes that gate request proxying (from the `readyOn` config). */
50
+ private readonly lunoraReadyOn;
44
51
  constructor(context: DurableObjectContext, env: Env, definition: ContainerDefinition, exportName?: string, jurisdiction?: DurableObjectJurisdiction);
52
+ override onActivityExpired(): Promise<void>;
45
53
  override onError(error: unknown): unknown;
46
54
  override onStart(): Promise<void>;
55
+ /**
56
+ * Hook run when the container's `hardTimeout` elapses (dispatched by the base
57
+ * scheduler via the run-generation-stamped schedule armed in
58
+ * {@link onStart}). Default: stop the instance. Override to drain/checkpoint
59
+ * first. A stale schedule from a previous run, or an already-stopped
60
+ * instance, is ignored (upstream cloudflare/containers#85).
61
+ */
62
+ onHardTimeoutExpired(payload?: {
63
+ generation?: number;
64
+ }): Promise<void>;
47
65
  override onStop(parameters: StopParams): Promise<void>;
48
66
  /**
67
+ * Arm the hard-timeout kill via the base scheduler (so it integrates with
68
+ * the container's own alarm machinery instead of fighting it). Bumps the run
69
+ * generation and stamps the schedule with it, so {@link onHardTimeoutExpired}
70
+ * can tell a fresh schedule from a stale one. No-op without a `hardTimeout`.
71
+ */
72
+ private armHardTimeout;
73
+ /**
74
+ * Block until every `readyOn` probe responds with its expected status, or
75
+ * throw once the readiness budget is spent. Probes run in parallel and hit
76
+ * the container's TCP port directly (NOT `containerFetch`, which would
77
+ * recurse back into the start path). No-op without `readyOn`.
78
+ */
79
+ private awaitContainerReadiness;
80
+ /** Poll one readiness probe until it returns its expected status or the shared deadline passes. */
81
+ private awaitReadinessCheck;
82
+ /**
49
83
  * Best-effort push of `envelope` into the root ShardDO's log buffer so it
50
84
  * also appears in the Studio Logs panel (the terminal already has it via
51
85
  * `emitContainerLifecycle`). Fire-and-forget and fully swallowed: a missing
@@ -60,4 +94,4 @@ declare class LunoraContainer<Env = unknown> extends Container<Env> {
60
94
  */
61
95
  private instanceId;
62
96
  }
63
- export { LunoraContainer as default };
97
+ export { LunoraContainer };
package/dist/do/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Container } from '@cloudflare/containers';
2
- import { resolveContainerEnvVars as resolveContainerEnvVariables } from '../packem_shared/containerBindingName-BGdSdFNA.mjs';
2
+ export { ContainerProxy, outboundParams } from '@cloudflare/containers';
3
+ import { resolveContainerEnvVars as resolveContainerEnvVariables, parseDurationSeconds } from '../packem_shared/containerBindingName-C7Lic2ET.mjs';
3
4
 
4
5
  const LUNORA_EVENT_SOURCE = "lunora";
5
6
  const buildContainerLifecycleEvent = (container, instance, event, message) => {
@@ -73,6 +74,9 @@ const reportContainerLifecycle = async (env, envelope, jurisdiction) => {
73
74
  }
74
75
  };
75
76
 
77
+ const READINESS_POLL_INTERVAL_MS = 500;
78
+ const READINESS_TIMEOUT_MS = 3e4;
79
+ const HARD_TIMEOUT_GENERATION_KEY = "__lunoraHardTimeoutGeneration";
76
80
  class LunoraContainer extends Container {
77
81
  /**
78
82
  * Data-residency jurisdiction the app's DOs are pinned to (codegen passes the
@@ -82,17 +86,50 @@ class LunoraContainer extends Container {
82
86
  lunoraJurisdiction;
83
87
  /** The `lunora/containers.ts` export name, for lifecycle log correlation. */
84
88
  lunoraName;
89
+ /** Default port the readiness probes target when a check omits its own `port`. */
90
+ lunoraDefaultPort;
91
+ /** Hard-cap lifetime in whole seconds (from the `hardTimeout` config), or `undefined`. */
92
+ lunoraHardTimeoutSeconds;
93
+ /** Declarative readiness probes that gate request proxying (from the `readyOn` config). */
94
+ lunoraReadyOn;
85
95
  constructor(context, env, definition, exportName, jurisdiction) {
86
96
  super(context, env, {
87
97
  defaultPort: definition.defaultPort,
98
+ entrypoint: definition.entrypoint ? [...definition.entrypoint] : void 0,
88
99
  envVars: resolveContainerEnvVariables(definition, env, exportName),
89
100
  sleepAfter: definition.sleepAfter
90
101
  });
91
102
  if (definition.enableInternet !== void 0) {
92
103
  this.enableInternet = definition.enableInternet;
93
104
  }
105
+ if (definition.requiredPorts !== void 0) {
106
+ this.requiredPorts = [...definition.requiredPorts];
107
+ }
108
+ if (definition.interceptHttps !== void 0) {
109
+ this.interceptHttps = definition.interceptHttps;
110
+ }
111
+ if (definition.allowedHosts !== void 0) {
112
+ this.allowedHosts = [...definition.allowedHosts];
113
+ }
114
+ if (definition.deniedHosts !== void 0) {
115
+ this.deniedHosts = [...definition.deniedHosts];
116
+ }
117
+ if (definition.pingEndpoint !== void 0) {
118
+ this.pingEndpoint = definition.pingEndpoint;
119
+ }
120
+ if (definition.labels !== void 0) {
121
+ this.labels = { ...definition.labels };
122
+ }
94
123
  this.lunoraName = exportName ?? "container";
95
124
  this.lunoraJurisdiction = jurisdiction;
125
+ this.lunoraDefaultPort = definition.defaultPort;
126
+ this.lunoraReadyOn = definition.readyOn ? [...definition.readyOn] : [];
127
+ this.lunoraHardTimeoutSeconds = definition.hardTimeout === void 0 ? void 0 : parseDurationSeconds(definition.hardTimeout);
128
+ }
129
+ async onActivityExpired() {
130
+ const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "sleep");
131
+ this.surfaceInStudioLogs(envelope);
132
+ await super.onActivityExpired();
96
133
  }
97
134
  onError(error) {
98
135
  const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "error", error instanceof Error ? error.message : String(error));
@@ -103,12 +140,93 @@ class LunoraContainer extends Container {
103
140
  const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "start");
104
141
  this.surfaceInStudioLogs(envelope);
105
142
  await super.onStart();
143
+ await this.armHardTimeout();
144
+ await this.awaitContainerReadiness();
145
+ }
146
+ /**
147
+ * Hook run when the container's `hardTimeout` elapses (dispatched by the base
148
+ * scheduler via the run-generation-stamped schedule armed in
149
+ * {@link onStart}). Default: stop the instance. Override to drain/checkpoint
150
+ * first. A stale schedule from a previous run, or an already-stopped
151
+ * instance, is ignored (upstream cloudflare/containers#85).
152
+ */
153
+ async onHardTimeoutExpired(payload) {
154
+ const current = await this.ctx.storage.get(HARD_TIMEOUT_GENERATION_KEY);
155
+ if (payload?.generation !== void 0 && payload.generation !== current) {
156
+ return;
157
+ }
158
+ if (this.ctx.container?.running !== true) {
159
+ return;
160
+ }
161
+ const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "stop", "hard timeout reached");
162
+ this.surfaceInStudioLogs(envelope);
163
+ await this.stop();
106
164
  }
107
165
  async onStop(parameters) {
108
166
  const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "stop", `${parameters.reason} (exit ${String(parameters.exitCode)})`);
109
167
  this.surfaceInStudioLogs(envelope);
110
168
  await super.onStop(parameters);
111
169
  }
170
+ /**
171
+ * Arm the hard-timeout kill via the base scheduler (so it integrates with
172
+ * the container's own alarm machinery instead of fighting it). Bumps the run
173
+ * generation and stamps the schedule with it, so {@link onHardTimeoutExpired}
174
+ * can tell a fresh schedule from a stale one. No-op without a `hardTimeout`.
175
+ */
176
+ async armHardTimeout() {
177
+ if (this.lunoraHardTimeoutSeconds === void 0) {
178
+ return;
179
+ }
180
+ const generation = (await this.ctx.storage.get(HARD_TIMEOUT_GENERATION_KEY) ?? 0) + 1;
181
+ await this.ctx.storage.put(HARD_TIMEOUT_GENERATION_KEY, generation);
182
+ await this.schedule(this.lunoraHardTimeoutSeconds, "onHardTimeoutExpired", { generation });
183
+ }
184
+ /**
185
+ * Block until every `readyOn` probe responds with its expected status, or
186
+ * throw once the readiness budget is spent. Probes run in parallel and hit
187
+ * the container's TCP port directly (NOT `containerFetch`, which would
188
+ * recurse back into the start path). No-op without `readyOn`.
189
+ */
190
+ async awaitContainerReadiness() {
191
+ if (this.lunoraReadyOn.length === 0) {
192
+ return;
193
+ }
194
+ const { container } = this.ctx;
195
+ if (container === void 0) {
196
+ return;
197
+ }
198
+ const deadline = Date.now() + READINESS_TIMEOUT_MS;
199
+ await Promise.all(this.lunoraReadyOn.map(async (check) => this.awaitReadinessCheck(container, check, deadline)));
200
+ }
201
+ /** Poll one readiness probe until it returns its expected status or the shared deadline passes. */
202
+ async awaitReadinessCheck(container, check, deadline) {
203
+ const port = check.port ?? this.lunoraDefaultPort;
204
+ if (port === void 0) {
205
+ throw new Error(
206
+ `container "${this.lunoraName}": readyOn check "${check.path}" has no port — set the check's \`port\` or the container \`defaultPort\`.`
207
+ );
208
+ }
209
+ const expectedStatus = check.status ?? 200;
210
+ const path = check.path.startsWith("/") ? check.path : `/${check.path}`;
211
+ const tcpPort = container.getTcpPort(port);
212
+ for (; ; ) {
213
+ try {
214
+ const response = await tcpPort.fetch(`http://container${path}`);
215
+ if (response.status === expectedStatus) {
216
+ return;
217
+ }
218
+ } catch {
219
+ }
220
+ if (Date.now() >= deadline) {
221
+ throw new Error(
222
+ `container "${this.lunoraName}": readiness check "${check.path}" (port ${String(port)}) did not return ${String(expectedStatus)} within ${String(READINESS_TIMEOUT_MS)}ms`
223
+ );
224
+ }
225
+ await new Promise((resolve) => {
226
+ setTimeout(resolve, READINESS_POLL_INTERVAL_MS);
227
+ });
228
+ }
229
+ }
112
230
  /**
113
231
  * Best-effort push of `envelope` into the root ShardDO's log buffer so it
114
232
  * also appears in the Studio Logs panel (the terminal already has it via
@@ -135,4 +253,4 @@ class LunoraContainer extends Container {
135
253
  }
136
254
  }
137
255
 
138
- export { LunoraContainer as default };
256
+ export { LunoraContainer };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as ContainerConfig, C as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/types.d-D2l2SYol.mjs";
2
- export type { B as BuildImageSource, c as ContainerInstanceType, d as ContainerRollout, e as CustomContainerInstanceType, f as NamedContainerInstanceType, R as RegistryImageSource } from "./packem_shared/types.d-D2l2SYol.mjs";
1
+ import { a as ContainerConfig, C as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/types.d-DAOLRiZQ.mjs";
2
+ export type { B as BuildImageSource, c as ContainerInstanceType, d as ContainerReadinessCheck, e as ContainerRollout, f as CustomContainerInstanceType, g as NamedContainerInstanceType, R as RegistryImageSource } from "./packem_shared/types.d-DAOLRiZQ.mjs";
3
3
  /**
4
4
  * The `ctx.containers` action surface: typed handles over the `CONTAINER_*`
5
5
  * Durable Object namespace bindings the config layer reconciles.
@@ -23,13 +23,25 @@ interface ContainerStartOptions {
23
23
  /** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */
24
24
  interface ContainerInstanceState {
25
25
  [key: string]: unknown;
26
+ /** Process exit code, present once the instance has `stopped_with_code`. */
27
+ exitCode?: number;
28
+ /** Epoch-ms of the last state transition. */
26
29
  lastChange?: number;
30
+ /** Lifecycle status. Widening union — Cloudflare adds values over time. */
31
+ status?: "healthy" | "running" | "stopped" | "stopped_with_code" | "stopping";
27
32
  }
28
- /** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle RPCs the container DO exposes. */
33
+ /** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle/egress RPCs the container DO exposes. */
29
34
  interface ContainerStubLike {
35
+ allowHost?: (hostname: string) => Promise<void>;
36
+ denyHost?: (hostname: string) => Promise<void>;
30
37
  destroy?: () => Promise<void>;
31
38
  fetch: (input: Request) => Promise<Response>;
32
39
  getState?: () => Promise<ContainerInstanceState>;
40
+ removeAllowedHost?: (hostname: string) => Promise<void>;
41
+ removeDeniedHost?: (hostname: string) => Promise<void>;
42
+ renewActivityTimeout?: () => Promise<void>;
43
+ setAllowedHosts?: (hosts: string[]) => Promise<void>;
44
+ setDeniedHosts?: (hosts: string[]) => Promise<void>;
33
45
  start?: (options?: ContainerStartOptions) => Promise<void>;
34
46
  stop?: (signal?: number | string) => Promise<void>;
35
47
  }
@@ -57,6 +69,15 @@ interface ContainerHandle {
57
69
  * `Request`/URL passes through unchanged.
58
70
  */
59
71
  fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
72
+ /**
73
+ * Return a handle that routes every request to `targetPort` on the
74
+ * container instead of the definition's `defaultPort` — for multi-port
75
+ * containers (declare the ports in `requiredPorts`). Sets the
76
+ * `cf-container-target-port` header the way `@cloudflare/containers`'
77
+ * `switchPort` does, so it composes with `.get()`, `.any()`, and `.pool()`:
78
+ * `ctx.containers.app.get("u1").port(9090).fetch("/admin")`.
79
+ */
80
+ port: (targetPort: number) => ContainerHandle;
60
81
  }
61
82
  /**
62
83
  * A handle on a *named* instance (from `.get(name)`) — `fetch` plus explicit
@@ -68,13 +89,48 @@ interface ContainerHandle {
68
89
  interface ContainerInstanceHandle extends ContainerHandle {
69
90
  /** Stop and discard the instance (its ephemeral disk is lost). */
70
91
  destroy: () => Promise<void>;
92
+ /**
93
+ * Adjust this instance's egress allow/deny lists at runtime — the dynamic
94
+ * counterpart to the static `allowedHosts`/`deniedHosts` config. Useful for
95
+ * per-tenant egress policy. Requires the worker to export `ContainerProxy`
96
+ * (codegen re-exports it from the generated container file whenever any
97
+ * container is defined, so the runtime controls always work).
98
+ */
99
+ egress: ContainerEgressControls;
71
100
  /** Read the instance's current runtime state. */
72
101
  getState: () => Promise<ContainerInstanceState>;
102
+ /**
103
+ * Reset the instance's `sleepAfter` idle timer. The platform renews it on
104
+ * each request automatically, but WebSocket message activity does not yet
105
+ * renew it (cloudflare/containers#147) — call this on inbound WS traffic to
106
+ * keep a busy socket's container awake.
107
+ */
108
+ renewActivityTimeout: () => Promise<void>;
73
109
  /** Explicitly start the instance, optionally with per-instance env/entrypoint. */
74
110
  start: (options?: ContainerStartOptions) => Promise<void>;
75
111
  /** Stop the instance (optionally with a signal); it can start again on the next request. */
76
112
  stop: (signal?: number | string) => Promise<void>;
77
113
  }
114
+ /**
115
+ * Runtime egress-firewall controls for a named instance (`handle.egress.*`).
116
+ * Each maps to the corresponding `@cloudflare/containers` `Container` RPC, so
117
+ * an app can tighten or relax a single instance's allowed/denied hosts after
118
+ * start without redeploying.
119
+ */
120
+ interface ContainerEgressControls {
121
+ /** Add one hostname (or glob) to the allow-list. */
122
+ allow: (hostname: string) => Promise<void>;
123
+ /** Add one hostname (or glob) to the deny-list. */
124
+ deny: (hostname: string) => Promise<void>;
125
+ /** Remove one hostname from the allow-list. */
126
+ removeAllowed: (hostname: string) => Promise<void>;
127
+ /** Remove one hostname from the deny-list. */
128
+ removeDenied: (hostname: string) => Promise<void>;
129
+ /** Replace the entire allow-list. */
130
+ setAllowed: (hosts: ReadonlyArray<string>) => Promise<void>;
131
+ /** Replace the entire deny-list. */
132
+ setDenied: (hosts: ReadonlyArray<string>) => Promise<void>;
133
+ }
78
134
  /** The per-definition accessor exposed as `ctx.containers.&lt;exportName>`. */
79
135
  interface ContainerAccessor {
80
136
  /**
@@ -197,4 +253,4 @@ declare const isContainerDefinition: (value: unknown) => value is ContainerDefin
197
253
  * credential it was promised yields far worse errors downstream.
198
254
  */
199
255
  declare const resolveContainerEnvVariables: (definition: ContainerDefinition, workerEnv: Record<string, unknown>, exportName?: string) => Record<string, string>;
200
- export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
256
+ export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerEgressControls, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as ContainerConfig, C as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/types.d-D2l2SYol.js";
2
- export type { B as BuildImageSource, c as ContainerInstanceType, d as ContainerRollout, e as CustomContainerInstanceType, f as NamedContainerInstanceType, R as RegistryImageSource } from "./packem_shared/types.d-D2l2SYol.js";
1
+ import { a as ContainerConfig, C as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/types.d-DAOLRiZQ.js";
2
+ export type { B as BuildImageSource, c as ContainerInstanceType, d as ContainerReadinessCheck, e as ContainerRollout, f as CustomContainerInstanceType, g as NamedContainerInstanceType, R as RegistryImageSource } from "./packem_shared/types.d-DAOLRiZQ.js";
3
3
  /**
4
4
  * The `ctx.containers` action surface: typed handles over the `CONTAINER_*`
5
5
  * Durable Object namespace bindings the config layer reconciles.
@@ -23,13 +23,25 @@ interface ContainerStartOptions {
23
23
  /** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */
24
24
  interface ContainerInstanceState {
25
25
  [key: string]: unknown;
26
+ /** Process exit code, present once the instance has `stopped_with_code`. */
27
+ exitCode?: number;
28
+ /** Epoch-ms of the last state transition. */
26
29
  lastChange?: number;
30
+ /** Lifecycle status. Widening union — Cloudflare adds values over time. */
31
+ status?: "healthy" | "running" | "stopped" | "stopped_with_code" | "stopping";
27
32
  }
28
- /** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle RPCs the container DO exposes. */
33
+ /** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle/egress RPCs the container DO exposes. */
29
34
  interface ContainerStubLike {
35
+ allowHost?: (hostname: string) => Promise<void>;
36
+ denyHost?: (hostname: string) => Promise<void>;
30
37
  destroy?: () => Promise<void>;
31
38
  fetch: (input: Request) => Promise<Response>;
32
39
  getState?: () => Promise<ContainerInstanceState>;
40
+ removeAllowedHost?: (hostname: string) => Promise<void>;
41
+ removeDeniedHost?: (hostname: string) => Promise<void>;
42
+ renewActivityTimeout?: () => Promise<void>;
43
+ setAllowedHosts?: (hosts: string[]) => Promise<void>;
44
+ setDeniedHosts?: (hosts: string[]) => Promise<void>;
33
45
  start?: (options?: ContainerStartOptions) => Promise<void>;
34
46
  stop?: (signal?: number | string) => Promise<void>;
35
47
  }
@@ -57,6 +69,15 @@ interface ContainerHandle {
57
69
  * `Request`/URL passes through unchanged.
58
70
  */
59
71
  fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
72
+ /**
73
+ * Return a handle that routes every request to `targetPort` on the
74
+ * container instead of the definition's `defaultPort` — for multi-port
75
+ * containers (declare the ports in `requiredPorts`). Sets the
76
+ * `cf-container-target-port` header the way `@cloudflare/containers`'
77
+ * `switchPort` does, so it composes with `.get()`, `.any()`, and `.pool()`:
78
+ * `ctx.containers.app.get("u1").port(9090).fetch("/admin")`.
79
+ */
80
+ port: (targetPort: number) => ContainerHandle;
60
81
  }
61
82
  /**
62
83
  * A handle on a *named* instance (from `.get(name)`) — `fetch` plus explicit
@@ -68,13 +89,48 @@ interface ContainerHandle {
68
89
  interface ContainerInstanceHandle extends ContainerHandle {
69
90
  /** Stop and discard the instance (its ephemeral disk is lost). */
70
91
  destroy: () => Promise<void>;
92
+ /**
93
+ * Adjust this instance's egress allow/deny lists at runtime — the dynamic
94
+ * counterpart to the static `allowedHosts`/`deniedHosts` config. Useful for
95
+ * per-tenant egress policy. Requires the worker to export `ContainerProxy`
96
+ * (codegen re-exports it from the generated container file whenever any
97
+ * container is defined, so the runtime controls always work).
98
+ */
99
+ egress: ContainerEgressControls;
71
100
  /** Read the instance's current runtime state. */
72
101
  getState: () => Promise<ContainerInstanceState>;
102
+ /**
103
+ * Reset the instance's `sleepAfter` idle timer. The platform renews it on
104
+ * each request automatically, but WebSocket message activity does not yet
105
+ * renew it (cloudflare/containers#147) — call this on inbound WS traffic to
106
+ * keep a busy socket's container awake.
107
+ */
108
+ renewActivityTimeout: () => Promise<void>;
73
109
  /** Explicitly start the instance, optionally with per-instance env/entrypoint. */
74
110
  start: (options?: ContainerStartOptions) => Promise<void>;
75
111
  /** Stop the instance (optionally with a signal); it can start again on the next request. */
76
112
  stop: (signal?: number | string) => Promise<void>;
77
113
  }
114
+ /**
115
+ * Runtime egress-firewall controls for a named instance (`handle.egress.*`).
116
+ * Each maps to the corresponding `@cloudflare/containers` `Container` RPC, so
117
+ * an app can tighten or relax a single instance's allowed/denied hosts after
118
+ * start without redeploying.
119
+ */
120
+ interface ContainerEgressControls {
121
+ /** Add one hostname (or glob) to the allow-list. */
122
+ allow: (hostname: string) => Promise<void>;
123
+ /** Add one hostname (or glob) to the deny-list. */
124
+ deny: (hostname: string) => Promise<void>;
125
+ /** Remove one hostname from the allow-list. */
126
+ removeAllowed: (hostname: string) => Promise<void>;
127
+ /** Remove one hostname from the deny-list. */
128
+ removeDenied: (hostname: string) => Promise<void>;
129
+ /** Replace the entire allow-list. */
130
+ setAllowed: (hosts: ReadonlyArray<string>) => Promise<void>;
131
+ /** Replace the entire deny-list. */
132
+ setDenied: (hosts: ReadonlyArray<string>) => Promise<void>;
133
+ }
78
134
  /** The per-definition accessor exposed as `ctx.containers.&lt;exportName>`. */
79
135
  interface ContainerAccessor {
80
136
  /**
@@ -197,4 +253,4 @@ declare const isContainerDefinition: (value: unknown) => value is ContainerDefin
197
253
  * credential it was promised yields far worse errors downstream.
198
254
  */
199
255
  declare const resolveContainerEnvVariables: (definition: ContainerDefinition, workerEnv: Record<string, unknown>, exportName?: string) => Record<string, string>;
200
- export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
256
+ export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerEgressControls, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export { createContainerContext, createContainerTestContext } from './packem_shared/createContainerContext-CTpyUQ4J.mjs';
2
- export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVars } from './packem_shared/containerBindingName-BGdSdFNA.mjs';
1
+ export { createContainerContext, createContainerTestContext } from './packem_shared/createContainerContext-DBp8oJOr.mjs';
2
+ export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVars } from './packem_shared/containerBindingName-C7Lic2ET.mjs';
@@ -1,6 +1,17 @@
1
1
  const NAMED_INSTANCE_TYPES = /* @__PURE__ */ new Set(["basic", "lite", "standard-1", "standard-2", "standard-3", "standard-4"]);
2
2
  const ENV_NAME_PATTERN = /^[A-Z_]\w*$/i;
3
3
  const SLEEP_AFTER_PATTERN = /^\d+[smh]$/;
4
+ const DURATION_UNIT_SECONDS = { h: 3600, m: 60, s: 1 };
5
+ const parseDurationSeconds = (duration) => {
6
+ if (typeof duration === "number") {
7
+ return Math.floor(duration);
8
+ }
9
+ const match = SLEEP_AFTER_PATTERN.exec(duration);
10
+ if (match === null) {
11
+ throw new TypeError(`Invalid duration "${duration}" — expected a number of seconds or "<n>[smh]"`);
12
+ }
13
+ return Number(duration.slice(0, -1)) * (DURATION_UNIT_SECONDS[duration.slice(-1)] ?? 1);
14
+ };
4
15
  const basename = (path) => {
5
16
  const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
6
17
  const separatorIndex = trimmed.lastIndexOf("/");
@@ -72,10 +83,81 @@ const assertValidEnvAndSecrets = (config) => {
72
83
  }
73
84
  }
74
85
  };
86
+ const assertValidPort = (port, field) => {
87
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
88
+ throw new TypeError(`defineContainer: \`${field}\` must be an integer in 1–65535 (got ${String(port)})`);
89
+ }
90
+ };
91
+ const assertValidReadyOnChecks = (config) => {
92
+ for (const check of config.readyOn ?? []) {
93
+ if (typeof check.path !== "string" || check.path.trim().length === 0) {
94
+ throw new TypeError("defineContainer: `readyOn[].path` must be a non-empty HTTP path string");
95
+ }
96
+ if (check.path !== check.path.trim()) {
97
+ throw new TypeError("defineContainer: `readyOn[].path` must not have leading or trailing whitespace");
98
+ }
99
+ if (check.port !== void 0) {
100
+ assertValidPort(check.port, "readyOn[].port");
101
+ }
102
+ if (check.status !== void 0 && (!Number.isInteger(check.status) || check.status < 100 || check.status > 599)) {
103
+ throw new TypeError(`defineContainer: \`readyOn[].status\` must be an HTTP status code in 100–599 (got ${String(check.status)})`);
104
+ }
105
+ }
106
+ };
107
+ const assertValidHardTimeout = (hardTimeout) => {
108
+ if (hardTimeout === void 0) {
109
+ return;
110
+ }
111
+ if (typeof hardTimeout === "string") {
112
+ if (!SLEEP_AFTER_PATTERN.test(hardTimeout)) {
113
+ throw new TypeError(
114
+ `defineContainer: \`hardTimeout\` string "${hardTimeout}" must be a number of seconds followed by a unit, e.g. "30s", "5m", or "1h"`
115
+ );
116
+ }
117
+ } else if (!Number.isInteger(hardTimeout) || hardTimeout < 1) {
118
+ throw new TypeError(
119
+ `defineContainer: \`hardTimeout\` must be a positive integer number of seconds or a duration string like "5m" (got ${String(hardTimeout)})`
120
+ );
121
+ }
122
+ };
123
+ const assertValidEgressFields = (config) => {
124
+ for (const field of ["allowedHosts", "deniedHosts"]) {
125
+ const hosts = config[field];
126
+ if (hosts?.some((host) => typeof host !== "string" || host.trim().length === 0)) {
127
+ throw new TypeError(`defineContainer: \`${field}\` must be an array of non-empty hostname patterns`);
128
+ }
129
+ }
130
+ if (config.interceptHttps !== void 0 && typeof config.interceptHttps !== "boolean") {
131
+ throw new TypeError("defineContainer: `interceptHttps` must be a boolean, or omitted");
132
+ }
133
+ };
134
+ const assertValidContainerRuntimeFields = (config) => {
135
+ if (config.requiredPorts !== void 0) {
136
+ if (config.requiredPorts.length === 0) {
137
+ throw new TypeError("defineContainer: `requiredPorts` must be a non-empty array of ports, or omitted");
138
+ }
139
+ for (const port of config.requiredPorts) {
140
+ assertValidPort(port, "requiredPorts[]");
141
+ }
142
+ }
143
+ if (config.entrypoint !== void 0 && (config.entrypoint.length === 0 || config.entrypoint.some((part) => typeof part !== "string" || part.trim().length === 0))) {
144
+ throw new TypeError("defineContainer: `entrypoint` must be a non-empty array of non-empty strings, or omitted");
145
+ }
146
+ assertValidEgressFields(config);
147
+ if (config.pingEndpoint !== void 0 && (typeof config.pingEndpoint !== "string" || config.pingEndpoint.trim().length === 0)) {
148
+ throw new TypeError("defineContainer: `pingEndpoint` must be a non-empty path string");
149
+ }
150
+ for (const [key, value] of Object.entries(config.labels ?? {})) {
151
+ if (key.trim().length === 0 || typeof value !== "string") {
152
+ throw new TypeError("defineContainer: `labels` must be a record of non-empty keys to string values");
153
+ }
154
+ }
155
+ assertValidReadyOnChecks(config);
156
+ };
75
157
  const defineContainer = (config) => {
76
158
  assertValidImage(config.image);
77
- if (config.defaultPort !== void 0 && (!Number.isInteger(config.defaultPort) || config.defaultPort < 1 || config.defaultPort > 65535)) {
78
- throw new TypeError(`defineContainer: \`defaultPort\` must be an integer in 1–65535 (got ${String(config.defaultPort)})`);
159
+ if (config.defaultPort !== void 0) {
160
+ assertValidPort(config.defaultPort, "defaultPort");
79
161
  }
80
162
  const stepPercentage = config.rollout?.stepPercentage;
81
163
  if (stepPercentage !== void 0 && (!Number.isInteger(stepPercentage) || stepPercentage < 1 || stepPercentage > 100)) {
@@ -94,7 +176,9 @@ const defineContainer = (config) => {
94
176
  `defineContainer: \`sleepAfter\` string "${config.sleepAfter}" must be a number of seconds followed by a unit, e.g. "30s", "5m", or "1h"`
95
177
  );
96
178
  }
179
+ assertValidHardTimeout(config.hardTimeout);
97
180
  assertValidEnvAndSecrets(config);
181
+ assertValidContainerRuntimeFields(config);
98
182
  return { ...config, isLunoraContainer: true };
99
183
  };
100
184
  const isContainerDefinition = (value) => typeof value === "object" && value !== null && value.isLunoraContainer === true;
@@ -113,4 +197,4 @@ const resolveContainerEnvVariables = (definition, workerEnv, exportName) => {
113
197
  return resolved;
114
198
  };
115
199
 
116
- export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
200
+ export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, parseDurationSeconds, resolveContainerEnvVariables as resolveContainerEnvVars };
@@ -11,17 +11,21 @@ const applyJurisdiction = (namespace, jurisdiction) => {
11
11
  };
12
12
  const DEFAULT_POOL_SIZE = 3;
13
13
  const DEFAULT_MAX_BACKOFF_MS = 3e4;
14
- const toRequest = (input, init) => {
15
- if (typeof input === "string" && input.startsWith("/")) {
16
- return new Request(`http://container${input}`, init);
14
+ const TARGET_PORT_HEADER = "cf-container-target-port";
15
+ const toRequest = (input, init, port) => {
16
+ const request = typeof input === "string" && input.startsWith("/") ? new Request(`http://container${input}`, init) : new Request(input, init);
17
+ if (port !== void 0) {
18
+ request.headers.set(TARGET_PORT_HEADER, String(port));
17
19
  }
18
- return new Request(input, init);
20
+ return request;
19
21
  };
20
- const handleFor = (namespace, instanceName) => {
22
+ const sendingHandle = (send, port) => {
21
23
  return {
22
- fetch: async (input, init) => namespace.get(namespace.idFromName(instanceName)).fetch(toRequest(input, init))
24
+ fetch: async (input, init) => send(toRequest(input, init, port)),
25
+ port: (targetPort) => sendingHandle(send, targetPort)
23
26
  };
24
27
  };
28
+ const handleFor = (namespace, instanceName) => sendingHandle(async (request) => namespace.get(namespace.idFromName(instanceName)).fetch(request));
25
29
  const lifecycleCall = async (stub, method, binding, argument) => {
26
30
  const rpc = stub[method];
27
31
  if (typeof rpc !== "function") {
@@ -29,12 +33,24 @@ const lifecycleCall = async (stub, method, binding, argument) => {
29
33
  }
30
34
  return rpc(argument);
31
35
  };
36
+ const egressControlsFor = (stub, binding) => {
37
+ return {
38
+ allow: async (hostname) => lifecycleCall(stub(), "allowHost", binding, hostname),
39
+ deny: async (hostname) => lifecycleCall(stub(), "denyHost", binding, hostname),
40
+ removeAllowed: async (hostname) => lifecycleCall(stub(), "removeAllowedHost", binding, hostname),
41
+ removeDenied: async (hostname) => lifecycleCall(stub(), "removeDeniedHost", binding, hostname),
42
+ setAllowed: async (hosts) => lifecycleCall(stub(), "setAllowedHosts", binding, [...hosts]),
43
+ setDenied: async (hosts) => lifecycleCall(stub(), "setDeniedHosts", binding, [...hosts])
44
+ };
45
+ };
32
46
  const instanceHandleFor = (namespace, spec, instanceName) => {
33
47
  const stub = () => namespace.get(namespace.idFromName(instanceName));
34
48
  return {
49
+ ...sendingHandle(async (request) => stub().fetch(request)),
35
50
  destroy: async () => lifecycleCall(stub(), "destroy", spec.binding),
36
- fetch: async (input, init) => stub().fetch(toRequest(input, init)),
51
+ egress: egressControlsFor(stub, spec.binding),
37
52
  getState: async () => lifecycleCall(stub(), "getState", spec.binding),
53
+ renewActivityTimeout: async () => lifecycleCall(stub(), "renewActivityTimeout", spec.binding),
38
54
  start: async (options) => lifecycleCall(stub(), "start", spec.binding, options),
39
55
  stop: async (signal) => lifecycleCall(stub(), "stop", spec.binding, signal)
40
56
  };
@@ -52,7 +68,7 @@ const sleep = async (ms) => {
52
68
  });
53
69
  };
54
70
  const retryOnServerError = (response) => response.status >= 500;
55
- const poolHandleFor = (namespace, spec, options = {}) => {
71
+ const poolHandleFor = (namespace, spec, options = {}, port) => {
56
72
  const size = options.size ?? spec.maxInstances ?? DEFAULT_POOL_SIZE;
57
73
  const attempts = Math.max(1, options.attempts ?? 3);
58
74
  const baseBackoff = options.backoffMs ?? 100;
@@ -65,7 +81,7 @@ const poolHandleFor = (namespace, spec, options = {}) => {
65
81
  if (attempt > 0) {
66
82
  await sleep(Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff));
67
83
  }
68
- const request = toRequest(input, init);
84
+ const request = toRequest(input, init, port);
69
85
  try {
70
86
  const response = await namespace.get(namespace.idFromName(randomPoolName(size))).fetch(request);
71
87
  if (attempt === attempts - 1 || !shouldRetry(response)) {
@@ -76,7 +92,8 @@ const poolHandleFor = (namespace, spec, options = {}) => {
76
92
  }
77
93
  }
78
94
  throw lastError instanceof Error ? lastError : new Error(`ctx.containers.${spec.exportName}.pool(): all ${String(attempts)} attempts failed`);
79
- }
95
+ },
96
+ port: (targetPort) => poolHandleFor(namespace, spec, options, targetPort)
80
97
  };
81
98
  };
82
99
  const accessorFor = (namespace, spec) => {
@@ -102,29 +119,37 @@ const createContainerContext = (env, specs, jurisdiction) => {
102
119
  }
103
120
  return containers;
104
121
  };
122
+ const testNamespaceFor = (handler) => {
123
+ const stubFor = (name) => {
124
+ return {
125
+ allowHost: () => Promise.resolve(),
126
+ denyHost: () => Promise.resolve(),
127
+ destroy: () => Promise.resolve(),
128
+ fetch: (request) => Promise.resolve(handler(request, { name })),
129
+ getState: () => Promise.resolve({ lastChange: 0 }),
130
+ removeAllowedHost: () => Promise.resolve(),
131
+ removeDeniedHost: () => Promise.resolve(),
132
+ renewActivityTimeout: () => Promise.resolve(),
133
+ setAllowedHosts: () => Promise.resolve(),
134
+ setDeniedHosts: () => Promise.resolve(),
135
+ start: () => Promise.resolve(),
136
+ stop: () => Promise.resolve()
137
+ };
138
+ };
139
+ return { get: (id) => stubFor(String(id)), idFromName: (name) => name };
140
+ };
105
141
  const createContainerTestContext = (handlers) => {
106
142
  const containers = {};
107
143
  for (const [exportName, handler] of Object.entries(handlers)) {
108
- const testHandleFor = (instanceName) => {
109
- return {
110
- fetch: async (input, init) => handler(toRequest(input, init), { name: instanceName })
111
- };
112
- };
113
- const testInstanceHandleFor = (instanceName) => {
114
- return {
115
- ...testHandleFor(instanceName),
116
- destroy: () => Promise.resolve(),
117
- getState: () => Promise.resolve({ lastChange: 0 }),
118
- start: () => Promise.resolve(),
119
- stop: () => Promise.resolve()
120
- };
121
- };
144
+ const namespace = testNamespaceFor(handler);
145
+ const spec = { binding: `CONTAINER_${exportName.toUpperCase()}`};
122
146
  containers[exportName] = {
123
- any: () => testHandleFor("pool-0"),
124
- get: (name) => testInstanceHandleFor(name),
125
- // The double doesn't simulate failure/retry pool() just routes to
126
- // the handler like any other call, so tests stay deterministic.
127
- pool: () => testHandleFor("pool-0")
147
+ // `.any()`/`.pool()` route to a fixed `pool-0` so the handler's
148
+ // `instance.name` is deterministic under test; the double doesn't
149
+ // simulate the random-pick or retry/backoff the real pool does.
150
+ any: () => handleFor(namespace, "pool-0"),
151
+ get: (name) => instanceHandleFor(namespace, spec, name),
152
+ pool: () => handleFor(namespace, "pool-0")
128
153
  };
129
154
  }
130
155
  return containers;
@@ -52,7 +52,37 @@ interface BuildImageSource {
52
52
  * the Dockerfile itself — while `{ registry }` is a pre-built image reference.
53
53
  */
54
54
  type ContainerImageSource = BuildImageSource | RegistryImageSource | string;
55
+ /**
56
+ * An application-level readiness probe that gates request proxying. Layered on
57
+ * top of the platform's own port/`pingEndpoint` health wait, it lets you hold
58
+ * traffic back until the app inside the container is *functionally* ready —
59
+ * migrations applied, caches warmed — which an open-port check can't see.
60
+ *
61
+ * Declarative on purpose: a `defineContainer` value stays pure data (no handler
62
+ * functions), so codegen and the config layer can read it without evaluating
63
+ * code. (Upstream cloudflare/containers#188 expresses the same idea as handler
64
+ * functions; the Lunora config is data-only, so it's modelled as descriptors.)
65
+ */
66
+ interface ContainerReadinessCheck {
67
+ /** HTTP path probed on the container, e.g. `"/ready"` (a leading slash is optional). */
68
+ path: string;
69
+ /** Port to probe. Defaults to {@link ContainerConfig.defaultPort}. */
70
+ port?: number;
71
+ /** HTTP status that means "ready". Defaults to `200`. */
72
+ status?: number;
73
+ }
55
74
  interface ContainerConfig {
75
+ /**
76
+ * Hostnames the container may reach **even when {@link ContainerConfig.enableInternet}
77
+ * is `false`** — an egress allow-list (Cloudflare's `allowedHosts`). Glob
78
+ * patterns like `*.stripe.com` are supported. Pair with `enableInternet:
79
+ * false` to deny all egress except these hosts (the firewall pattern
80
+ * upstream issue cloudflare/containers#30 asked for). The interception path
81
+ * needs the `ContainerProxy` worker entrypoint, which codegen re-exports
82
+ * from the generated container file automatically; the named-instance
83
+ * handle's `egress` controls adjust the lists at runtime.
84
+ */
85
+ allowedHosts?: ReadonlyArray<string>;
56
86
  /**
57
87
  * Build-time variables for a Dockerfile/Railpack image — wrangler's
58
88
  * `image_vars` (equivalent to `docker build --build-arg`). For *runtime*
@@ -62,21 +92,46 @@ interface ContainerConfig {
62
92
  buildArgs?: Readonly<Record<string, string>>;
63
93
  /**
64
94
  * The port the container listens on. Worker → container requests target
65
- * this port. Locally the Dockerfile must also `EXPOSE` it.
95
+ * this port. Locally the Dockerfile must also `EXPOSE` it. For a
96
+ * multi-port container also declare {@link ContainerConfig.requiredPorts}
97
+ * and route per request with the handle's `.port(n)`.
66
98
  */
67
99
  defaultPort?: number;
68
100
  /**
101
+ * Hostnames the container may **never** reach — an egress deny-list
102
+ * (Cloudflare's `deniedHosts`). Overrides everything else, including
103
+ * `enableInternet: true` and {@link ContainerConfig.allowedHosts}. Glob
104
+ * patterns like `*.evil.com` are supported.
105
+ */
106
+ deniedHosts?: ReadonlyArray<string>;
107
+ /**
69
108
  * Whether the container may open outbound internet connections. Defaults
70
109
  * to `true` — the platform default. Note that container egress is billed
71
- * per GB by Cloudflare.
110
+ * per GB by Cloudflare. Combine with {@link ContainerConfig.allowedHosts} /
111
+ * {@link ContainerConfig.deniedHosts} for a precise egress firewall.
72
112
  */
73
113
  enableInternet?: boolean;
74
114
  /**
115
+ * Default command to run inside the container, overriding the image's
116
+ * `ENTRYPOINT`/`CMD` (Cloudflare's `entrypoint`). A per-start override is
117
+ * still available via the named-instance handle's `start({ entrypoint })`.
118
+ */
119
+ entrypoint?: ReadonlyArray<string>;
120
+ /**
75
121
  * Static environment variables passed to the container on every start.
76
122
  * For secret values use {@link ContainerConfig.secrets} instead so they
77
123
  * flow through Worker Secrets rather than source code.
78
124
  */
79
125
  env?: Readonly<Record<string, string>>;
126
+ /**
127
+ * Hard cap on how long an instance may run, measured from start regardless
128
+ * of activity — a runaway-cost backstop on top of the idle
129
+ * {@link ContainerConfig.sleepAfter}. Same grammar as `sleepAfter`
130
+ * (`"30s"`, `"5m"`, `"1h"`, or a plain number of seconds). When it elapses,
131
+ * the `LunoraContainer.onHardTimeoutExpired` hook runs (default: `stop()`).
132
+ * (Upstream cloudflare/containers#85.)
133
+ */
134
+ hardTimeout?: number | string;
80
135
  /** Image source — a local Dockerfile path/directory or a registry reference. */
81
136
  image: ContainerImageSource;
82
137
  /**
@@ -85,6 +140,21 @@ interface ContainerConfig {
85
140
  */
86
141
  instanceType?: ContainerInstanceType;
87
142
  /**
143
+ * Intercept the container's outbound **HTTPS** traffic so the egress
144
+ * allow/deny lists apply to TLS connections too (Cloudflare's
145
+ * `interceptHttps`). Requires the image to trust the Cloudflare CA at
146
+ * `/etc/cloudflare/certs/cloudflare-containers-ca.crt`. Defaults to `false`
147
+ * (HTTP egress is gated regardless).
148
+ */
149
+ interceptHttps?: boolean;
150
+ /**
151
+ * Key-value metadata attached to every instance for metrics/observability
152
+ * (Cloudflare's container `labels`), e.g. `{ tenant: "acme", env: "prod" }`.
153
+ * A per-start override is available via the named-instance handle's
154
+ * `start({ labels })`.
155
+ */
156
+ labels?: Readonly<Record<string, string>>;
157
+ /**
88
158
  * Maximum number of concurrently *running* instances. Stopped (slept)
89
159
  * containers don't count. Also the default pool size for `.any()`.
90
160
  */
@@ -95,6 +165,30 @@ interface ContainerConfig {
95
165
  */
96
166
  name?: string;
97
167
  /**
168
+ * HTTP path Cloudflare polls to decide an instance is healthy
169
+ * (Cloudflare's `pingEndpoint`). Defaults to upstream's slash-less `"ping"`;
170
+ * either `"ping"` or `"/healthz"`-style paths are accepted. Set this when
171
+ * the container exposes its readiness check under a different route.
172
+ */
173
+ pingEndpoint?: string;
174
+ /**
175
+ * Application-level readiness probes that gate request proxying: a
176
+ * `ctx.containers.&lt;name>` fetch waits until every probe responds with its
177
+ * expected status before the request reaches the container — on top of the
178
+ * platform's port/`pingEndpoint` health wait. All probes run in parallel.
179
+ * Use these for readiness an open-port check can't see (migrations applied,
180
+ * caches warm). (Upstream cloudflare/containers#188.)
181
+ */
182
+ readyOn?: ReadonlyArray<ContainerReadinessCheck>;
183
+ /**
184
+ * Ports the container must be listening on before it's considered ready
185
+ * (Cloudflare's `requiredPorts`) — for multi-port containers. Start-up
186
+ * waits for every listed port, and the handle's `.port(n)` routes a request
187
+ * to any of them; {@link ContainerConfig.defaultPort} is the target when a
188
+ * request doesn't pick one.
189
+ */
190
+ requiredPorts?: ReadonlyArray<number>;
191
+ /**
98
192
  * Rolling-deploy tuning. `stepPercentage` is the share of instances updated
99
193
  * per rollout step (wrangler `rollout_step_percentage`); `gracePeriodSeconds`
100
194
  * is how long an active instance is left running before it's eligible for
@@ -137,4 +231,4 @@ type NormalizedContainerImage = {
137
231
  kind: "registry"; /** Fully-qualified image reference (wrangler `image`). */
138
232
  reference: string;
139
233
  };
140
- export { BuildImageSource as B, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c, ContainerRollout as d, CustomContainerInstanceType as e, NamedContainerInstanceType as f };
234
+ export { BuildImageSource as B, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c, ContainerReadinessCheck as d, ContainerRollout as e, CustomContainerInstanceType as f, NamedContainerInstanceType as g };
@@ -52,7 +52,37 @@ interface BuildImageSource {
52
52
  * the Dockerfile itself — while `{ registry }` is a pre-built image reference.
53
53
  */
54
54
  type ContainerImageSource = BuildImageSource | RegistryImageSource | string;
55
+ /**
56
+ * An application-level readiness probe that gates request proxying. Layered on
57
+ * top of the platform's own port/`pingEndpoint` health wait, it lets you hold
58
+ * traffic back until the app inside the container is *functionally* ready —
59
+ * migrations applied, caches warmed — which an open-port check can't see.
60
+ *
61
+ * Declarative on purpose: a `defineContainer` value stays pure data (no handler
62
+ * functions), so codegen and the config layer can read it without evaluating
63
+ * code. (Upstream cloudflare/containers#188 expresses the same idea as handler
64
+ * functions; the Lunora config is data-only, so it's modelled as descriptors.)
65
+ */
66
+ interface ContainerReadinessCheck {
67
+ /** HTTP path probed on the container, e.g. `"/ready"` (a leading slash is optional). */
68
+ path: string;
69
+ /** Port to probe. Defaults to {@link ContainerConfig.defaultPort}. */
70
+ port?: number;
71
+ /** HTTP status that means "ready". Defaults to `200`. */
72
+ status?: number;
73
+ }
55
74
  interface ContainerConfig {
75
+ /**
76
+ * Hostnames the container may reach **even when {@link ContainerConfig.enableInternet}
77
+ * is `false`** — an egress allow-list (Cloudflare's `allowedHosts`). Glob
78
+ * patterns like `*.stripe.com` are supported. Pair with `enableInternet:
79
+ * false` to deny all egress except these hosts (the firewall pattern
80
+ * upstream issue cloudflare/containers#30 asked for). The interception path
81
+ * needs the `ContainerProxy` worker entrypoint, which codegen re-exports
82
+ * from the generated container file automatically; the named-instance
83
+ * handle's `egress` controls adjust the lists at runtime.
84
+ */
85
+ allowedHosts?: ReadonlyArray<string>;
56
86
  /**
57
87
  * Build-time variables for a Dockerfile/Railpack image — wrangler's
58
88
  * `image_vars` (equivalent to `docker build --build-arg`). For *runtime*
@@ -62,21 +92,46 @@ interface ContainerConfig {
62
92
  buildArgs?: Readonly<Record<string, string>>;
63
93
  /**
64
94
  * The port the container listens on. Worker → container requests target
65
- * this port. Locally the Dockerfile must also `EXPOSE` it.
95
+ * this port. Locally the Dockerfile must also `EXPOSE` it. For a
96
+ * multi-port container also declare {@link ContainerConfig.requiredPorts}
97
+ * and route per request with the handle's `.port(n)`.
66
98
  */
67
99
  defaultPort?: number;
68
100
  /**
101
+ * Hostnames the container may **never** reach — an egress deny-list
102
+ * (Cloudflare's `deniedHosts`). Overrides everything else, including
103
+ * `enableInternet: true` and {@link ContainerConfig.allowedHosts}. Glob
104
+ * patterns like `*.evil.com` are supported.
105
+ */
106
+ deniedHosts?: ReadonlyArray<string>;
107
+ /**
69
108
  * Whether the container may open outbound internet connections. Defaults
70
109
  * to `true` — the platform default. Note that container egress is billed
71
- * per GB by Cloudflare.
110
+ * per GB by Cloudflare. Combine with {@link ContainerConfig.allowedHosts} /
111
+ * {@link ContainerConfig.deniedHosts} for a precise egress firewall.
72
112
  */
73
113
  enableInternet?: boolean;
74
114
  /**
115
+ * Default command to run inside the container, overriding the image's
116
+ * `ENTRYPOINT`/`CMD` (Cloudflare's `entrypoint`). A per-start override is
117
+ * still available via the named-instance handle's `start({ entrypoint })`.
118
+ */
119
+ entrypoint?: ReadonlyArray<string>;
120
+ /**
75
121
  * Static environment variables passed to the container on every start.
76
122
  * For secret values use {@link ContainerConfig.secrets} instead so they
77
123
  * flow through Worker Secrets rather than source code.
78
124
  */
79
125
  env?: Readonly<Record<string, string>>;
126
+ /**
127
+ * Hard cap on how long an instance may run, measured from start regardless
128
+ * of activity — a runaway-cost backstop on top of the idle
129
+ * {@link ContainerConfig.sleepAfter}. Same grammar as `sleepAfter`
130
+ * (`"30s"`, `"5m"`, `"1h"`, or a plain number of seconds). When it elapses,
131
+ * the `LunoraContainer.onHardTimeoutExpired` hook runs (default: `stop()`).
132
+ * (Upstream cloudflare/containers#85.)
133
+ */
134
+ hardTimeout?: number | string;
80
135
  /** Image source — a local Dockerfile path/directory or a registry reference. */
81
136
  image: ContainerImageSource;
82
137
  /**
@@ -85,6 +140,21 @@ interface ContainerConfig {
85
140
  */
86
141
  instanceType?: ContainerInstanceType;
87
142
  /**
143
+ * Intercept the container's outbound **HTTPS** traffic so the egress
144
+ * allow/deny lists apply to TLS connections too (Cloudflare's
145
+ * `interceptHttps`). Requires the image to trust the Cloudflare CA at
146
+ * `/etc/cloudflare/certs/cloudflare-containers-ca.crt`. Defaults to `false`
147
+ * (HTTP egress is gated regardless).
148
+ */
149
+ interceptHttps?: boolean;
150
+ /**
151
+ * Key-value metadata attached to every instance for metrics/observability
152
+ * (Cloudflare's container `labels`), e.g. `{ tenant: "acme", env: "prod" }`.
153
+ * A per-start override is available via the named-instance handle's
154
+ * `start({ labels })`.
155
+ */
156
+ labels?: Readonly<Record<string, string>>;
157
+ /**
88
158
  * Maximum number of concurrently *running* instances. Stopped (slept)
89
159
  * containers don't count. Also the default pool size for `.any()`.
90
160
  */
@@ -95,6 +165,30 @@ interface ContainerConfig {
95
165
  */
96
166
  name?: string;
97
167
  /**
168
+ * HTTP path Cloudflare polls to decide an instance is healthy
169
+ * (Cloudflare's `pingEndpoint`). Defaults to upstream's slash-less `"ping"`;
170
+ * either `"ping"` or `"/healthz"`-style paths are accepted. Set this when
171
+ * the container exposes its readiness check under a different route.
172
+ */
173
+ pingEndpoint?: string;
174
+ /**
175
+ * Application-level readiness probes that gate request proxying: a
176
+ * `ctx.containers.&lt;name>` fetch waits until every probe responds with its
177
+ * expected status before the request reaches the container — on top of the
178
+ * platform's port/`pingEndpoint` health wait. All probes run in parallel.
179
+ * Use these for readiness an open-port check can't see (migrations applied,
180
+ * caches warm). (Upstream cloudflare/containers#188.)
181
+ */
182
+ readyOn?: ReadonlyArray<ContainerReadinessCheck>;
183
+ /**
184
+ * Ports the container must be listening on before it's considered ready
185
+ * (Cloudflare's `requiredPorts`) — for multi-port containers. Start-up
186
+ * waits for every listed port, and the handle's `.port(n)` routes a request
187
+ * to any of them; {@link ContainerConfig.defaultPort} is the target when a
188
+ * request doesn't pick one.
189
+ */
190
+ requiredPorts?: ReadonlyArray<number>;
191
+ /**
98
192
  * Rolling-deploy tuning. `stepPercentage` is the share of instances updated
99
193
  * per rollout step (wrangler `rollout_step_percentage`); `gracePeriodSeconds`
100
194
  * is how long an active instance is left running before it's eligible for
@@ -137,4 +231,4 @@ type NormalizedContainerImage = {
137
231
  kind: "registry"; /** Fully-qualified image reference (wrangler `image`). */
138
232
  reference: string;
139
233
  };
140
- export { BuildImageSource as B, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c, ContainerRollout as d, CustomContainerInstanceType as e, NamedContainerInstanceType as f };
234
+ export { BuildImageSource as B, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c, ContainerReadinessCheck as d, ContainerRollout as e, CustomContainerInstanceType as f, NamedContainerInstanceType as g };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/container",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Cloudflare Containers for Lunora: defineContainer, generated Container DO classes, and the ctx.containers action surface",
5
5
  "keywords": [
6
6
  "cloudflare",