@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.
@@ -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 sendingHandle = (send, port) => {
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) => send(toRequest(input, init, port)),
25
- port: (targetPort) => sendingHandle(send, targetPort)
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) => sendingHandle(async (request) => namespace.get(namespace.idFromName(instanceName)).fetch(request));
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
- ...sendingHandle(async (request) => stub().fetch(request)),
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 (options) => lifecycleCall(stub(), "start", spec.binding, options),
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 does.
150
- any: () => handleFor(namespace, "pool-0"),
151
- get: (name) => instanceHandleFor(namespace, spec, name),
152
- pool: () => handleFor(namespace, "pool-0")
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, 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.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
  }