@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.
@@ -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 Error(
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 sendingHandle = (send, port) => {
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) => send(toRequest(input, init, port)),
25
- port: (targetPort) => sendingHandle(send, targetPort)
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) => sendingHandle(async (request) => namespace.get(namespace.idFromName(instanceName)).fetch(request));
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
- ...sendingHandle(async (request) => stub().fetch(request)),
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 (options) => lifecycleCall(stub(), "start", spec.binding, options),
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 Error(
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 does.
150
- any: () => handleFor(namespace, "pool-0"),
151
- get: (name) => instanceHandleFor(namespace, spec, name),
152
- pool: () => handleFor(namespace, "pool-0")
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, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c, ContainerReadinessCheck as d, ContainerRollout as e, CustomContainerInstanceType as f, NamedContainerInstanceType as g };
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, ContainerDefinition as C, NormalizedContainerImage as N, RegistryImageSource as R, ContainerConfig as a, ContainerImageSource as b, ContainerInstanceType as c, ContainerReadinessCheck as d, ContainerRollout as e, CustomContainerInstanceType as f, NamedContainerInstanceType as g };
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.4",
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
- "@cloudflare/containers": "^0.3.7"
55
+ "@lunora/errors": "1.0.0-alpha.1"
56
56
  },
57
57
  "engines": {
58
58
  "node": "^22.15.0 || >=24.11.0"