@lunora/container 1.0.0-alpha.4 → 1.0.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/dist/bridge.d.mts +3 -3
- package/dist/bridge.d.ts +3 -3
- package/dist/bridge.mjs +10 -7
- package/dist/do/index.d.mts +518 -4
- package/dist/do/index.d.ts +518 -4
- package/dist/do/index.mjs +77 -5
- package/dist/index.d.mts +45 -18
- package/dist/index.d.ts +45 -18
- package/dist/index.mjs +2 -2
- package/dist/packem_shared/ContainerProxy-DWqUX_re.mjs +1474 -0
- package/dist/packem_shared/{containerBindingName-C7Lic2ET.mjs → containerBindingName-D5KTrblH.mjs} +19 -1
- package/dist/packem_shared/{createContainerContext-DBp8oJOr.mjs → createContainerContext-Cibbh7AE.mjs} +92 -22
- package/dist/packem_shared/{types.d-DAOLRiZQ.d.mts → types.d-BlNwNY44.d.mts} +17 -1
- package/dist/packem_shared/{types.d-DAOLRiZQ.d.ts → types.d-BlNwNY44.d.ts} +17 -1
- package/package.json +2 -2
package/dist/packem_shared/{containerBindingName-C7Lic2ET.mjs → containerBindingName-D5KTrblH.mjs}
RENAMED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
|
|
1
3
|
const NAMED_INSTANCE_TYPES = /* @__PURE__ */ new Set(["basic", "lite", "standard-1", "standard-2", "standard-3", "standard-4"]);
|
|
2
4
|
const ENV_NAME_PATTERN = /^[A-Z_]\w*$/i;
|
|
3
5
|
const SLEEP_AFTER_PATTERN = /^\d+[smh]$/;
|
|
@@ -60,6 +62,19 @@ const assertValidImage = (image) => {
|
|
|
60
62
|
throw new TypeError("defineContainer: `image.registry` must be a non-empty fully-qualified image reference");
|
|
61
63
|
}
|
|
62
64
|
};
|
|
65
|
+
const assertValidSecretsStore = (config, envNames, secretNames) => {
|
|
66
|
+
for (const [envName, binding] of Object.entries(config.secretsStore ?? {})) {
|
|
67
|
+
if (!ENV_NAME_PATTERN.test(envName)) {
|
|
68
|
+
throw new TypeError(`defineContainer: secretsStore env name "${envName}" is not a valid environment variable name`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof binding !== "string" || binding.trim().length === 0) {
|
|
71
|
+
throw new TypeError(`defineContainer: \`secretsStore["${envName}"]\` must be a non-empty Secrets Store binding name`);
|
|
72
|
+
}
|
|
73
|
+
if (envNames.has(envName) || secretNames.has(envName)) {
|
|
74
|
+
throw new TypeError(`defineContainer: "${envName}" is declared in both \`secretsStore\` and \`env\`/\`secrets\` — pick one source for the value`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
63
78
|
const assertValidEnvAndSecrets = (config) => {
|
|
64
79
|
for (const name of Object.keys(config.env ?? {})) {
|
|
65
80
|
if (!ENV_NAME_PATTERN.test(name)) {
|
|
@@ -72,6 +87,7 @@ const assertValidEnvAndSecrets = (config) => {
|
|
|
72
87
|
}
|
|
73
88
|
}
|
|
74
89
|
const envNames = new Set(Object.keys(config.env ?? {}));
|
|
90
|
+
const secretNames = new Set(config.secrets);
|
|
75
91
|
for (const secret of config.secrets ?? []) {
|
|
76
92
|
if (!ENV_NAME_PATTERN.test(secret)) {
|
|
77
93
|
throw new TypeError(`defineContainer: secret name "${secret}" is not a valid environment variable name`);
|
|
@@ -82,6 +98,7 @@ const assertValidEnvAndSecrets = (config) => {
|
|
|
82
98
|
);
|
|
83
99
|
}
|
|
84
100
|
}
|
|
101
|
+
assertValidSecretsStore(config, envNames, secretNames);
|
|
85
102
|
};
|
|
86
103
|
const assertValidPort = (port, field) => {
|
|
87
104
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
@@ -188,7 +205,8 @@ const resolveContainerEnvVariables = (definition, workerEnv, exportName) => {
|
|
|
188
205
|
const value = workerEnv[secret];
|
|
189
206
|
if (typeof value !== "string") {
|
|
190
207
|
const label = exportName === void 0 ? "container" : `container "${exportName}"`;
|
|
191
|
-
throw new
|
|
208
|
+
throw new LunoraError(
|
|
209
|
+
"INTERNAL",
|
|
192
210
|
`${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.`
|
|
193
211
|
);
|
|
194
212
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
|
|
1
3
|
const applyJurisdiction = (namespace, jurisdiction) => {
|
|
2
4
|
if (jurisdiction === void 0) {
|
|
3
5
|
return namespace;
|
|
@@ -11,6 +13,8 @@ const applyJurisdiction = (namespace, jurisdiction) => {
|
|
|
11
13
|
};
|
|
12
14
|
const DEFAULT_POOL_SIZE = 3;
|
|
13
15
|
const DEFAULT_MAX_BACKOFF_MS = 3e4;
|
|
16
|
+
const DEFAULT_COLD_START_ATTEMPTS = 3;
|
|
17
|
+
const DEFAULT_COLD_START_BACKOFF_MS = 500;
|
|
14
18
|
const TARGET_PORT_HEADER = "cf-container-target-port";
|
|
15
19
|
const toRequest = (input, init, port) => {
|
|
16
20
|
const request = typeof input === "string" && input.startsWith("/") ? new Request(`http://container${input}`, init) : new Request(input, init);
|
|
@@ -19,13 +23,85 @@ const toRequest = (input, init, port) => {
|
|
|
19
23
|
}
|
|
20
24
|
return request;
|
|
21
25
|
};
|
|
22
|
-
const
|
|
26
|
+
const sleep = async (ms) => {
|
|
27
|
+
if (ms <= 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await new Promise((resolve) => {
|
|
31
|
+
setTimeout(resolve, ms);
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
const COLD_START_ERROR_PATTERN = /no container instance|not listening|try again later|rate.?limit|provision/i;
|
|
35
|
+
const COLD_START_NO_INSTANCE_BODY = "no Container instance available";
|
|
36
|
+
const COLD_START_START_FAILURE_BODY = "Failed to start container:";
|
|
37
|
+
const COLD_START_SENTINEL_SCAN_BYTES = 1024;
|
|
38
|
+
const readBodyPrefix = async (response) => {
|
|
39
|
+
const stream = response.clone().body;
|
|
40
|
+
if (stream === null) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
const reader = stream.getReader();
|
|
44
|
+
const decoder = new TextDecoder();
|
|
45
|
+
let text = "";
|
|
46
|
+
try {
|
|
47
|
+
while (text.length < COLD_START_SENTINEL_SCAN_BYTES) {
|
|
48
|
+
const { done, value } = await reader.read();
|
|
49
|
+
if (done) {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
text += decoder.decode(value, { stream: true });
|
|
53
|
+
}
|
|
54
|
+
} finally {
|
|
55
|
+
await reader.cancel();
|
|
56
|
+
}
|
|
57
|
+
return text;
|
|
58
|
+
};
|
|
59
|
+
const isColdStartError = (error) => error instanceof Error && COLD_START_ERROR_PATTERN.test(error.message);
|
|
60
|
+
const isColdStartTransient = async (response) => {
|
|
61
|
+
if (response.status === 429) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (response.status !== 500 && response.status !== 503) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const body = await readBodyPrefix(response);
|
|
69
|
+
return body.includes(COLD_START_NO_INSTANCE_BODY) || body.startsWith(COLD_START_START_FAILURE_BODY);
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const coldStartRetryingHandle = (send, options = {}, port) => {
|
|
75
|
+
const attempts = Math.max(1, options.attempts ?? DEFAULT_COLD_START_ATTEMPTS);
|
|
76
|
+
const baseBackoff = options.backoffMs ?? DEFAULT_COLD_START_BACKOFF_MS;
|
|
77
|
+
const maxBackoff = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
23
78
|
return {
|
|
24
|
-
fetch: async (input, init) =>
|
|
25
|
-
|
|
79
|
+
fetch: async (input, init) => {
|
|
80
|
+
const totalAttempts = typeof input === "string" ? attempts : 1;
|
|
81
|
+
let lastError;
|
|
82
|
+
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
|
83
|
+
const isLastAttempt = attempt === totalAttempts - 1;
|
|
84
|
+
if (attempt > 0) {
|
|
85
|
+
await sleep(Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff));
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const response = await send(toRequest(input, init, port));
|
|
89
|
+
if (isLastAttempt || !await isColdStartTransient(response)) {
|
|
90
|
+
return response;
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
lastError = error;
|
|
94
|
+
if (isLastAttempt || !isColdStartError(error)) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw lastError instanceof Error ? lastError : new Error("ctx.containers: cold-start retry exhausted");
|
|
100
|
+
},
|
|
101
|
+
port: (targetPort) => coldStartRetryingHandle(send, options, targetPort)
|
|
26
102
|
};
|
|
27
103
|
};
|
|
28
|
-
const handleFor = (namespace, instanceName) =>
|
|
104
|
+
const handleFor = (namespace, instanceName, options) => coldStartRetryingHandle(async (request) => namespace.get(namespace.idFromName(instanceName)).fetch(request), options);
|
|
29
105
|
const lifecycleCall = async (stub, method, binding, argument) => {
|
|
30
106
|
const rpc = stub[method];
|
|
31
107
|
if (typeof rpc !== "function") {
|
|
@@ -43,15 +119,15 @@ const egressControlsFor = (stub, binding) => {
|
|
|
43
119
|
setDenied: async (hosts) => lifecycleCall(stub(), "setDeniedHosts", binding, [...hosts])
|
|
44
120
|
};
|
|
45
121
|
};
|
|
46
|
-
const instanceHandleFor = (namespace, spec, instanceName) => {
|
|
122
|
+
const instanceHandleFor = (namespace, spec, instanceName, options) => {
|
|
47
123
|
const stub = () => namespace.get(namespace.idFromName(instanceName));
|
|
48
124
|
return {
|
|
49
|
-
...
|
|
125
|
+
...coldStartRetryingHandle(async (request) => stub().fetch(request), options),
|
|
50
126
|
destroy: async () => lifecycleCall(stub(), "destroy", spec.binding),
|
|
51
127
|
egress: egressControlsFor(stub, spec.binding),
|
|
52
128
|
getState: async () => lifecycleCall(stub(), "getState", spec.binding),
|
|
53
129
|
renewActivityTimeout: async () => lifecycleCall(stub(), "renewActivityTimeout", spec.binding),
|
|
54
|
-
start: async (
|
|
130
|
+
start: async (startOptions) => lifecycleCall(stub(), "start", spec.binding, startOptions),
|
|
55
131
|
stop: async (signal) => lifecycleCall(stub(), "stop", spec.binding, signal)
|
|
56
132
|
};
|
|
57
133
|
};
|
|
@@ -59,14 +135,6 @@ const randomPoolName = (size) => (
|
|
|
59
135
|
// eslint-disable-next-line sonarjs/pseudo-random -- load-balancing pick across interchangeable instances, not a security decision
|
|
60
136
|
`pool-${String(Math.floor(Math.random() * size))}`
|
|
61
137
|
);
|
|
62
|
-
const sleep = async (ms) => {
|
|
63
|
-
if (ms <= 0) {
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
await new Promise((resolve) => {
|
|
67
|
-
setTimeout(resolve, ms);
|
|
68
|
-
});
|
|
69
|
-
};
|
|
70
138
|
const retryOnServerError = (response) => response.status >= 500;
|
|
71
139
|
const poolHandleFor = (namespace, spec, options = {}, port) => {
|
|
72
140
|
const size = options.size ?? spec.maxInstances ?? DEFAULT_POOL_SIZE;
|
|
@@ -98,14 +166,15 @@ const poolHandleFor = (namespace, spec, options = {}, port) => {
|
|
|
98
166
|
};
|
|
99
167
|
const accessorFor = (namespace, spec) => {
|
|
100
168
|
return {
|
|
101
|
-
any: (count) => handleFor(namespace, randomPoolName(count ?? spec.maxInstances ?? DEFAULT_POOL_SIZE)),
|
|
102
|
-
get: (name) => instanceHandleFor(namespace, spec, name),
|
|
169
|
+
any: (count, options) => handleFor(namespace, randomPoolName(count ?? spec.maxInstances ?? DEFAULT_POOL_SIZE), options),
|
|
170
|
+
get: (name, options) => instanceHandleFor(namespace, spec, name, options),
|
|
103
171
|
pool: (options) => poolHandleFor(namespace, spec, options)
|
|
104
172
|
};
|
|
105
173
|
};
|
|
106
174
|
const missingBindingAccessor = (spec) => {
|
|
107
175
|
const fail = () => {
|
|
108
|
-
throw new
|
|
176
|
+
throw new LunoraError(
|
|
177
|
+
"INTERNAL",
|
|
109
178
|
`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.`
|
|
110
179
|
);
|
|
111
180
|
};
|
|
@@ -146,10 +215,11 @@ const createContainerTestContext = (handlers) => {
|
|
|
146
215
|
containers[exportName] = {
|
|
147
216
|
// `.any()`/`.pool()` route to a fixed `pool-0` so the handler's
|
|
148
217
|
// `instance.name` is deterministic under test; the double doesn't
|
|
149
|
-
// simulate the random-pick or retry/backoff the real pool
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
218
|
+
// simulate the random-pick or retry/backoff the real pool/cold-start
|
|
219
|
+
// path does (`attempts: 1` keeps a handler's own 5xx from looping).
|
|
220
|
+
any: () => handleFor(namespace, "pool-0", { attempts: 1 }),
|
|
221
|
+
get: (name) => instanceHandleFor(namespace, spec, name, { attempts: 1 }),
|
|
222
|
+
pool: () => handleFor(namespace, "pool-0", { attempts: 1 })
|
|
153
223
|
};
|
|
154
224
|
}
|
|
155
225
|
return containers;
|
|
@@ -203,6 +203,22 @@ interface ContainerConfig {
|
|
|
203
203
|
*/
|
|
204
204
|
secrets?: ReadonlyArray<string>;
|
|
205
205
|
/**
|
|
206
|
+
* Cloudflare **Secrets Store** secrets forwarded into the container's
|
|
207
|
+
* environment, as a map of *container env-var name → Worker Secrets Store
|
|
208
|
+
* binding name*. Each binding is resolved with its async `.get()` the first
|
|
209
|
+
* time the instance starts, then injected as that env var — e.g.
|
|
210
|
+
* `{ STRIPE_KEY: "STRIPE_SECRET" }` runs `env.STRIPE_SECRET.get()` and sets
|
|
211
|
+
* `STRIPE_KEY` inside the container. Unlike {@link ContainerConfig.secrets}
|
|
212
|
+
* (plain Worker text secrets), this pulls from a `secrets_store_secrets`
|
|
213
|
+
* binding. A name already used by `env`/`secrets` is rejected at authoring
|
|
214
|
+
* time; a missing binding or unreadable value fails the start. Applies
|
|
215
|
+
* to the default start (the `ctx.containers` proxy path and a bare
|
|
216
|
+
* `start()`); a per-instance `start({ envVars })` replaces the env set
|
|
217
|
+
* wholesale, as it does for `env`/`secrets`. (Upstream
|
|
218
|
+
* cloudflare/containers#96.)
|
|
219
|
+
*/
|
|
220
|
+
secretsStore?: Readonly<Record<string, string>>;
|
|
221
|
+
/**
|
|
206
222
|
* Idle timeout after which the instance is put to sleep, e.g. `"5m"`,
|
|
207
223
|
* `"30s"`, or a number of seconds. Cloudflare's default is `"10m"`.
|
|
208
224
|
*/
|
|
@@ -231,4 +247,4 @@ type NormalizedContainerImage = {
|
|
|
231
247
|
kind: "registry"; /** Fully-qualified image reference (wrangler `image`). */
|
|
232
248
|
reference: string;
|
|
233
249
|
};
|
|
234
|
-
export { BuildImageSource as B,
|
|
250
|
+
export { BuildImageSource as B, ContainerConfig as C, 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 };
|
|
@@ -203,6 +203,22 @@ interface ContainerConfig {
|
|
|
203
203
|
*/
|
|
204
204
|
secrets?: ReadonlyArray<string>;
|
|
205
205
|
/**
|
|
206
|
+
* Cloudflare **Secrets Store** secrets forwarded into the container's
|
|
207
|
+
* environment, as a map of *container env-var name → Worker Secrets Store
|
|
208
|
+
* binding name*. Each binding is resolved with its async `.get()` the first
|
|
209
|
+
* time the instance starts, then injected as that env var — e.g.
|
|
210
|
+
* `{ STRIPE_KEY: "STRIPE_SECRET" }` runs `env.STRIPE_SECRET.get()` and sets
|
|
211
|
+
* `STRIPE_KEY` inside the container. Unlike {@link ContainerConfig.secrets}
|
|
212
|
+
* (plain Worker text secrets), this pulls from a `secrets_store_secrets`
|
|
213
|
+
* binding. A name already used by `env`/`secrets` is rejected at authoring
|
|
214
|
+
* time; a missing binding or unreadable value fails the start. Applies
|
|
215
|
+
* to the default start (the `ctx.containers` proxy path and a bare
|
|
216
|
+
* `start()`); a per-instance `start({ envVars })` replaces the env set
|
|
217
|
+
* wholesale, as it does for `env`/`secrets`. (Upstream
|
|
218
|
+
* cloudflare/containers#96.)
|
|
219
|
+
*/
|
|
220
|
+
secretsStore?: Readonly<Record<string, string>>;
|
|
221
|
+
/**
|
|
206
222
|
* Idle timeout after which the instance is put to sleep, e.g. `"5m"`,
|
|
207
223
|
* `"30s"`, or a number of seconds. Cloudflare's default is `"10m"`.
|
|
208
224
|
*/
|
|
@@ -231,4 +247,4 @@ type NormalizedContainerImage = {
|
|
|
231
247
|
kind: "registry"; /** Fully-qualified image reference (wrangler `image`). */
|
|
232
248
|
reference: string;
|
|
233
249
|
};
|
|
234
|
-
export { BuildImageSource as B,
|
|
250
|
+
export { BuildImageSource as B, ContainerConfig as C, 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.6",
|
|
4
4
|
"description": "Cloudflare Containers for Lunora: defineContainer, generated Container DO classes, and the ctx.containers action surface",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"access": "public"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@
|
|
55
|
+
"@lunora/errors": "1.0.0-alpha.1"
|
|
56
56
|
},
|
|
57
57
|
"engines": {
|
|
58
58
|
"node": "^22.15.0 || >=24.11.0"
|