@lunora/container 1.0.0-alpha.2 → 1.0.0-alpha.21
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 +26 -0
- package/README.md +99 -1
- package/dist/bridge.d.mts +51 -35
- package/dist/bridge.d.ts +51 -35
- package/dist/bridge.mjs +1 -76
- package/dist/do/index.d.mts +580 -45
- package/dist/do/index.d.ts +580 -45
- package/dist/do/index.mjs +1 -138
- package/dist/index.d.mts +235 -114
- package/dist/index.d.ts +235 -114
- package/dist/index.mjs +1 -2
- package/dist/otel.d.mts +161 -0
- package/dist/otel.d.ts +161 -0
- package/dist/otel.mjs +1 -0
- package/dist/packem_shared/ContainerProxy-BsQAwSNX.mjs +27 -0
- package/dist/packem_shared/containerBindingName-DP2NqQV-.mjs +1 -0
- package/dist/packem_shared/createContainerContext-Df9Ev-Fp.mjs +1 -0
- package/dist/packem_shared/jurisdiction-BKRNOTip.mjs +1 -0
- package/dist/packem_shared/jurisdiction.d-8oUUvrew.d.mts +281 -0
- package/dist/packem_shared/jurisdiction.d-8oUUvrew.d.ts +281 -0
- package/package.json +7 -3
- package/dist/packem_shared/containerBindingName-BGdSdFNA.mjs +0 -116
- package/dist/packem_shared/createContainerContext-CTpyUQ4J.mjs +0 -133
- package/dist/packem_shared/types.d-D2l2SYol.d.mts +0 -140
- package/dist/packem_shared/types.d-D2l2SYol.d.ts +0 -140
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public configuration types for `@lunora/container`.
|
|
3
|
+
*
|
|
4
|
+
* Everything in this module is pure data — no Cloudflare runtime imports — so
|
|
5
|
+
* it is safe to import from Node tooling (codegen, the config layer) as well
|
|
6
|
+
* as from worker code.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Named instance types Cloudflare Containers provides.
|
|
10
|
+
* @experimental
|
|
11
|
+
*/
|
|
12
|
+
type NamedContainerInstanceType = "basic" | "lite" | "standard-1" | "standard-2" | "standard-3" | "standard-4";
|
|
13
|
+
/**
|
|
14
|
+
* A custom instance type. Cloudflare's bounds at the time of writing: up to
|
|
15
|
+
* 4 vCPU, 12 GiB memory, 20 GB disk, ≥ 3 GiB memory per vCPU and ≤ 2 GB disk
|
|
16
|
+
* per GiB memory. The config-layer validator enforces the documented ranges.
|
|
17
|
+
* @experimental
|
|
18
|
+
*/
|
|
19
|
+
interface CustomContainerInstanceType {
|
|
20
|
+
/** Disk in MB. Cloudflare's default is 2000 (2 GB). */
|
|
21
|
+
diskMb?: number;
|
|
22
|
+
/** Memory in MiB. Cloudflare's default is 256. */
|
|
23
|
+
memoryMib?: number;
|
|
24
|
+
/** vCPU count. Cloudflare's default is 0.0625 (1/16 vCPU). */
|
|
25
|
+
vcpu?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `ContainerInstanceType` is part of the experimental `@lunora/container` API and may change without a major version bump.
|
|
29
|
+
* @experimental
|
|
30
|
+
*/
|
|
31
|
+
type ContainerInstanceType = CustomContainerInstanceType | NamedContainerInstanceType;
|
|
32
|
+
/**
|
|
33
|
+
* Rolling-deploy tuning for a container.
|
|
34
|
+
* @experimental
|
|
35
|
+
*/
|
|
36
|
+
interface ContainerRollout {
|
|
37
|
+
/** Seconds an active instance runs before it's eligible for update (wrangler `rollout_active_grace_period`). */
|
|
38
|
+
gracePeriodSeconds?: number;
|
|
39
|
+
/** Percentage of instances updated per rollout step, 1–100 (wrangler `rollout_step_percentage`). */
|
|
40
|
+
stepPercentage?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A pre-built image pulled from a registry — the Cloudflare Registry, Docker
|
|
44
|
+
* Hub, or Amazon ECR (the registries `wrangler deploy` supports). The
|
|
45
|
+
* reference must be fully qualified, e.g. `docker.io/acme/transcoder:1.4`.
|
|
46
|
+
* @experimental
|
|
47
|
+
*/
|
|
48
|
+
interface RegistryImageSource {
|
|
49
|
+
registry: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A Dockerfile-less build via [Railpack](https://railpack.com): point at a
|
|
53
|
+
* source directory and `lunora deploy` builds an OCI image with Railpack
|
|
54
|
+
* (needs a BuildKit instance) and pushes it to the Cloudflare Registry before
|
|
55
|
+
* wrangler runs. Opt-in — the Dockerfile path is the zero-extra-deps default.
|
|
56
|
+
* @experimental
|
|
57
|
+
*/
|
|
58
|
+
interface BuildImageSource {
|
|
59
|
+
build: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Where the container image comes from. A `string` is a **local path** —
|
|
63
|
+
* either a directory containing a `Dockerfile` (normalized to
|
|
64
|
+
* `<dir>/Dockerfile` with the directory as the build context) or a path to
|
|
65
|
+
* the Dockerfile itself — while `{ registry }` is a pre-built image reference.
|
|
66
|
+
* @experimental
|
|
67
|
+
*/
|
|
68
|
+
type ContainerImageSource = BuildImageSource | RegistryImageSource | string;
|
|
69
|
+
/**
|
|
70
|
+
* An application-level readiness probe that gates request proxying. Layered on
|
|
71
|
+
* top of the platform's own port/`pingEndpoint` health wait, it lets you hold
|
|
72
|
+
* traffic back until the app inside the container is *functionally* ready —
|
|
73
|
+
* migrations applied, caches warmed — which an open-port check can't see.
|
|
74
|
+
*
|
|
75
|
+
* Declarative on purpose: a `defineContainer` value stays pure data (no handler
|
|
76
|
+
* functions), so codegen and the config layer can read it without evaluating
|
|
77
|
+
* code. (Upstream cloudflare/containers#188 expresses the same idea as handler
|
|
78
|
+
* functions; the Lunora config is data-only, so it's modelled as descriptors.)
|
|
79
|
+
* @experimental
|
|
80
|
+
*/
|
|
81
|
+
interface ContainerReadinessCheck {
|
|
82
|
+
/** HTTP path probed on the container, e.g. `"/ready"` (a leading slash is optional). */
|
|
83
|
+
path: string;
|
|
84
|
+
/** Port to probe. Defaults to {@link ContainerConfig.defaultPort}. */
|
|
85
|
+
port?: number;
|
|
86
|
+
/** HTTP status that means "ready". Defaults to `200`. */
|
|
87
|
+
status?: number;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `ContainerConfig` is part of the experimental `@lunora/container` API and may change without a major version bump.
|
|
91
|
+
* @experimental
|
|
92
|
+
*/
|
|
93
|
+
interface ContainerConfig {
|
|
94
|
+
/**
|
|
95
|
+
* Hostnames the container may reach **even when {@link ContainerConfig.enableInternet}
|
|
96
|
+
* is `false`** — an egress allow-list (Cloudflare's `allowedHosts`). Glob
|
|
97
|
+
* patterns like `*.stripe.com` are supported. Pair with `enableInternet:
|
|
98
|
+
* false` to deny all egress except these hosts (the firewall pattern
|
|
99
|
+
* upstream issue cloudflare/containers#30 asked for). The interception path
|
|
100
|
+
* needs the `ContainerProxy` worker entrypoint, which codegen re-exports
|
|
101
|
+
* from the generated container file automatically; the named-instance
|
|
102
|
+
* handle's `egress` controls adjust the lists at runtime.
|
|
103
|
+
*/
|
|
104
|
+
allowedHosts?: ReadonlyArray<string>;
|
|
105
|
+
/**
|
|
106
|
+
* Build-time variables for a Dockerfile/Railpack image — wrangler's
|
|
107
|
+
* `image_vars` (equivalent to `docker build --build-arg`). For *runtime*
|
|
108
|
+
* values use {@link ContainerConfig.env} / {@link ContainerConfig.secrets}.
|
|
109
|
+
* Ignored for a pre-built `{ registry }` image.
|
|
110
|
+
*/
|
|
111
|
+
buildArgs?: Readonly<Record<string, string>>;
|
|
112
|
+
/**
|
|
113
|
+
* The port the container listens on. Worker → container requests target
|
|
114
|
+
* this port. Locally the Dockerfile must also `EXPOSE` it. For a
|
|
115
|
+
* multi-port container also declare {@link ContainerConfig.requiredPorts}
|
|
116
|
+
* and route per request with the handle's `.port(n)`.
|
|
117
|
+
*/
|
|
118
|
+
defaultPort?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Hostnames the container may **never** reach — an egress deny-list
|
|
121
|
+
* (Cloudflare's `deniedHosts`). Overrides everything else, including
|
|
122
|
+
* `enableInternet: true` and {@link ContainerConfig.allowedHosts}. Glob
|
|
123
|
+
* patterns like `*.evil.com` are supported.
|
|
124
|
+
*/
|
|
125
|
+
deniedHosts?: ReadonlyArray<string>;
|
|
126
|
+
/**
|
|
127
|
+
* Whether the container may open outbound internet connections. Defaults
|
|
128
|
+
* to `true` — the platform default. Note that container egress is billed
|
|
129
|
+
* per GB by Cloudflare. Combine with {@link ContainerConfig.allowedHosts} /
|
|
130
|
+
* {@link ContainerConfig.deniedHosts} for a precise egress firewall.
|
|
131
|
+
*/
|
|
132
|
+
enableInternet?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Default command to run inside the container, overriding the image's
|
|
135
|
+
* `ENTRYPOINT`/`CMD` (Cloudflare's `entrypoint`). A per-start override is
|
|
136
|
+
* still available via the named-instance handle's `start({ entrypoint })`.
|
|
137
|
+
*/
|
|
138
|
+
entrypoint?: ReadonlyArray<string>;
|
|
139
|
+
/**
|
|
140
|
+
* Static environment variables passed to the container on every start.
|
|
141
|
+
* For secret values use {@link ContainerConfig.secrets} instead so they
|
|
142
|
+
* flow through Worker Secrets rather than source code.
|
|
143
|
+
*/
|
|
144
|
+
env?: Readonly<Record<string, string>>;
|
|
145
|
+
/**
|
|
146
|
+
* Hard cap on how long an instance may run, measured from start regardless
|
|
147
|
+
* of activity — a runaway-cost backstop on top of the idle
|
|
148
|
+
* {@link ContainerConfig.sleepAfter}. Same grammar as `sleepAfter`
|
|
149
|
+
* (`"30s"`, `"5m"`, `"1h"`, or a plain number of seconds). When it elapses,
|
|
150
|
+
* the `LunoraContainer.onHardTimeoutExpired` hook runs (default: `stop()`).
|
|
151
|
+
* (Upstream cloudflare/containers#85.)
|
|
152
|
+
*/
|
|
153
|
+
hardTimeout?: number | string;
|
|
154
|
+
/** Image source — a local Dockerfile path/directory or a registry reference. */
|
|
155
|
+
image: ContainerImageSource;
|
|
156
|
+
/**
|
|
157
|
+
* Resource class for each instance: a named Cloudflare instance type or a
|
|
158
|
+
* custom `{ vcpu, memoryMib, diskMb }` object.
|
|
159
|
+
*/
|
|
160
|
+
instanceType?: ContainerInstanceType;
|
|
161
|
+
/**
|
|
162
|
+
* Intercept the container's outbound **HTTPS** traffic so the egress
|
|
163
|
+
* allow/deny lists apply to TLS connections too (Cloudflare's
|
|
164
|
+
* `interceptHttps`). Requires the image to trust the Cloudflare CA at
|
|
165
|
+
* `/etc/cloudflare/certs/cloudflare-containers-ca.crt`. Defaults to `false`
|
|
166
|
+
* (HTTP egress is gated regardless).
|
|
167
|
+
*/
|
|
168
|
+
interceptHttps?: boolean;
|
|
169
|
+
/**
|
|
170
|
+
* Key-value metadata attached to every instance for metrics/observability
|
|
171
|
+
* (Cloudflare's container `labels`), e.g. `{ tenant: "acme", env: "prod" }`.
|
|
172
|
+
* A per-start override is available via the named-instance handle's
|
|
173
|
+
* `start({ labels })`.
|
|
174
|
+
*/
|
|
175
|
+
labels?: Readonly<Record<string, string>>;
|
|
176
|
+
/**
|
|
177
|
+
* Maximum number of concurrently *running* instances. Stopped (slept)
|
|
178
|
+
* containers don't count. Also the default pool size for `.any()`.
|
|
179
|
+
*/
|
|
180
|
+
maxInstances?: number;
|
|
181
|
+
/**
|
|
182
|
+
* Override for the wrangler `containers[].name` identifier. Defaults to
|
|
183
|
+
* wrangler's own default (worker name + class name + environment).
|
|
184
|
+
*/
|
|
185
|
+
name?: string;
|
|
186
|
+
/**
|
|
187
|
+
* HTTP path Cloudflare polls to decide an instance is healthy
|
|
188
|
+
* (Cloudflare's `pingEndpoint`). Defaults to upstream's slash-less `"ping"`;
|
|
189
|
+
* either `"ping"` or `"/healthz"`-style paths are accepted. Set this when
|
|
190
|
+
* the container exposes its readiness check under a different route.
|
|
191
|
+
*/
|
|
192
|
+
pingEndpoint?: string;
|
|
193
|
+
/**
|
|
194
|
+
* Application-level readiness probes that gate request proxying: a
|
|
195
|
+
* `ctx.containers.<name>` fetch waits until every probe responds with its
|
|
196
|
+
* expected status before the request reaches the container — on top of the
|
|
197
|
+
* platform's port/`pingEndpoint` health wait. All probes run in parallel.
|
|
198
|
+
* Use these for readiness an open-port check can't see (migrations applied,
|
|
199
|
+
* caches warm). (Upstream cloudflare/containers#188.)
|
|
200
|
+
*/
|
|
201
|
+
readyOn?: ReadonlyArray<ContainerReadinessCheck>;
|
|
202
|
+
/**
|
|
203
|
+
* Ports the container must be listening on before it's considered ready
|
|
204
|
+
* (Cloudflare's `requiredPorts`) — for multi-port containers. Start-up
|
|
205
|
+
* waits for every listed port, and the handle's `.port(n)` routes a request
|
|
206
|
+
* to any of them; {@link ContainerConfig.defaultPort} is the target when a
|
|
207
|
+
* request doesn't pick one.
|
|
208
|
+
*/
|
|
209
|
+
requiredPorts?: ReadonlyArray<number>;
|
|
210
|
+
/**
|
|
211
|
+
* Rolling-deploy tuning. `stepPercentage` is the share of instances updated
|
|
212
|
+
* per rollout step (wrangler `rollout_step_percentage`); `gracePeriodSeconds`
|
|
213
|
+
* is how long an active instance is left running before it's eligible for
|
|
214
|
+
* update (wrangler `rollout_active_grace_period`).
|
|
215
|
+
*/
|
|
216
|
+
rollout?: ContainerRollout;
|
|
217
|
+
/**
|
|
218
|
+
* Names of Worker secrets (from `wrangler secret` / `.dev.vars`) forwarded
|
|
219
|
+
* into the container's environment at instance start. Each declared name
|
|
220
|
+
* must exist on the Worker `env` — a missing one fails fast with a
|
|
221
|
+
* directed error instead of starting the container without it.
|
|
222
|
+
*/
|
|
223
|
+
secrets?: ReadonlyArray<string>;
|
|
224
|
+
/**
|
|
225
|
+
* Cloudflare **Secrets Store** secrets forwarded into the container's
|
|
226
|
+
* environment, as a map of *container env-var name → Worker Secrets Store
|
|
227
|
+
* binding name*. Each binding is resolved with its async `.get()` the first
|
|
228
|
+
* time the instance starts, then injected as that env var — e.g.
|
|
229
|
+
* `{ STRIPE_KEY: "STRIPE_SECRET" }` runs `env.STRIPE_SECRET.get()` and sets
|
|
230
|
+
* `STRIPE_KEY` inside the container. Unlike {@link ContainerConfig.secrets}
|
|
231
|
+
* (plain Worker text secrets), this pulls from a `secrets_store_secrets`
|
|
232
|
+
* binding. A name already used by `env`/`secrets` is rejected at authoring
|
|
233
|
+
* time; a missing binding or unreadable value fails the start. Applies
|
|
234
|
+
* to the default start (the `ctx.containers` proxy path and a bare
|
|
235
|
+
* `start()`); a per-instance `start({ envVars })` replaces the env set
|
|
236
|
+
* wholesale, as it does for `env`/`secrets`. (Upstream
|
|
237
|
+
* cloudflare/containers#96.)
|
|
238
|
+
*/
|
|
239
|
+
secretsStore?: Readonly<Record<string, string>>;
|
|
240
|
+
/**
|
|
241
|
+
* Idle timeout after which the instance is put to sleep, e.g. `"5m"`,
|
|
242
|
+
* `"30s"`, or a number of seconds. Cloudflare's default is `"10m"`.
|
|
243
|
+
*/
|
|
244
|
+
sleepAfter?: number | string;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The value `defineContainer` returns: the validated config plus a brand the
|
|
248
|
+
* codegen discovery and the generated Container DO class key on.
|
|
249
|
+
* @experimental
|
|
250
|
+
*/
|
|
251
|
+
interface ContainerDefinition extends ContainerConfig {
|
|
252
|
+
/** Brand marking a value as a Lunora container definition. */
|
|
253
|
+
readonly isLunoraContainer: true;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* A normalized image source, as written into `wrangler.jsonc`.
|
|
257
|
+
* @experimental
|
|
258
|
+
*/
|
|
259
|
+
type NormalizedContainerImage = {
|
|
260
|
+
/** Build context directory (wrangler `image_build_context`). */
|
|
261
|
+
buildContext: string;
|
|
262
|
+
/** Path to the Dockerfile (wrangler `image`). */
|
|
263
|
+
dockerfilePath: string;
|
|
264
|
+
kind: "dockerfile";
|
|
265
|
+
} | {
|
|
266
|
+
/** Railpack source directory built + pushed at deploy time. */
|
|
267
|
+
buildDir: string;
|
|
268
|
+
kind: "build";
|
|
269
|
+
} | {
|
|
270
|
+
kind: "registry";
|
|
271
|
+
/** Fully-qualified image reference (wrangler `image`). */
|
|
272
|
+
reference: string;
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Cloudflare Durable Object data-residency jurisdiction. Widening union —
|
|
276
|
+
* Cloudflare adds values over time.
|
|
277
|
+
* @see https://developers.cloudflare.com/durable-objects/reference/data-location/
|
|
278
|
+
* @experimental
|
|
279
|
+
*/
|
|
280
|
+
type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
|
|
281
|
+
export { BuildImageSource as B, ContainerConfig as C, DurableObjectJurisdiction as D, NormalizedContainerImage as N, RegistryImageSource as R, ContainerDefinition 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
|
+
"version": "1.0.0-alpha.21",
|
|
4
4
|
"description": "Cloudflare Containers for Lunora: defineContainer, generated Container DO classes, and the ctx.containers action surface",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"directory": "packages/container"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
-
"dist",
|
|
26
|
+
"./dist",
|
|
27
27
|
"README.md",
|
|
28
28
|
"LICENSE.md",
|
|
29
29
|
"__assets__"
|
|
@@ -46,13 +46,17 @@
|
|
|
46
46
|
"types": "./dist/bridge.d.ts",
|
|
47
47
|
"import": "./dist/bridge.mjs"
|
|
48
48
|
},
|
|
49
|
+
"./otel": {
|
|
50
|
+
"types": "./dist/otel.d.ts",
|
|
51
|
+
"import": "./dist/otel.mjs"
|
|
52
|
+
},
|
|
49
53
|
"./package.json": "./package.json"
|
|
50
54
|
},
|
|
51
55
|
"publishConfig": {
|
|
52
56
|
"access": "public"
|
|
53
57
|
},
|
|
54
58
|
"dependencies": {
|
|
55
|
-
"@
|
|
59
|
+
"@lunora/errors": "1.0.0-alpha.13"
|
|
56
60
|
},
|
|
57
61
|
"engines": {
|
|
58
62
|
"node": "^22.15.0 || >=24.11.0"
|
|
@@ -1,116 +0,0 @@
|
|
|
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 };
|
|
@@ -1,133 +0,0 @@
|
|
|
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 };
|