@lunora/container 0.0.0 → 1.0.0-alpha.10

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.
@@ -0,0 +1,321 @@
1
+ import { Container } from '../packem_shared/ContainerProxy-DWqUX_re.mjs';
2
+ export { ContainerProxy, outboundParams } from '../packem_shared/ContainerProxy-DWqUX_re.mjs';
3
+ import { LunoraError } from '@lunora/errors';
4
+ import { resolveContainerEnvVars as resolveContainerEnvVariables, parseDurationSeconds } from '../packem_shared/containerBindingName-BiTrAF1J.mjs';
5
+ import { a as applyJurisdiction } from '../packem_shared/jurisdiction-CuPNcLDt.mjs';
6
+
7
+ const LUNORA_EVENT_SOURCE = "lunora";
8
+ const buildContainerLifecycleEvent = (container, instance, event, message) => {
9
+ return {
10
+ container,
11
+ event,
12
+ instance,
13
+ level: event === "error" ? "error" : "info",
14
+ message,
15
+ source: LUNORA_EVENT_SOURCE,
16
+ ts: Date.now(),
17
+ type: "container"
18
+ };
19
+ };
20
+ const emitContainerLifecycle = (container, instance, event, message) => {
21
+ const envelope = buildContainerLifecycleEvent(container, instance, event, message);
22
+ const line = JSON.stringify(envelope);
23
+ if (event === "error") {
24
+ console.error(line);
25
+ } else {
26
+ console.log(line);
27
+ }
28
+ return envelope;
29
+ };
30
+
31
+ const RECORD_CONTAINER_EVENT_OP = "__lunora_admin__:recordContainerEvent";
32
+ const ROOT_SHARD_NAME = "__root__";
33
+ const isShardNamespace = (value) => {
34
+ if (value === null || typeof value !== "object") {
35
+ return false;
36
+ }
37
+ const candidate = value;
38
+ return typeof candidate.get === "function" && typeof candidate.idFromName === "function";
39
+ };
40
+ const resolveRootShard = (namespace, jurisdiction) => {
41
+ const pinned = applyJurisdiction(namespace, jurisdiction);
42
+ if (typeof pinned.getByName === "function") {
43
+ return pinned.getByName(ROOT_SHARD_NAME);
44
+ }
45
+ return pinned.get(pinned.idFromName(ROOT_SHARD_NAME));
46
+ };
47
+ const reportContainerLifecycle = async (env, envelope, jurisdiction) => {
48
+ try {
49
+ const envRecord = env ?? {};
50
+ const namespace = envRecord["SHARD"];
51
+ if (!isShardNamespace(namespace)) {
52
+ return;
53
+ }
54
+ const adminBearer = typeof envRecord["LUNORA_ADMIN_TOKEN"] === "string" ? envRecord["LUNORA_ADMIN_TOKEN"] : void 0;
55
+ if (!adminBearer || adminBearer.length === 0) {
56
+ return;
57
+ }
58
+ const request = new Request("https://shard.internal/rpc", {
59
+ body: JSON.stringify({ args: { event: envelope }, functionPath: RECORD_CONTAINER_EVENT_OP }),
60
+ headers: { authorization: `Bearer ${adminBearer}`, "content-type": "application/json" },
61
+ method: "POST"
62
+ });
63
+ await resolveRootShard(namespace, jurisdiction).fetch(request);
64
+ } catch {
65
+ }
66
+ };
67
+
68
+ const READINESS_POLL_INTERVAL_MS = 500;
69
+ const READINESS_TIMEOUT_MS = 3e4;
70
+ const HARD_TIMEOUT_GENERATION_KEY = "__lunoraHardTimeoutGeneration";
71
+ class LunoraContainer extends Container {
72
+ /**
73
+ * Data-residency jurisdiction the app's DOs are pinned to (codegen passes the
74
+ * schema's `.jurisdiction("…")`). Used to pin the best-effort lifecycle report
75
+ * to the same region as the root shard. `undefined` ⇒ un-pinned.
76
+ */
77
+ lunoraJurisdiction;
78
+ /** The `lunora/containers.ts` export name, for lifecycle log correlation. */
79
+ lunoraName;
80
+ /** Default port the readiness probes target when a check omits its own `port`. */
81
+ lunoraDefaultPort;
82
+ /** Hard-cap lifetime in whole seconds (from the `hardTimeout` config), or `undefined`. */
83
+ lunoraHardTimeoutSeconds;
84
+ /** Declarative readiness probes that gate request proxying (from the `readyOn` config). */
85
+ lunoraReadyOn;
86
+ /** Map of container env-var name → Worker Secrets Store binding name (from the `secretsStore` config). */
87
+ lunoraSecretsStore;
88
+ /** Memoised Secrets Store resolution: run once, then merged into `envVars` before the first start. */
89
+ lunoraSecretsStoreResolved;
90
+ constructor(context, env, definition, exportName, jurisdiction) {
91
+ super(context, env, {
92
+ defaultPort: definition.defaultPort,
93
+ entrypoint: definition.entrypoint ? [...definition.entrypoint] : void 0,
94
+ envVars: resolveContainerEnvVariables(definition, env, exportName),
95
+ sleepAfter: definition.sleepAfter
96
+ });
97
+ if (definition.enableInternet !== void 0) {
98
+ this.enableInternet = definition.enableInternet;
99
+ }
100
+ if (definition.requiredPorts !== void 0) {
101
+ this.requiredPorts = [...definition.requiredPorts];
102
+ }
103
+ if (definition.interceptHttps !== void 0) {
104
+ this.interceptHttps = definition.interceptHttps;
105
+ }
106
+ if (definition.allowedHosts !== void 0) {
107
+ this.allowedHosts = [...definition.allowedHosts];
108
+ }
109
+ if (definition.deniedHosts !== void 0) {
110
+ this.deniedHosts = [...definition.deniedHosts];
111
+ }
112
+ if (definition.pingEndpoint !== void 0) {
113
+ this.pingEndpoint = definition.pingEndpoint;
114
+ }
115
+ if (definition.labels !== void 0) {
116
+ this.labels = { ...definition.labels };
117
+ }
118
+ this.lunoraName = exportName ?? "container";
119
+ this.lunoraJurisdiction = jurisdiction;
120
+ this.lunoraDefaultPort = definition.defaultPort;
121
+ this.lunoraReadyOn = definition.readyOn ? [...definition.readyOn] : [];
122
+ this.lunoraHardTimeoutSeconds = definition.hardTimeout === void 0 ? void 0 : parseDurationSeconds(definition.hardTimeout);
123
+ this.lunoraSecretsStore = definition.secretsStore;
124
+ }
125
+ /**
126
+ * Proxy entry for every `ctx.containers.<name>` fetch. Resolves the
127
+ * `secretsStore` bindings into `envVars` before delegating, so the values
128
+ * are present when the base implicitly starts the container for this
129
+ * request — a no-op when `secretsStore` is unset.
130
+ */
131
+ async containerFetch(...args) {
132
+ await this.resolveSecretsStoreEnv();
133
+ return super.containerFetch(...args);
134
+ }
135
+ /**
136
+ * Explicit start (`ctx.containers.<name>.get(id).start()`). Resolves the
137
+ * `secretsStore` bindings into `envVars` first, mirroring
138
+ * {@link containerFetch}. A per-instance `start({ envVars })` replaces the
139
+ * env set wholesale (base behavior), so the injected values only apply to a
140
+ * bare `start()` — same as the static `env`/`secrets`. When the caller
141
+ * supplies its own `envVars` we skip resolution entirely: those values would
142
+ * be discarded anyway, so a missing/unreadable binding shouldn't fail a start
143
+ * that never uses them.
144
+ */
145
+ async start(...args) {
146
+ const [options] = args;
147
+ if (options?.envVars === void 0) {
148
+ await this.resolveSecretsStoreEnv();
149
+ }
150
+ return super.start(...args);
151
+ }
152
+ async onActivityExpired() {
153
+ const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "sleep");
154
+ this.surfaceInStudioLogs(envelope);
155
+ await super.onActivityExpired();
156
+ }
157
+ onError(error) {
158
+ const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "error", error instanceof Error ? error.message : String(error));
159
+ this.surfaceInStudioLogs(envelope);
160
+ return super.onError(error);
161
+ }
162
+ async onStart() {
163
+ const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "start");
164
+ this.surfaceInStudioLogs(envelope);
165
+ await super.onStart();
166
+ await this.armHardTimeout();
167
+ await this.awaitContainerReadiness();
168
+ }
169
+ /**
170
+ * Hook run when the container's `hardTimeout` elapses (dispatched by the base
171
+ * scheduler via the run-generation-stamped schedule armed in
172
+ * {@link onStart}). Default: stop the instance. Override to drain/checkpoint
173
+ * first. A stale schedule from a previous run, or an already-stopped
174
+ * instance, is ignored (upstream cloudflare/containers#85).
175
+ */
176
+ async onHardTimeoutExpired(payload) {
177
+ const current = await this.ctx.storage.get(HARD_TIMEOUT_GENERATION_KEY);
178
+ if (payload?.generation !== void 0 && payload.generation !== current) {
179
+ return;
180
+ }
181
+ if (this.ctx.container?.running !== true) {
182
+ return;
183
+ }
184
+ const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "stop", "hard timeout reached");
185
+ this.surfaceInStudioLogs(envelope);
186
+ await this.stop();
187
+ }
188
+ async onStop(parameters) {
189
+ const envelope = emitContainerLifecycle(this.lunoraName, this.instanceId(), "stop", `${parameters.reason} (exit ${String(parameters.exitCode)})`);
190
+ this.surfaceInStudioLogs(envelope);
191
+ await super.onStop(parameters);
192
+ }
193
+ /**
194
+ * Arm the hard-timeout kill via the base scheduler (so it integrates with
195
+ * the container's own alarm machinery instead of fighting it). Bumps the run
196
+ * generation and stamps the schedule with it, so {@link onHardTimeoutExpired}
197
+ * can tell a fresh schedule from a stale one. No-op without a `hardTimeout`.
198
+ */
199
+ async armHardTimeout() {
200
+ if (this.lunoraHardTimeoutSeconds === void 0) {
201
+ return;
202
+ }
203
+ const generation = (await this.ctx.storage.get(HARD_TIMEOUT_GENERATION_KEY) ?? 0) + 1;
204
+ await this.ctx.storage.put(HARD_TIMEOUT_GENERATION_KEY, generation);
205
+ await this.schedule(this.lunoraHardTimeoutSeconds, "onHardTimeoutExpired", { generation });
206
+ }
207
+ /**
208
+ * Resolve the `secretsStore` bindings (async `.get()`) once and merge the
209
+ * values into `envVars`, so they're present when the base starts the
210
+ * container. Memoised on the first call — every later start reuses the
211
+ * resolved promise. A missing binding or a non-string value fails fast (the
212
+ * start surfaces the error), the same fail-closed stance the static
213
+ * `secrets` resolution takes for a missing Worker secret. No-op without
214
+ * `secretsStore`.
215
+ */
216
+ async resolveSecretsStoreEnv() {
217
+ const secretsStore = this.lunoraSecretsStore;
218
+ if (secretsStore === void 0) {
219
+ return;
220
+ }
221
+ this.lunoraSecretsStoreResolved ??= (async () => {
222
+ const workerEnv = this.env;
223
+ const resolved = {};
224
+ for (const [envName, binding] of Object.entries(secretsStore)) {
225
+ const store = workerEnv[binding];
226
+ if (store === void 0 || typeof store.get !== "function") {
227
+ throw new LunoraError(
228
+ "INTERNAL",
229
+ `container "${this.lunoraName}": secretsStore env "${envName}" points at binding "${binding}", which is not a Secrets Store binding on the Worker env. Add a \`secrets_store_secrets\` entry binding "${binding}".`
230
+ );
231
+ }
232
+ const value = await store.get();
233
+ if (typeof value !== "string") {
234
+ throw new TypeError(
235
+ `container "${this.lunoraName}": Secrets Store binding "${binding}" (env "${envName}") did not resolve to a string value.`
236
+ );
237
+ }
238
+ resolved[envName] = value;
239
+ }
240
+ this.envVars = { ...this.envVars, ...resolved };
241
+ })().catch((error) => {
242
+ this.lunoraSecretsStoreResolved = void 0;
243
+ throw error;
244
+ });
245
+ await this.lunoraSecretsStoreResolved;
246
+ }
247
+ /**
248
+ * Block until every `readyOn` probe responds with its expected status, or
249
+ * throw once the readiness budget is spent. Probes run in parallel and hit
250
+ * the container's TCP port directly (NOT `containerFetch`, which would
251
+ * recurse back into the start path). No-op without `readyOn`.
252
+ */
253
+ async awaitContainerReadiness() {
254
+ if (this.lunoraReadyOn.length === 0) {
255
+ return;
256
+ }
257
+ const { container } = this.ctx;
258
+ if (container === void 0) {
259
+ return;
260
+ }
261
+ const deadline = Date.now() + READINESS_TIMEOUT_MS;
262
+ await Promise.all(this.lunoraReadyOn.map(async (check) => this.awaitReadinessCheck(container, check, deadline)));
263
+ }
264
+ /** Poll one readiness probe until it returns its expected status or the shared deadline passes. */
265
+ async awaitReadinessCheck(container, check, deadline) {
266
+ const port = check.port ?? this.lunoraDefaultPort;
267
+ if (port === void 0) {
268
+ throw new LunoraError(
269
+ "INTERNAL",
270
+ `container "${this.lunoraName}": readyOn check "${check.path}" has no port — set the check's \`port\` or the container \`defaultPort\`.`
271
+ );
272
+ }
273
+ const expectedStatus = check.status ?? 200;
274
+ const path = check.path.startsWith("/") ? check.path : `/${check.path}`;
275
+ const tcpPort = container.getTcpPort(port);
276
+ for (; ; ) {
277
+ try {
278
+ const response = await tcpPort.fetch(`http://container${path}`);
279
+ if (response.status === expectedStatus) {
280
+ return;
281
+ }
282
+ } catch {
283
+ }
284
+ if (Date.now() >= deadline) {
285
+ throw new LunoraError(
286
+ "INTERNAL",
287
+ `container "${this.lunoraName}": readiness check "${check.path}" (port ${String(port)}) did not return ${String(expectedStatus)} within ${String(READINESS_TIMEOUT_MS)}ms`
288
+ );
289
+ }
290
+ await new Promise((resolve) => {
291
+ setTimeout(resolve, READINESS_POLL_INTERVAL_MS);
292
+ });
293
+ }
294
+ }
295
+ /**
296
+ * Best-effort push of `envelope` into the root ShardDO's log buffer so it
297
+ * also appears in the Studio Logs panel (the terminal already has it via
298
+ * `emitContainerLifecycle`). Fire-and-forget and fully swallowed: a missing
299
+ * `SHARD` binding, a missing admin token, or a fetch failure NEVER throws
300
+ * out of a lifecycle hook — the `console` path stays the source of truth.
301
+ */
302
+ surfaceInStudioLogs(envelope) {
303
+ reportContainerLifecycle(this.env, envelope, this.lunoraJurisdiction).catch(() => {
304
+ });
305
+ }
306
+ /**
307
+ * Per-instance correlation id: the Durable Object id, which Cloudflare also
308
+ * injects into the container as `CLOUDFLARE_DURABLE_OBJECT_ID`. Read
309
+ * defensively — the id shape varies and isn't worth crashing a hook over.
310
+ */
311
+ instanceId() {
312
+ try {
313
+ const { id } = this.ctx;
314
+ return typeof id?.toString === "function" ? id.toString() : "unknown";
315
+ } catch {
316
+ return "unknown";
317
+ }
318
+ }
319
+ }
320
+
321
+ export { LunoraContainer };
@@ -0,0 +1,277 @@
1
+ import { D as DurableObjectJurisdiction, C as ContainerConfig, a as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/jurisdiction.d-TwTGkgTg.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/jurisdiction.d-TwTGkgTg.mjs";
3
+ /** Options for explicitly starting an instance (mirrors `@cloudflare/containers`). */
4
+ interface ContainerStartOptions {
5
+ /** Override outbound internet access for this start. */
6
+ enableInternet?: boolean;
7
+ /** Override the container entrypoint. */
8
+ entrypoint?: string[];
9
+ /** Per-instance environment, merged over the definition's `env`/secrets. */
10
+ envVars?: Record<string, string>;
11
+ /** Metadata labels attached for metrics/observability. */
12
+ labels?: Record<string, string>;
13
+ }
14
+ /** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */
15
+ interface ContainerInstanceState {
16
+ [key: string]: unknown;
17
+ /** Process exit code, present once the instance has `stopped_with_code`. */
18
+ exitCode?: number;
19
+ /** Epoch-ms of the last state transition. */
20
+ lastChange?: number;
21
+ /** Lifecycle status. Widening union — Cloudflare adds values over time. */
22
+ status?: "healthy" | "running" | "stopped" | "stopped_with_code" | "stopping";
23
+ }
24
+ /** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle/egress RPCs the container DO exposes. */
25
+ interface ContainerStubLike {
26
+ allowHost?: (hostname: string) => Promise<void>;
27
+ denyHost?: (hostname: string) => Promise<void>;
28
+ destroy?: () => Promise<void>;
29
+ fetch: (input: Request) => Promise<Response>;
30
+ getState?: () => Promise<ContainerInstanceState>;
31
+ removeAllowedHost?: (hostname: string) => Promise<void>;
32
+ removeDeniedHost?: (hostname: string) => Promise<void>;
33
+ renewActivityTimeout?: () => Promise<void>;
34
+ setAllowedHosts?: (hosts: string[]) => Promise<void>;
35
+ setDeniedHosts?: (hosts: string[]) => Promise<void>;
36
+ start?: (options?: ContainerStartOptions) => Promise<void>;
37
+ stop?: (signal?: number | string) => Promise<void>;
38
+ }
39
+ /** What the client needs from a Durable Object namespace binding. */
40
+ interface ContainerNamespaceLike {
41
+ get: (id: unknown) => ContainerStubLike;
42
+ idFromName: (name: string) => unknown;
43
+ /**
44
+ * Derive a jurisdiction-restricted subnamespace. Optional because older
45
+ * workers-types releases (and test doubles) may not expose it.
46
+ */
47
+ jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => ContainerNamespaceLike;
48
+ }
49
+ /** A handle on one container instance (one Durable Object). */
50
+ interface ContainerHandle {
51
+ /**
52
+ * Send an HTTP (or WebSocket-upgrade) request to the container. A path
53
+ * string (`"/transcode"`) is resolved against a synthetic origin; a full
54
+ * `Request`/URL passes through unchanged.
55
+ */
56
+ fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
57
+ /**
58
+ * Return a handle that routes every request to `targetPort` on the
59
+ * container instead of the definition's `defaultPort` — for multi-port
60
+ * containers (declare the ports in `requiredPorts`). Sets the
61
+ * `cf-container-target-port` header the way `@cloudflare/containers`'
62
+ * `switchPort` does, so it composes with `.get()`, `.any()`, and `.pool()`:
63
+ * `ctx.containers.app.get("u1").port(9090).fetch("/admin")`.
64
+ */
65
+ port: (targetPort: number) => ContainerHandle;
66
+ }
67
+ /**
68
+ * A handle on a *named* instance (from `.get(name)`) — `fetch` plus explicit
69
+ * lifecycle control. The per-entity pattern (a sandbox per user, a room per
70
+ * game, a job runner per id) often needs to tear down or inspect the instance
71
+ * rather than wait for `sleepAfter`, so these wrap the container DO's
72
+ * `start`/`stop`/`destroy`/`getState`.
73
+ */
74
+ interface ContainerInstanceHandle extends ContainerHandle {
75
+ /** Stop and discard the instance (its ephemeral disk is lost). */
76
+ destroy: () => Promise<void>;
77
+ /**
78
+ * Adjust this instance's egress allow/deny lists at runtime — the dynamic
79
+ * counterpart to the static `allowedHosts`/`deniedHosts` config. Useful for
80
+ * per-tenant egress policy. Requires the worker to export `ContainerProxy`
81
+ * (codegen re-exports it from the generated container file whenever any
82
+ * container is defined, so the runtime controls always work).
83
+ */
84
+ egress: ContainerEgressControls;
85
+ /** Read the instance's current runtime state. */
86
+ getState: () => Promise<ContainerInstanceState>;
87
+ /**
88
+ * Reset the instance's `sleepAfter` idle timer. The platform renews it on
89
+ * each proxied request, and because `@lunora/container` proxies WebSocket
90
+ * frames through the Durable Object, message traffic on an open socket
91
+ * renews it too (the WebSocket-keepalive gap of cloudflare/containers#147 is
92
+ * closed in the bundled base). This manual control is the escape hatch for
93
+ * keeping a container awake during activity that is neither an HTTP request
94
+ * nor a WS message — e.g. a long out-of-band job running inside it.
95
+ */
96
+ renewActivityTimeout: () => Promise<void>;
97
+ /** Explicitly start the instance, optionally with per-instance env/entrypoint. */
98
+ start: (options?: ContainerStartOptions) => Promise<void>;
99
+ /** Stop the instance (optionally with a signal); it can start again on the next request. */
100
+ stop: (signal?: number | string) => Promise<void>;
101
+ }
102
+ /**
103
+ * Runtime egress-firewall controls for a named instance (`handle.egress.*`).
104
+ * Each maps to the corresponding `@cloudflare/containers` `Container` RPC, so
105
+ * an app can tighten or relax a single instance's allowed/denied hosts after
106
+ * start without redeploying.
107
+ */
108
+ interface ContainerEgressControls {
109
+ /** Add one hostname (or glob) to the allow-list. */
110
+ allow: (hostname: string) => Promise<void>;
111
+ /** Add one hostname (or glob) to the deny-list. */
112
+ deny: (hostname: string) => Promise<void>;
113
+ /** Remove one hostname from the allow-list. */
114
+ removeAllowed: (hostname: string) => Promise<void>;
115
+ /** Remove one hostname from the deny-list. */
116
+ removeDenied: (hostname: string) => Promise<void>;
117
+ /** Replace the entire allow-list. */
118
+ setAllowed: (hosts: ReadonlyArray<string>) => Promise<void>;
119
+ /** Replace the entire deny-list. */
120
+ setDenied: (hosts: ReadonlyArray<string>) => Promise<void>;
121
+ }
122
+ /** The per-definition accessor exposed as `ctx.containers.&lt;exportName>`. */
123
+ interface ContainerAccessor {
124
+ /**
125
+ * A random instance from a fixed pool of `count` (defaults to the
126
+ * definition's `maxInstances`, else 3 — mirroring `getRandom` from
127
+ * `@cloudflare/containers`). For stateless, interchangeable workloads.
128
+ *
129
+ * Like `.get()`, a path/URL-string fetch transparently retries the
130
+ * cold-start "instance is provisioning" transients (cloudflare/containers#45,
131
+ * #139); pass {@link InstanceRetryOptions} to tune or disable it.
132
+ */
133
+ any: (count?: number, options?: InstanceRetryOptions) => ContainerHandle;
134
+ /**
135
+ * The instance for `name` — one container per entity (user, room, job…),
136
+ * with lifecycle control.
137
+ *
138
+ * A path/URL-string fetch transparently retries the platform's cold-start
139
+ * transients — "there is no Container instance available" / "container is
140
+ * not listening" while an instance is still provisioning
141
+ * (cloudflare/containers#45, #139) — on the *same* instance with backoff,
142
+ * since the request never reached the app. Pass {@link InstanceRetryOptions}
143
+ * to tune attempts/backoff or disable it (`{ attempts: 1 }`). A pre-built
144
+ * `Request` (possibly a one-shot stream body) is sent once, never retried.
145
+ */
146
+ get: (name: string, options?: InstanceRetryOptions) => ContainerInstanceHandle;
147
+ /**
148
+ * A resilient handle over the pool: each `fetch` picks a random instance and,
149
+ * on a thrown error or a retryable response (5xx by default), retries on a
150
+ * freshly-picked instance with exponential backoff. Until Cloudflare ships
151
+ * native autoscaling + health-aware routing this is the recommended way to
152
+ * call a stateless container pool — it rides over a single cold/unhealthy
153
+ * instance instead of failing the whole request.
154
+ *
155
+ * Because a retry re-issues the request, pass a **replayable** body — a path
156
+ * string plus an `init.body` string/`ArrayBuffer` (re-created each attempt).
157
+ * A pre-built `Request` carrying a stream body can only be sent once, so it
158
+ * is not retry-safe here; use `.get()`/`.any()` for those.
159
+ */
160
+ pool: (options?: PoolOptions) => ContainerHandle;
161
+ }
162
+ /** Tuning for a pooled, retrying container handle. See {@link ContainerAccessor.pool}. */
163
+ interface PoolOptions {
164
+ /** Total attempts before giving up (each on a freshly-picked instance). Default 3. */
165
+ attempts?: number;
166
+ /** Base backoff in ms between attempts; doubles each retry (0 disables the wait). Default 100. */
167
+ backoffMs?: number;
168
+ /**
169
+ * Upper bound on a single backoff sleep, in ms. The doubling delay is clamped
170
+ * to this ceiling so a large `attempts` count can't produce an unboundedly
171
+ * long wait. Default {@link DEFAULT_MAX_BACKOFF_MS} (30s).
172
+ */
173
+ maxBackoffMs?: number;
174
+ /**
175
+ * Whether a *returned* response should be retried on another instance.
176
+ * Defaults to retrying any `5xx`. A thrown error (network/start failure) is
177
+ * always retried regardless of this predicate.
178
+ */
179
+ retryOn?: (response: Response) => boolean;
180
+ /** Pool size to spread picks across. Defaults to the definition's `maxInstances`, else 3. */
181
+ size?: number;
182
+ }
183
+ /**
184
+ * Tuning for the cold-start retry on a `.get()`/`.any()` handle. The retry fires
185
+ * only on the platform's provisioning transients (no-instance / not-listening /
186
+ * rate-limited — see {@link isColdStartTransient}), which is why it's safe by
187
+ * default: those responses mean the request never reached the container.
188
+ */
189
+ interface InstanceRetryOptions {
190
+ /**
191
+ * Total attempts on a cold-start transient before the last outcome is
192
+ * surfaced as-is. `1` disables the retry. Default
193
+ * {@link DEFAULT_COLD_START_ATTEMPTS}.
194
+ */
195
+ attempts?: number;
196
+ /** Base backoff in ms between attempts; doubles each retry (0 disables the wait). Default {@link DEFAULT_COLD_START_BACKOFF_MS}. */
197
+ backoffMs?: number;
198
+ /** Upper bound on a single backoff sleep, in ms. Default {@link DEFAULT_MAX_BACKOFF_MS} (30s). */
199
+ maxBackoffMs?: number;
200
+ }
201
+ /** Wiring info for one definition, emitted by codegen into the generated DO. */
202
+ interface ContainerBindingSpec {
203
+ /** Durable Object binding name, e.g. `CONTAINER_TRANSCODER`. */
204
+ binding: string;
205
+ /** The `lunora/containers.ts` export name, e.g. `transcoder`. */
206
+ exportName: string;
207
+ /** Pool size default for `.any()`. */
208
+ maxInstances?: number;
209
+ }
210
+ /**
211
+ * Build the `ctx.containers` record from the Worker `env`. Called by the
212
+ * generated ShardDO with the specs codegen derived from
213
+ * `lunora/containers.ts`. A missing binding doesn't throw here — only when the
214
+ * handle is actually used — so one unprovisioned container never breaks
215
+ * unrelated functions.
216
+ */
217
+ declare const createContainerContext: (env: Record<string, unknown>, specs: ReadonlyArray<ContainerBindingSpec>, jurisdiction?: DurableObjectJurisdiction) => Record<string, ContainerAccessor>;
218
+ /** A test handler: receives the request plus the targeted instance name. */
219
+ type ContainerTestHandler = (request: Request, instance: {
220
+ name: string;
221
+ }) => Promise<Response> | Response;
222
+ /**
223
+ * Docker-free test double for `ctx.containers`: each export name maps to a
224
+ * fetch handler that plays the container. Mirrors the real shape exactly, so
225
+ * action handlers under test can't tell the difference.
226
+ *
227
+ * ```ts
228
+ * const containers = createContainerTestContext({
229
+ * transcoder: (request) => new Response("ok"),
230
+ * });
231
+ * ```
232
+ */
233
+ declare const createContainerTestContext: (handlers: Record<string, ContainerTestHandler>) => Record<string, ContainerAccessor>;
234
+ /**
235
+ * Normalize a `ContainerImageSource` into the shape wrangler wants: a
236
+ * Dockerfile path + build context for local builds, or a fully-qualified
237
+ * reference for pre-built images.
238
+ *
239
+ * A local-path string whose basename starts with `Dockerfile` (so
240
+ * `Dockerfile.dev` also counts) is used as-is with its directory as the build
241
+ * context; any other path is treated as the build-context directory and the
242
+ * Dockerfile is expected at `&lt;dir>/Dockerfile`.
243
+ */
244
+ declare const normalizeContainerImage: (image: ContainerImageSource) => NormalizedContainerImage;
245
+ /**
246
+ * The generated Container DO class name for a `lunora/containers.ts` export:
247
+ * `transcoder` → `TranscoderContainer`. wrangler's `containers[].class_name`
248
+ * and the Durable Object binding's `class_name` both reference it, so codegen
249
+ * and the config layer MUST derive it identically — always via this helper.
250
+ */
251
+ declare const containerClassName: (exportName: string) => string;
252
+ /**
253
+ * The Durable Object binding name for a container export: `transcoder` →
254
+ * `CONTAINER_TRANSCODER`, `imageResizer` → `CONTAINER_IMAGE_RESIZER`. The
255
+ * `CONTAINER_` prefix namespaces these away from `SHARD`/`SESSION`/`SCHEDULER`
256
+ * so a container export can never collide with the built-in bindings.
257
+ */
258
+ declare const containerBindingName: (exportName: string) => string;
259
+ /**
260
+ * The local image tag a Railpack `{ build }` container is built and pushed
261
+ * under: `transcoder` → `lunora-transcoder:build`. The config reconciler writes
262
+ * it as the wrangler `containers[].image`, and `lunora deploy` builds that tag
263
+ * with Railpack and `wrangler containers push`es it before deploying — so all
264
+ * three derive the tag from this one helper and can never disagree.
265
+ */
266
+ declare const containerBuildTag: (exportName: string) => string;
267
+ declare const defineContainer: (config: ContainerConfig) => ContainerDefinition;
268
+ /** True when a value is a `defineContainer` result (the runtime brand check). */
269
+ declare const isContainerDefinition: (value: unknown) => value is ContainerDefinition;
270
+ /**
271
+ * The container's full environment at instance start: the static `env` block
272
+ * plus every declared secret resolved from the Worker `env`. A declared secret
273
+ * missing from the Worker env fails fast — starting the container without a
274
+ * credential it was promised yields far worse errors downstream.
275
+ */
276
+ declare const resolveContainerEnvVariables: (definition: ContainerDefinition, workerEnv: Record<string, unknown>, exportName?: string) => Record<string, string>;
277
+ 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 InstanceRetryOptions, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };