@lunora/container 1.0.0-alpha.4 → 1.0.0-alpha.5
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/do/index.d.mts +518 -4
- package/dist/do/index.d.ts +518 -4
- package/dist/do/index.mjs +71 -3
- package/dist/index.d.mts +45 -9
- package/dist/index.d.ts +45 -9
- package/dist/index.mjs +2 -2
- package/dist/packem_shared/ContainerProxy-DWqUX_re.mjs +1474 -0
- package/dist/packem_shared/{containerBindingName-C7Lic2ET.mjs → containerBindingName-CWmEE_3Y.mjs} +15 -0
- package/dist/packem_shared/{createContainerContext-DBp8oJOr.mjs → createContainerContext-BtzK5gJL.mjs} +88 -21
- 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 +1 -4
package/dist/packem_shared/{containerBindingName-C7Lic2ET.mjs → containerBindingName-CWmEE_3Y.mjs}
RENAMED
|
@@ -60,6 +60,19 @@ const assertValidImage = (image) => {
|
|
|
60
60
|
throw new TypeError("defineContainer: `image.registry` must be a non-empty fully-qualified image reference");
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
|
+
const assertValidSecretsStore = (config, envNames, secretNames) => {
|
|
64
|
+
for (const [envName, binding] of Object.entries(config.secretsStore ?? {})) {
|
|
65
|
+
if (!ENV_NAME_PATTERN.test(envName)) {
|
|
66
|
+
throw new TypeError(`defineContainer: secretsStore env name "${envName}" is not a valid environment variable name`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof binding !== "string" || binding.trim().length === 0) {
|
|
69
|
+
throw new TypeError(`defineContainer: \`secretsStore["${envName}"]\` must be a non-empty Secrets Store binding name`);
|
|
70
|
+
}
|
|
71
|
+
if (envNames.has(envName) || secretNames.has(envName)) {
|
|
72
|
+
throw new TypeError(`defineContainer: "${envName}" is declared in both \`secretsStore\` and \`env\`/\`secrets\` — pick one source for the value`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
63
76
|
const assertValidEnvAndSecrets = (config) => {
|
|
64
77
|
for (const name of Object.keys(config.env ?? {})) {
|
|
65
78
|
if (!ENV_NAME_PATTERN.test(name)) {
|
|
@@ -72,6 +85,7 @@ const assertValidEnvAndSecrets = (config) => {
|
|
|
72
85
|
}
|
|
73
86
|
}
|
|
74
87
|
const envNames = new Set(Object.keys(config.env ?? {}));
|
|
88
|
+
const secretNames = new Set(config.secrets);
|
|
75
89
|
for (const secret of config.secrets ?? []) {
|
|
76
90
|
if (!ENV_NAME_PATTERN.test(secret)) {
|
|
77
91
|
throw new TypeError(`defineContainer: secret name "${secret}" is not a valid environment variable name`);
|
|
@@ -82,6 +96,7 @@ const assertValidEnvAndSecrets = (config) => {
|
|
|
82
96
|
);
|
|
83
97
|
}
|
|
84
98
|
}
|
|
99
|
+
assertValidSecretsStore(config, envNames, secretNames);
|
|
85
100
|
};
|
|
86
101
|
const assertValidPort = (port, field) => {
|
|
87
102
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
@@ -11,6 +11,8 @@ const applyJurisdiction = (namespace, jurisdiction) => {
|
|
|
11
11
|
};
|
|
12
12
|
const DEFAULT_POOL_SIZE = 3;
|
|
13
13
|
const DEFAULT_MAX_BACKOFF_MS = 3e4;
|
|
14
|
+
const DEFAULT_COLD_START_ATTEMPTS = 3;
|
|
15
|
+
const DEFAULT_COLD_START_BACKOFF_MS = 500;
|
|
14
16
|
const TARGET_PORT_HEADER = "cf-container-target-port";
|
|
15
17
|
const toRequest = (input, init, port) => {
|
|
16
18
|
const request = typeof input === "string" && input.startsWith("/") ? new Request(`http://container${input}`, init) : new Request(input, init);
|
|
@@ -19,13 +21,85 @@ const toRequest = (input, init, port) => {
|
|
|
19
21
|
}
|
|
20
22
|
return request;
|
|
21
23
|
};
|
|
22
|
-
const
|
|
24
|
+
const sleep = async (ms) => {
|
|
25
|
+
if (ms <= 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await new Promise((resolve) => {
|
|
29
|
+
setTimeout(resolve, ms);
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
const COLD_START_ERROR_PATTERN = /no container instance|not listening|try again later|rate.?limit|provision/i;
|
|
33
|
+
const COLD_START_NO_INSTANCE_BODY = "no Container instance available";
|
|
34
|
+
const COLD_START_START_FAILURE_BODY = "Failed to start container:";
|
|
35
|
+
const COLD_START_SENTINEL_SCAN_BYTES = 1024;
|
|
36
|
+
const readBodyPrefix = async (response) => {
|
|
37
|
+
const stream = response.clone().body;
|
|
38
|
+
if (stream === null) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
const reader = stream.getReader();
|
|
42
|
+
const decoder = new TextDecoder();
|
|
43
|
+
let text = "";
|
|
44
|
+
try {
|
|
45
|
+
while (text.length < COLD_START_SENTINEL_SCAN_BYTES) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
text += decoder.decode(value, { stream: true });
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
await reader.cancel();
|
|
54
|
+
}
|
|
55
|
+
return text;
|
|
56
|
+
};
|
|
57
|
+
const isColdStartError = (error) => error instanceof Error && COLD_START_ERROR_PATTERN.test(error.message);
|
|
58
|
+
const isColdStartTransient = async (response) => {
|
|
59
|
+
if (response.status === 429) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (response.status !== 500 && response.status !== 503) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const body = await readBodyPrefix(response);
|
|
67
|
+
return body.includes(COLD_START_NO_INSTANCE_BODY) || body.startsWith(COLD_START_START_FAILURE_BODY);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const coldStartRetryingHandle = (send, options = {}, port) => {
|
|
73
|
+
const attempts = Math.max(1, options.attempts ?? DEFAULT_COLD_START_ATTEMPTS);
|
|
74
|
+
const baseBackoff = options.backoffMs ?? DEFAULT_COLD_START_BACKOFF_MS;
|
|
75
|
+
const maxBackoff = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
23
76
|
return {
|
|
24
|
-
fetch: async (input, init) =>
|
|
25
|
-
|
|
77
|
+
fetch: async (input, init) => {
|
|
78
|
+
const totalAttempts = typeof input === "string" ? attempts : 1;
|
|
79
|
+
let lastError;
|
|
80
|
+
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
|
81
|
+
const isLastAttempt = attempt === totalAttempts - 1;
|
|
82
|
+
if (attempt > 0) {
|
|
83
|
+
await sleep(Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff));
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const response = await send(toRequest(input, init, port));
|
|
87
|
+
if (isLastAttempt || !await isColdStartTransient(response)) {
|
|
88
|
+
return response;
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
lastError = error;
|
|
92
|
+
if (isLastAttempt || !isColdStartError(error)) {
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw lastError instanceof Error ? lastError : new Error("ctx.containers: cold-start retry exhausted");
|
|
98
|
+
},
|
|
99
|
+
port: (targetPort) => coldStartRetryingHandle(send, options, targetPort)
|
|
26
100
|
};
|
|
27
101
|
};
|
|
28
|
-
const handleFor = (namespace, instanceName) =>
|
|
102
|
+
const handleFor = (namespace, instanceName, options) => coldStartRetryingHandle(async (request) => namespace.get(namespace.idFromName(instanceName)).fetch(request), options);
|
|
29
103
|
const lifecycleCall = async (stub, method, binding, argument) => {
|
|
30
104
|
const rpc = stub[method];
|
|
31
105
|
if (typeof rpc !== "function") {
|
|
@@ -43,15 +117,15 @@ const egressControlsFor = (stub, binding) => {
|
|
|
43
117
|
setDenied: async (hosts) => lifecycleCall(stub(), "setDeniedHosts", binding, [...hosts])
|
|
44
118
|
};
|
|
45
119
|
};
|
|
46
|
-
const instanceHandleFor = (namespace, spec, instanceName) => {
|
|
120
|
+
const instanceHandleFor = (namespace, spec, instanceName, options) => {
|
|
47
121
|
const stub = () => namespace.get(namespace.idFromName(instanceName));
|
|
48
122
|
return {
|
|
49
|
-
...
|
|
123
|
+
...coldStartRetryingHandle(async (request) => stub().fetch(request), options),
|
|
50
124
|
destroy: async () => lifecycleCall(stub(), "destroy", spec.binding),
|
|
51
125
|
egress: egressControlsFor(stub, spec.binding),
|
|
52
126
|
getState: async () => lifecycleCall(stub(), "getState", spec.binding),
|
|
53
127
|
renewActivityTimeout: async () => lifecycleCall(stub(), "renewActivityTimeout", spec.binding),
|
|
54
|
-
start: async (
|
|
128
|
+
start: async (startOptions) => lifecycleCall(stub(), "start", spec.binding, startOptions),
|
|
55
129
|
stop: async (signal) => lifecycleCall(stub(), "stop", spec.binding, signal)
|
|
56
130
|
};
|
|
57
131
|
};
|
|
@@ -59,14 +133,6 @@ const randomPoolName = (size) => (
|
|
|
59
133
|
// eslint-disable-next-line sonarjs/pseudo-random -- load-balancing pick across interchangeable instances, not a security decision
|
|
60
134
|
`pool-${String(Math.floor(Math.random() * size))}`
|
|
61
135
|
);
|
|
62
|
-
const sleep = async (ms) => {
|
|
63
|
-
if (ms <= 0) {
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
await new Promise((resolve) => {
|
|
67
|
-
setTimeout(resolve, ms);
|
|
68
|
-
});
|
|
69
|
-
};
|
|
70
136
|
const retryOnServerError = (response) => response.status >= 500;
|
|
71
137
|
const poolHandleFor = (namespace, spec, options = {}, port) => {
|
|
72
138
|
const size = options.size ?? spec.maxInstances ?? DEFAULT_POOL_SIZE;
|
|
@@ -98,8 +164,8 @@ const poolHandleFor = (namespace, spec, options = {}, port) => {
|
|
|
98
164
|
};
|
|
99
165
|
const accessorFor = (namespace, spec) => {
|
|
100
166
|
return {
|
|
101
|
-
any: (count) => handleFor(namespace, randomPoolName(count ?? spec.maxInstances ?? DEFAULT_POOL_SIZE)),
|
|
102
|
-
get: (name) => instanceHandleFor(namespace, spec, name),
|
|
167
|
+
any: (count, options) => handleFor(namespace, randomPoolName(count ?? spec.maxInstances ?? DEFAULT_POOL_SIZE), options),
|
|
168
|
+
get: (name, options) => instanceHandleFor(namespace, spec, name, options),
|
|
103
169
|
pool: (options) => poolHandleFor(namespace, spec, options)
|
|
104
170
|
};
|
|
105
171
|
};
|
|
@@ -146,10 +212,11 @@ const createContainerTestContext = (handlers) => {
|
|
|
146
212
|
containers[exportName] = {
|
|
147
213
|
// `.any()`/`.pool()` route to a fixed `pool-0` so the handler's
|
|
148
214
|
// `instance.name` is deterministic under test; the double doesn't
|
|
149
|
-
// simulate the random-pick or retry/backoff the real pool
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
215
|
+
// simulate the random-pick or retry/backoff the real pool/cold-start
|
|
216
|
+
// path does (`attempts: 1` keeps a handler's own 5xx from looping).
|
|
217
|
+
any: () => handleFor(namespace, "pool-0", { attempts: 1 }),
|
|
218
|
+
get: (name) => instanceHandleFor(namespace, spec, name, { attempts: 1 }),
|
|
219
|
+
pool: () => handleFor(namespace, "pool-0", { attempts: 1 })
|
|
153
220
|
};
|
|
154
221
|
}
|
|
155
222
|
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.5",
|
|
4
4
|
"description": "Cloudflare Containers for Lunora: defineContainer, generated Container DO classes, and the ctx.containers action surface",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -51,9 +51,6 @@
|
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|
|
53
53
|
},
|
|
54
|
-
"dependencies": {
|
|
55
|
-
"@cloudflare/containers": "^0.3.7"
|
|
56
|
-
},
|
|
57
54
|
"engines": {
|
|
58
55
|
"node": "^22.15.0 || >=24.11.0"
|
|
59
56
|
}
|