@lunora/container 0.0.0 → 1.0.0-alpha.2
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/LICENSE.md +105 -0
- package/README.md +153 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/bridge.d.mts +90 -0
- package/dist/bridge.d.ts +90 -0
- package/dist/bridge.mjs +76 -0
- package/dist/do/index.d.mts +63 -0
- package/dist/do/index.d.ts +63 -0
- package/dist/do/index.mjs +138 -0
- package/dist/index.d.mts +200 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.mjs +2 -0
- package/dist/packem_shared/containerBindingName-BGdSdFNA.mjs +116 -0
- package/dist/packem_shared/createContainerContext-CTpyUQ4J.mjs +133 -0
- package/dist/packem_shared/types.d-D2l2SYol.d.mts +140 -0
- package/dist/packem_shared/types.d-D2l2SYol.d.ts +140 -0
- package/package.json +46 -15
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
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";
|
|
3
|
+
/**
|
|
4
|
+
* The `ctx.containers` action surface: typed handles over the `CONTAINER_*`
|
|
5
|
+
* Durable Object namespace bindings the config layer reconciles.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately structural (no `@cloudflare/containers` import): a Durable
|
|
8
|
+
* Object namespace stub is all that is needed to route a request to a
|
|
9
|
+
* container-enabled DO, so this module stays Node-safe and the test double
|
|
10
|
+
* below can satisfy the exact same shape without a workerd runtime.
|
|
11
|
+
*/
|
|
12
|
+
/** Options for explicitly starting an instance (mirrors `@cloudflare/containers`). */
|
|
13
|
+
interface ContainerStartOptions {
|
|
14
|
+
/** Override outbound internet access for this start. */
|
|
15
|
+
enableInternet?: boolean;
|
|
16
|
+
/** Override the container entrypoint. */
|
|
17
|
+
entrypoint?: string[];
|
|
18
|
+
/** Per-instance environment, merged over the definition's `env`/secrets. */
|
|
19
|
+
envVars?: Record<string, string>;
|
|
20
|
+
/** Metadata labels attached for metrics/observability. */
|
|
21
|
+
labels?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
/** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */
|
|
24
|
+
interface ContainerInstanceState {
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
lastChange?: number;
|
|
27
|
+
}
|
|
28
|
+
/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle RPCs the container DO exposes. */
|
|
29
|
+
interface ContainerStubLike {
|
|
30
|
+
destroy?: () => Promise<void>;
|
|
31
|
+
fetch: (input: Request) => Promise<Response>;
|
|
32
|
+
getState?: () => Promise<ContainerInstanceState>;
|
|
33
|
+
start?: (options?: ContainerStartOptions) => Promise<void>;
|
|
34
|
+
stop?: (signal?: number | string) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Cloudflare Durable Object data-residency jurisdiction. Widening union —
|
|
38
|
+
* Cloudflare adds values over time.
|
|
39
|
+
* @see https://developers.cloudflare.com/durable-objects/reference/data-location/
|
|
40
|
+
*/
|
|
41
|
+
type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
|
|
42
|
+
/** What the client needs from a Durable Object namespace binding. */
|
|
43
|
+
interface ContainerNamespaceLike {
|
|
44
|
+
get: (id: unknown) => ContainerStubLike;
|
|
45
|
+
idFromName: (name: string) => unknown;
|
|
46
|
+
/**
|
|
47
|
+
* Derive a jurisdiction-restricted subnamespace. Optional because older
|
|
48
|
+
* workers-types releases (and test doubles) may not expose it.
|
|
49
|
+
*/
|
|
50
|
+
jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => ContainerNamespaceLike;
|
|
51
|
+
}
|
|
52
|
+
/** A handle on one container instance (one Durable Object). */
|
|
53
|
+
interface ContainerHandle {
|
|
54
|
+
/**
|
|
55
|
+
* Send an HTTP (or WebSocket-upgrade) request to the container. A path
|
|
56
|
+
* string (`"/transcode"`) is resolved against a synthetic origin; a full
|
|
57
|
+
* `Request`/URL passes through unchanged.
|
|
58
|
+
*/
|
|
59
|
+
fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A handle on a *named* instance (from `.get(name)`) — `fetch` plus explicit
|
|
63
|
+
* lifecycle control. The per-entity pattern (a sandbox per user, a room per
|
|
64
|
+
* game, a job runner per id) often needs to tear down or inspect the instance
|
|
65
|
+
* rather than wait for `sleepAfter`, so these wrap the container DO's
|
|
66
|
+
* `start`/`stop`/`destroy`/`getState`.
|
|
67
|
+
*/
|
|
68
|
+
interface ContainerInstanceHandle extends ContainerHandle {
|
|
69
|
+
/** Stop and discard the instance (its ephemeral disk is lost). */
|
|
70
|
+
destroy: () => Promise<void>;
|
|
71
|
+
/** Read the instance's current runtime state. */
|
|
72
|
+
getState: () => Promise<ContainerInstanceState>;
|
|
73
|
+
/** Explicitly start the instance, optionally with per-instance env/entrypoint. */
|
|
74
|
+
start: (options?: ContainerStartOptions) => Promise<void>;
|
|
75
|
+
/** Stop the instance (optionally with a signal); it can start again on the next request. */
|
|
76
|
+
stop: (signal?: number | string) => Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/** The per-definition accessor exposed as `ctx.containers.<exportName>`. */
|
|
79
|
+
interface ContainerAccessor {
|
|
80
|
+
/**
|
|
81
|
+
* A random instance from a fixed pool of `count` (defaults to the
|
|
82
|
+
* definition's `maxInstances`, else 3 — mirroring `getRandom` from
|
|
83
|
+
* `@cloudflare/containers`). For stateless, interchangeable workloads.
|
|
84
|
+
*/
|
|
85
|
+
any: (count?: number) => ContainerHandle;
|
|
86
|
+
/** The instance for `name` — one container per entity (user, room, job…), with lifecycle control. */
|
|
87
|
+
get: (name: string) => ContainerInstanceHandle;
|
|
88
|
+
/**
|
|
89
|
+
* A resilient handle over the pool: each `fetch` picks a random instance and,
|
|
90
|
+
* on a thrown error or a retryable response (5xx by default), retries on a
|
|
91
|
+
* freshly-picked instance with exponential backoff. Until Cloudflare ships
|
|
92
|
+
* native autoscaling + health-aware routing this is the recommended way to
|
|
93
|
+
* call a stateless container pool — it rides over a single cold/unhealthy
|
|
94
|
+
* instance instead of failing the whole request.
|
|
95
|
+
*
|
|
96
|
+
* Because a retry re-issues the request, pass a **replayable** body — a path
|
|
97
|
+
* string plus an `init.body` string/`ArrayBuffer` (re-created each attempt).
|
|
98
|
+
* A pre-built `Request` carrying a stream body can only be sent once, so it
|
|
99
|
+
* is not retry-safe here; use `.get()`/`.any()` for those.
|
|
100
|
+
*/
|
|
101
|
+
pool: (options?: PoolOptions) => ContainerHandle;
|
|
102
|
+
}
|
|
103
|
+
/** Tuning for a pooled, retrying container handle. See {@link ContainerAccessor.pool}. */
|
|
104
|
+
interface PoolOptions {
|
|
105
|
+
/** Total attempts before giving up (each on a freshly-picked instance). Default 3. */
|
|
106
|
+
attempts?: number;
|
|
107
|
+
/** Base backoff in ms between attempts; doubles each retry (0 disables the wait). Default 100. */
|
|
108
|
+
backoffMs?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Upper bound on a single backoff sleep, in ms. The doubling delay is clamped
|
|
111
|
+
* to this ceiling so a large `attempts` count can't produce an unboundedly
|
|
112
|
+
* long wait. Default {@link DEFAULT_MAX_BACKOFF_MS} (30s).
|
|
113
|
+
*/
|
|
114
|
+
maxBackoffMs?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Whether a *returned* response should be retried on another instance.
|
|
117
|
+
* Defaults to retrying any `5xx`. A thrown error (network/start failure) is
|
|
118
|
+
* always retried regardless of this predicate.
|
|
119
|
+
*/
|
|
120
|
+
retryOn?: (response: Response) => boolean;
|
|
121
|
+
/** Pool size to spread picks across. Defaults to the definition's `maxInstances`, else 3. */
|
|
122
|
+
size?: number;
|
|
123
|
+
}
|
|
124
|
+
/** Wiring info for one definition, emitted by codegen into the generated DO. */
|
|
125
|
+
interface ContainerBindingSpec {
|
|
126
|
+
/** Durable Object binding name, e.g. `CONTAINER_TRANSCODER`. */
|
|
127
|
+
binding: string;
|
|
128
|
+
/** The `lunora/containers.ts` export name, e.g. `transcoder`. */
|
|
129
|
+
exportName: string;
|
|
130
|
+
/** Pool size default for `.any()`. */
|
|
131
|
+
maxInstances?: number;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the `ctx.containers` record from the Worker `env`. Called by the
|
|
135
|
+
* generated ShardDO with the specs codegen derived from
|
|
136
|
+
* `lunora/containers.ts`. A missing binding doesn't throw here — only when the
|
|
137
|
+
* handle is actually used — so one unprovisioned container never breaks
|
|
138
|
+
* unrelated functions.
|
|
139
|
+
*/
|
|
140
|
+
declare const createContainerContext: (env: Record<string, unknown>, specs: ReadonlyArray<ContainerBindingSpec>, jurisdiction?: DurableObjectJurisdiction) => Record<string, ContainerAccessor>;
|
|
141
|
+
/** A test handler: receives the request plus the targeted instance name. */
|
|
142
|
+
type ContainerTestHandler = (request: Request, instance: {
|
|
143
|
+
name: string;
|
|
144
|
+
}) => Promise<Response> | Response;
|
|
145
|
+
/**
|
|
146
|
+
* Docker-free test double for `ctx.containers`: each export name maps to a
|
|
147
|
+
* fetch handler that plays the container. Mirrors the real shape exactly, so
|
|
148
|
+
* action handlers under test can't tell the difference.
|
|
149
|
+
*
|
|
150
|
+
* ```ts
|
|
151
|
+
* const containers = createContainerTestContext({
|
|
152
|
+
* transcoder: (request) => new Response("ok"),
|
|
153
|
+
* });
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
declare const createContainerTestContext: (handlers: Record<string, ContainerTestHandler>) => Record<string, ContainerAccessor>;
|
|
157
|
+
/**
|
|
158
|
+
* Normalize a `ContainerImageSource` into the shape wrangler wants: a
|
|
159
|
+
* Dockerfile path + build context for local builds, or a fully-qualified
|
|
160
|
+
* reference for pre-built images.
|
|
161
|
+
*
|
|
162
|
+
* A local-path string whose basename starts with `Dockerfile` (so
|
|
163
|
+
* `Dockerfile.dev` also counts) is used as-is with its directory as the build
|
|
164
|
+
* context; any other path is treated as the build-context directory and the
|
|
165
|
+
* Dockerfile is expected at `<dir>/Dockerfile`.
|
|
166
|
+
*/
|
|
167
|
+
declare const normalizeContainerImage: (image: ContainerImageSource) => NormalizedContainerImage;
|
|
168
|
+
/**
|
|
169
|
+
* The generated Container DO class name for a `lunora/containers.ts` export:
|
|
170
|
+
* `transcoder` → `TranscoderContainer`. wrangler's `containers[].class_name`
|
|
171
|
+
* and the Durable Object binding's `class_name` both reference it, so codegen
|
|
172
|
+
* and the config layer MUST derive it identically — always via this helper.
|
|
173
|
+
*/
|
|
174
|
+
declare const containerClassName: (exportName: string) => string;
|
|
175
|
+
/**
|
|
176
|
+
* The Durable Object binding name for a container export: `transcoder` →
|
|
177
|
+
* `CONTAINER_TRANSCODER`, `imageResizer` → `CONTAINER_IMAGE_RESIZER`. The
|
|
178
|
+
* `CONTAINER_` prefix namespaces these away from `SHARD`/`SESSION`/`SCHEDULER`
|
|
179
|
+
* so a container export can never collide with the built-in bindings.
|
|
180
|
+
*/
|
|
181
|
+
declare const containerBindingName: (exportName: string) => string;
|
|
182
|
+
/**
|
|
183
|
+
* The local image tag a Railpack `{ build }` container is built and pushed
|
|
184
|
+
* under: `transcoder` → `lunora-transcoder:build`. The config reconciler writes
|
|
185
|
+
* it as the wrangler `containers[].image`, and `lunora deploy` builds that tag
|
|
186
|
+
* with Railpack and `wrangler containers push`es it before deploying — so all
|
|
187
|
+
* three derive the tag from this one helper and can never disagree.
|
|
188
|
+
*/
|
|
189
|
+
declare const containerBuildTag: (exportName: string) => string;
|
|
190
|
+
declare const defineContainer: (config: ContainerConfig) => ContainerDefinition;
|
|
191
|
+
/** True when a value is a `defineContainer` result (the runtime brand check). */
|
|
192
|
+
declare const isContainerDefinition: (value: unknown) => value is ContainerDefinition;
|
|
193
|
+
/**
|
|
194
|
+
* The container's full environment at instance start: the static `env` block
|
|
195
|
+
* plus every declared secret resolved from the Worker `env`. A declared secret
|
|
196
|
+
* missing from the Worker env fails fast — starting the container without a
|
|
197
|
+
* credential it was promised yields far worse errors downstream.
|
|
198
|
+
*/
|
|
199
|
+
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 };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
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";
|
|
3
|
+
/**
|
|
4
|
+
* The `ctx.containers` action surface: typed handles over the `CONTAINER_*`
|
|
5
|
+
* Durable Object namespace bindings the config layer reconciles.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately structural (no `@cloudflare/containers` import): a Durable
|
|
8
|
+
* Object namespace stub is all that is needed to route a request to a
|
|
9
|
+
* container-enabled DO, so this module stays Node-safe and the test double
|
|
10
|
+
* below can satisfy the exact same shape without a workerd runtime.
|
|
11
|
+
*/
|
|
12
|
+
/** Options for explicitly starting an instance (mirrors `@cloudflare/containers`). */
|
|
13
|
+
interface ContainerStartOptions {
|
|
14
|
+
/** Override outbound internet access for this start. */
|
|
15
|
+
enableInternet?: boolean;
|
|
16
|
+
/** Override the container entrypoint. */
|
|
17
|
+
entrypoint?: string[];
|
|
18
|
+
/** Per-instance environment, merged over the definition's `env`/secrets. */
|
|
19
|
+
envVars?: Record<string, string>;
|
|
20
|
+
/** Metadata labels attached for metrics/observability. */
|
|
21
|
+
labels?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
/** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */
|
|
24
|
+
interface ContainerInstanceState {
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
lastChange?: number;
|
|
27
|
+
}
|
|
28
|
+
/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle RPCs the container DO exposes. */
|
|
29
|
+
interface ContainerStubLike {
|
|
30
|
+
destroy?: () => Promise<void>;
|
|
31
|
+
fetch: (input: Request) => Promise<Response>;
|
|
32
|
+
getState?: () => Promise<ContainerInstanceState>;
|
|
33
|
+
start?: (options?: ContainerStartOptions) => Promise<void>;
|
|
34
|
+
stop?: (signal?: number | string) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Cloudflare Durable Object data-residency jurisdiction. Widening union —
|
|
38
|
+
* Cloudflare adds values over time.
|
|
39
|
+
* @see https://developers.cloudflare.com/durable-objects/reference/data-location/
|
|
40
|
+
*/
|
|
41
|
+
type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
|
|
42
|
+
/** What the client needs from a Durable Object namespace binding. */
|
|
43
|
+
interface ContainerNamespaceLike {
|
|
44
|
+
get: (id: unknown) => ContainerStubLike;
|
|
45
|
+
idFromName: (name: string) => unknown;
|
|
46
|
+
/**
|
|
47
|
+
* Derive a jurisdiction-restricted subnamespace. Optional because older
|
|
48
|
+
* workers-types releases (and test doubles) may not expose it.
|
|
49
|
+
*/
|
|
50
|
+
jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => ContainerNamespaceLike;
|
|
51
|
+
}
|
|
52
|
+
/** A handle on one container instance (one Durable Object). */
|
|
53
|
+
interface ContainerHandle {
|
|
54
|
+
/**
|
|
55
|
+
* Send an HTTP (or WebSocket-upgrade) request to the container. A path
|
|
56
|
+
* string (`"/transcode"`) is resolved against a synthetic origin; a full
|
|
57
|
+
* `Request`/URL passes through unchanged.
|
|
58
|
+
*/
|
|
59
|
+
fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A handle on a *named* instance (from `.get(name)`) — `fetch` plus explicit
|
|
63
|
+
* lifecycle control. The per-entity pattern (a sandbox per user, a room per
|
|
64
|
+
* game, a job runner per id) often needs to tear down or inspect the instance
|
|
65
|
+
* rather than wait for `sleepAfter`, so these wrap the container DO's
|
|
66
|
+
* `start`/`stop`/`destroy`/`getState`.
|
|
67
|
+
*/
|
|
68
|
+
interface ContainerInstanceHandle extends ContainerHandle {
|
|
69
|
+
/** Stop and discard the instance (its ephemeral disk is lost). */
|
|
70
|
+
destroy: () => Promise<void>;
|
|
71
|
+
/** Read the instance's current runtime state. */
|
|
72
|
+
getState: () => Promise<ContainerInstanceState>;
|
|
73
|
+
/** Explicitly start the instance, optionally with per-instance env/entrypoint. */
|
|
74
|
+
start: (options?: ContainerStartOptions) => Promise<void>;
|
|
75
|
+
/** Stop the instance (optionally with a signal); it can start again on the next request. */
|
|
76
|
+
stop: (signal?: number | string) => Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/** The per-definition accessor exposed as `ctx.containers.<exportName>`. */
|
|
79
|
+
interface ContainerAccessor {
|
|
80
|
+
/**
|
|
81
|
+
* A random instance from a fixed pool of `count` (defaults to the
|
|
82
|
+
* definition's `maxInstances`, else 3 — mirroring `getRandom` from
|
|
83
|
+
* `@cloudflare/containers`). For stateless, interchangeable workloads.
|
|
84
|
+
*/
|
|
85
|
+
any: (count?: number) => ContainerHandle;
|
|
86
|
+
/** The instance for `name` — one container per entity (user, room, job…), with lifecycle control. */
|
|
87
|
+
get: (name: string) => ContainerInstanceHandle;
|
|
88
|
+
/**
|
|
89
|
+
* A resilient handle over the pool: each `fetch` picks a random instance and,
|
|
90
|
+
* on a thrown error or a retryable response (5xx by default), retries on a
|
|
91
|
+
* freshly-picked instance with exponential backoff. Until Cloudflare ships
|
|
92
|
+
* native autoscaling + health-aware routing this is the recommended way to
|
|
93
|
+
* call a stateless container pool — it rides over a single cold/unhealthy
|
|
94
|
+
* instance instead of failing the whole request.
|
|
95
|
+
*
|
|
96
|
+
* Because a retry re-issues the request, pass a **replayable** body — a path
|
|
97
|
+
* string plus an `init.body` string/`ArrayBuffer` (re-created each attempt).
|
|
98
|
+
* A pre-built `Request` carrying a stream body can only be sent once, so it
|
|
99
|
+
* is not retry-safe here; use `.get()`/`.any()` for those.
|
|
100
|
+
*/
|
|
101
|
+
pool: (options?: PoolOptions) => ContainerHandle;
|
|
102
|
+
}
|
|
103
|
+
/** Tuning for a pooled, retrying container handle. See {@link ContainerAccessor.pool}. */
|
|
104
|
+
interface PoolOptions {
|
|
105
|
+
/** Total attempts before giving up (each on a freshly-picked instance). Default 3. */
|
|
106
|
+
attempts?: number;
|
|
107
|
+
/** Base backoff in ms between attempts; doubles each retry (0 disables the wait). Default 100. */
|
|
108
|
+
backoffMs?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Upper bound on a single backoff sleep, in ms. The doubling delay is clamped
|
|
111
|
+
* to this ceiling so a large `attempts` count can't produce an unboundedly
|
|
112
|
+
* long wait. Default {@link DEFAULT_MAX_BACKOFF_MS} (30s).
|
|
113
|
+
*/
|
|
114
|
+
maxBackoffMs?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Whether a *returned* response should be retried on another instance.
|
|
117
|
+
* Defaults to retrying any `5xx`. A thrown error (network/start failure) is
|
|
118
|
+
* always retried regardless of this predicate.
|
|
119
|
+
*/
|
|
120
|
+
retryOn?: (response: Response) => boolean;
|
|
121
|
+
/** Pool size to spread picks across. Defaults to the definition's `maxInstances`, else 3. */
|
|
122
|
+
size?: number;
|
|
123
|
+
}
|
|
124
|
+
/** Wiring info for one definition, emitted by codegen into the generated DO. */
|
|
125
|
+
interface ContainerBindingSpec {
|
|
126
|
+
/** Durable Object binding name, e.g. `CONTAINER_TRANSCODER`. */
|
|
127
|
+
binding: string;
|
|
128
|
+
/** The `lunora/containers.ts` export name, e.g. `transcoder`. */
|
|
129
|
+
exportName: string;
|
|
130
|
+
/** Pool size default for `.any()`. */
|
|
131
|
+
maxInstances?: number;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the `ctx.containers` record from the Worker `env`. Called by the
|
|
135
|
+
* generated ShardDO with the specs codegen derived from
|
|
136
|
+
* `lunora/containers.ts`. A missing binding doesn't throw here — only when the
|
|
137
|
+
* handle is actually used — so one unprovisioned container never breaks
|
|
138
|
+
* unrelated functions.
|
|
139
|
+
*/
|
|
140
|
+
declare const createContainerContext: (env: Record<string, unknown>, specs: ReadonlyArray<ContainerBindingSpec>, jurisdiction?: DurableObjectJurisdiction) => Record<string, ContainerAccessor>;
|
|
141
|
+
/** A test handler: receives the request plus the targeted instance name. */
|
|
142
|
+
type ContainerTestHandler = (request: Request, instance: {
|
|
143
|
+
name: string;
|
|
144
|
+
}) => Promise<Response> | Response;
|
|
145
|
+
/**
|
|
146
|
+
* Docker-free test double for `ctx.containers`: each export name maps to a
|
|
147
|
+
* fetch handler that plays the container. Mirrors the real shape exactly, so
|
|
148
|
+
* action handlers under test can't tell the difference.
|
|
149
|
+
*
|
|
150
|
+
* ```ts
|
|
151
|
+
* const containers = createContainerTestContext({
|
|
152
|
+
* transcoder: (request) => new Response("ok"),
|
|
153
|
+
* });
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
declare const createContainerTestContext: (handlers: Record<string, ContainerTestHandler>) => Record<string, ContainerAccessor>;
|
|
157
|
+
/**
|
|
158
|
+
* Normalize a `ContainerImageSource` into the shape wrangler wants: a
|
|
159
|
+
* Dockerfile path + build context for local builds, or a fully-qualified
|
|
160
|
+
* reference for pre-built images.
|
|
161
|
+
*
|
|
162
|
+
* A local-path string whose basename starts with `Dockerfile` (so
|
|
163
|
+
* `Dockerfile.dev` also counts) is used as-is with its directory as the build
|
|
164
|
+
* context; any other path is treated as the build-context directory and the
|
|
165
|
+
* Dockerfile is expected at `<dir>/Dockerfile`.
|
|
166
|
+
*/
|
|
167
|
+
declare const normalizeContainerImage: (image: ContainerImageSource) => NormalizedContainerImage;
|
|
168
|
+
/**
|
|
169
|
+
* The generated Container DO class name for a `lunora/containers.ts` export:
|
|
170
|
+
* `transcoder` → `TranscoderContainer`. wrangler's `containers[].class_name`
|
|
171
|
+
* and the Durable Object binding's `class_name` both reference it, so codegen
|
|
172
|
+
* and the config layer MUST derive it identically — always via this helper.
|
|
173
|
+
*/
|
|
174
|
+
declare const containerClassName: (exportName: string) => string;
|
|
175
|
+
/**
|
|
176
|
+
* The Durable Object binding name for a container export: `transcoder` →
|
|
177
|
+
* `CONTAINER_TRANSCODER`, `imageResizer` → `CONTAINER_IMAGE_RESIZER`. The
|
|
178
|
+
* `CONTAINER_` prefix namespaces these away from `SHARD`/`SESSION`/`SCHEDULER`
|
|
179
|
+
* so a container export can never collide with the built-in bindings.
|
|
180
|
+
*/
|
|
181
|
+
declare const containerBindingName: (exportName: string) => string;
|
|
182
|
+
/**
|
|
183
|
+
* The local image tag a Railpack `{ build }` container is built and pushed
|
|
184
|
+
* under: `transcoder` → `lunora-transcoder:build`. The config reconciler writes
|
|
185
|
+
* it as the wrangler `containers[].image`, and `lunora deploy` builds that tag
|
|
186
|
+
* with Railpack and `wrangler containers push`es it before deploying — so all
|
|
187
|
+
* three derive the tag from this one helper and can never disagree.
|
|
188
|
+
*/
|
|
189
|
+
declare const containerBuildTag: (exportName: string) => string;
|
|
190
|
+
declare const defineContainer: (config: ContainerConfig) => ContainerDefinition;
|
|
191
|
+
/** True when a value is a `defineContainer` result (the runtime brand check). */
|
|
192
|
+
declare const isContainerDefinition: (value: unknown) => value is ContainerDefinition;
|
|
193
|
+
/**
|
|
194
|
+
* The container's full environment at instance start: the static `env` block
|
|
195
|
+
* plus every declared secret resolved from the Worker `env`. A declared secret
|
|
196
|
+
* missing from the Worker env fails fast — starting the container without a
|
|
197
|
+
* credential it was promised yields far worse errors downstream.
|
|
198
|
+
*/
|
|
199
|
+
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 };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +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';
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const NAMED_INSTANCE_TYPES = /* @__PURE__ */ new Set(["basic", "lite", "standard-1", "standard-2", "standard-3", "standard-4"]);
|
|
2
|
+
const ENV_NAME_PATTERN = /^[A-Z_]\w*$/i;
|
|
3
|
+
const SLEEP_AFTER_PATTERN = /^\d+[smh]$/;
|
|
4
|
+
const basename = (path) => {
|
|
5
|
+
const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
6
|
+
const separatorIndex = trimmed.lastIndexOf("/");
|
|
7
|
+
return separatorIndex === -1 ? trimmed : trimmed.slice(separatorIndex + 1);
|
|
8
|
+
};
|
|
9
|
+
const dirname = (path) => {
|
|
10
|
+
const separatorIndex = path.lastIndexOf("/");
|
|
11
|
+
return separatorIndex === -1 ? "." : path.slice(0, separatorIndex) || "/";
|
|
12
|
+
};
|
|
13
|
+
const normalizeContainerImage = (image) => {
|
|
14
|
+
if (typeof image !== "string") {
|
|
15
|
+
if ("build" in image) {
|
|
16
|
+
const buildDirectory = image.build.endsWith("/") ? image.build.slice(0, -1) : image.build;
|
|
17
|
+
return { buildDir: buildDirectory, kind: "build" };
|
|
18
|
+
}
|
|
19
|
+
return { kind: "registry", reference: image.registry };
|
|
20
|
+
}
|
|
21
|
+
if (basename(image).startsWith("Dockerfile")) {
|
|
22
|
+
return { buildContext: dirname(image), dockerfilePath: image, kind: "dockerfile" };
|
|
23
|
+
}
|
|
24
|
+
const context = image.endsWith("/") ? image.slice(0, -1) : image;
|
|
25
|
+
return { buildContext: context, dockerfilePath: `${context}/Dockerfile`, kind: "dockerfile" };
|
|
26
|
+
};
|
|
27
|
+
const containerClassName = (exportName) => `${exportName.charAt(0).toUpperCase()}${exportName.slice(1)}Container`;
|
|
28
|
+
const containerBindingName = (exportName) => `CONTAINER_${exportName.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g, "_").toUpperCase()}`;
|
|
29
|
+
const containerBuildTag = (exportName) => `lunora-${exportName.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g, "-").toLowerCase()}:build`;
|
|
30
|
+
const assertValidImage = (image) => {
|
|
31
|
+
if (typeof image === "string") {
|
|
32
|
+
if (image.length === 0) {
|
|
33
|
+
throw new TypeError("defineContainer: `image` must be a non-empty path or a { registry } reference");
|
|
34
|
+
}
|
|
35
|
+
if (image.includes(":")) {
|
|
36
|
+
throw new TypeError(
|
|
37
|
+
`defineContainer: \`image\` string "${image}" looks like a registry reference — pass it as { registry: "${image}" } instead. Plain strings are local Dockerfile paths.`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if ("build" in image) {
|
|
43
|
+
if (typeof image.build !== "string" || image.build.length === 0) {
|
|
44
|
+
throw new TypeError("defineContainer: `image.build` must be a non-empty source directory for Railpack to build");
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (typeof image.registry !== "string" || image.registry.length === 0) {
|
|
49
|
+
throw new TypeError("defineContainer: `image.registry` must be a non-empty fully-qualified image reference");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const assertValidEnvAndSecrets = (config) => {
|
|
53
|
+
for (const name of Object.keys(config.env ?? {})) {
|
|
54
|
+
if (!ENV_NAME_PATTERN.test(name)) {
|
|
55
|
+
throw new TypeError(`defineContainer: env variable name "${name}" is not a valid environment variable name`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const name of Object.keys(config.buildArgs ?? {})) {
|
|
59
|
+
if (!ENV_NAME_PATTERN.test(name)) {
|
|
60
|
+
throw new TypeError(`defineContainer: buildArg name "${name}" is not a valid environment variable name`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const envNames = new Set(Object.keys(config.env ?? {}));
|
|
64
|
+
for (const secret of config.secrets ?? []) {
|
|
65
|
+
if (!ENV_NAME_PATTERN.test(secret)) {
|
|
66
|
+
throw new TypeError(`defineContainer: secret name "${secret}" is not a valid environment variable name`);
|
|
67
|
+
}
|
|
68
|
+
if (envNames.has(secret)) {
|
|
69
|
+
throw new TypeError(
|
|
70
|
+
`defineContainer: "${secret}" is declared in both \`env\` and \`secrets\` — a secret would silently overwrite the static env value; pick one`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const defineContainer = (config) => {
|
|
76
|
+
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)})`);
|
|
79
|
+
}
|
|
80
|
+
const stepPercentage = config.rollout?.stepPercentage;
|
|
81
|
+
if (stepPercentage !== void 0 && (!Number.isInteger(stepPercentage) || stepPercentage < 1 || stepPercentage > 100)) {
|
|
82
|
+
throw new TypeError(`defineContainer: \`rollout.stepPercentage\` must be an integer in 1–100 (got ${String(stepPercentage)})`);
|
|
83
|
+
}
|
|
84
|
+
if (config.maxInstances !== void 0 && (!Number.isInteger(config.maxInstances) || config.maxInstances < 1)) {
|
|
85
|
+
throw new TypeError(`defineContainer: \`maxInstances\` must be a positive integer (got ${String(config.maxInstances)})`);
|
|
86
|
+
}
|
|
87
|
+
if (typeof config.instanceType === "string" && !NAMED_INSTANCE_TYPES.has(config.instanceType)) {
|
|
88
|
+
throw new TypeError(
|
|
89
|
+
`defineContainer: unknown \`instanceType\` "${config.instanceType}" — use one of ${[...NAMED_INSTANCE_TYPES].join(", ")}, or a custom { vcpu, memoryMib, diskMb } object`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (typeof config.sleepAfter === "string" && !SLEEP_AFTER_PATTERN.test(config.sleepAfter)) {
|
|
93
|
+
throw new TypeError(
|
|
94
|
+
`defineContainer: \`sleepAfter\` string "${config.sleepAfter}" must be a number of seconds followed by a unit, e.g. "30s", "5m", or "1h"`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
assertValidEnvAndSecrets(config);
|
|
98
|
+
return { ...config, isLunoraContainer: true };
|
|
99
|
+
};
|
|
100
|
+
const isContainerDefinition = (value) => typeof value === "object" && value !== null && value.isLunoraContainer === true;
|
|
101
|
+
const resolveContainerEnvVariables = (definition, workerEnv, exportName) => {
|
|
102
|
+
const resolved = { ...definition.env };
|
|
103
|
+
for (const secret of definition.secrets ?? []) {
|
|
104
|
+
const value = workerEnv[secret];
|
|
105
|
+
if (typeof value !== "string") {
|
|
106
|
+
const label = exportName === void 0 ? "container" : `container "${exportName}"`;
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${label}: declared secret "${secret}" is not set on the Worker environment. Add it to .dev.vars for local dev and run \`wrangler secret put ${secret}\` for production.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
resolved[secret] = value;
|
|
112
|
+
}
|
|
113
|
+
return resolved;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const applyJurisdiction = (namespace, jurisdiction) => {
|
|
2
|
+
if (jurisdiction === void 0) {
|
|
3
|
+
return namespace;
|
|
4
|
+
}
|
|
5
|
+
if (typeof namespace.jurisdiction !== "function") {
|
|
6
|
+
throw new TypeError(
|
|
7
|
+
`@lunora/container: Durable Object namespace does not support jurisdiction("${jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return namespace.jurisdiction(jurisdiction);
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_POOL_SIZE = 3;
|
|
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);
|
|
17
|
+
}
|
|
18
|
+
return new Request(input, init);
|
|
19
|
+
};
|
|
20
|
+
const handleFor = (namespace, instanceName) => {
|
|
21
|
+
return {
|
|
22
|
+
fetch: async (input, init) => namespace.get(namespace.idFromName(instanceName)).fetch(toRequest(input, init))
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
const lifecycleCall = async (stub, method, binding, argument) => {
|
|
26
|
+
const rpc = stub[method];
|
|
27
|
+
if (typeof rpc !== "function") {
|
|
28
|
+
throw new TypeError(`ctx.containers: the "${binding}" container DO does not expose ${method}() — is @lunora/container/do up to date?`);
|
|
29
|
+
}
|
|
30
|
+
return rpc(argument);
|
|
31
|
+
};
|
|
32
|
+
const instanceHandleFor = (namespace, spec, instanceName) => {
|
|
33
|
+
const stub = () => namespace.get(namespace.idFromName(instanceName));
|
|
34
|
+
return {
|
|
35
|
+
destroy: async () => lifecycleCall(stub(), "destroy", spec.binding),
|
|
36
|
+
fetch: async (input, init) => stub().fetch(toRequest(input, init)),
|
|
37
|
+
getState: async () => lifecycleCall(stub(), "getState", spec.binding),
|
|
38
|
+
start: async (options) => lifecycleCall(stub(), "start", spec.binding, options),
|
|
39
|
+
stop: async (signal) => lifecycleCall(stub(), "stop", spec.binding, signal)
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
const randomPoolName = (size) => (
|
|
43
|
+
// eslint-disable-next-line sonarjs/pseudo-random -- load-balancing pick across interchangeable instances, not a security decision
|
|
44
|
+
`pool-${String(Math.floor(Math.random() * size))}`
|
|
45
|
+
);
|
|
46
|
+
const sleep = async (ms) => {
|
|
47
|
+
if (ms <= 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await new Promise((resolve) => {
|
|
51
|
+
setTimeout(resolve, ms);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
const retryOnServerError = (response) => response.status >= 500;
|
|
55
|
+
const poolHandleFor = (namespace, spec, options = {}) => {
|
|
56
|
+
const size = options.size ?? spec.maxInstances ?? DEFAULT_POOL_SIZE;
|
|
57
|
+
const attempts = Math.max(1, options.attempts ?? 3);
|
|
58
|
+
const baseBackoff = options.backoffMs ?? 100;
|
|
59
|
+
const maxBackoff = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
60
|
+
const shouldRetry = options.retryOn ?? retryOnServerError;
|
|
61
|
+
return {
|
|
62
|
+
fetch: async (input, init) => {
|
|
63
|
+
let lastError;
|
|
64
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
65
|
+
if (attempt > 0) {
|
|
66
|
+
await sleep(Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff));
|
|
67
|
+
}
|
|
68
|
+
const request = toRequest(input, init);
|
|
69
|
+
try {
|
|
70
|
+
const response = await namespace.get(namespace.idFromName(randomPoolName(size))).fetch(request);
|
|
71
|
+
if (attempt === attempts - 1 || !shouldRetry(response)) {
|
|
72
|
+
return response;
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
lastError = error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw lastError instanceof Error ? lastError : new Error(`ctx.containers.${spec.exportName}.pool(): all ${String(attempts)} attempts failed`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const accessorFor = (namespace, spec) => {
|
|
83
|
+
return {
|
|
84
|
+
any: (count) => handleFor(namespace, randomPoolName(count ?? spec.maxInstances ?? DEFAULT_POOL_SIZE)),
|
|
85
|
+
get: (name) => instanceHandleFor(namespace, spec, name),
|
|
86
|
+
pool: (options) => poolHandleFor(namespace, spec, options)
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const missingBindingAccessor = (spec) => {
|
|
90
|
+
const fail = () => {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`ctx.containers.${spec.exportName}: no "${spec.binding}" Durable Object binding found. Run \`lunora dev\` (or \`lunora deploy\`) to reconcile wrangler.jsonc, and make sure the worker entry re-exports the generated container classes.`
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
return { any: fail, get: fail, pool: fail };
|
|
96
|
+
};
|
|
97
|
+
const createContainerContext = (env, specs, jurisdiction) => {
|
|
98
|
+
const containers = {};
|
|
99
|
+
for (const spec of specs) {
|
|
100
|
+
const binding = env[spec.binding];
|
|
101
|
+
containers[spec.exportName] = binding && typeof binding.idFromName === "function" && typeof binding.get === "function" ? accessorFor(applyJurisdiction(binding, jurisdiction), spec) : missingBindingAccessor(spec);
|
|
102
|
+
}
|
|
103
|
+
return containers;
|
|
104
|
+
};
|
|
105
|
+
const createContainerTestContext = (handlers) => {
|
|
106
|
+
const containers = {};
|
|
107
|
+
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
|
+
};
|
|
122
|
+
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")
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return containers;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export { createContainerContext, createContainerTestContext };
|