@lunora/container 0.0.0 → 1.0.0-alpha.10

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.
@@ -0,0 +1,1474 @@
1
+ import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
2
+
3
+ function generateId(length = 9) {
4
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
5
+ const bytes = new Uint8Array(length);
6
+ crypto.getRandomValues(bytes);
7
+ let result = "";
8
+ for (let i = 0; i < length; i++) {
9
+ result += alphabet[bytes[i] % alphabet.length];
10
+ }
11
+ return result;
12
+ }
13
+ function parseTimeExpression(timeExpression) {
14
+ if (typeof timeExpression === "number") {
15
+ return timeExpression;
16
+ }
17
+ if (typeof timeExpression === "string") {
18
+ const match = timeExpression.match(/^(\d+)([smh])$/);
19
+ if (!match) {
20
+ throw new Error(`invalid time expression ${timeExpression}`);
21
+ }
22
+ const value = parseInt(match[1]);
23
+ const unit = match[2];
24
+ switch (unit) {
25
+ case "s":
26
+ return value;
27
+ case "m":
28
+ return value * 60;
29
+ case "h":
30
+ return value * 60 * 60;
31
+ default:
32
+ throw new Error(`unknown time unit ${unit}`);
33
+ }
34
+ }
35
+ throw new Error(`invalid type for a time expression: ${typeof timeExpression}`);
36
+ }
37
+
38
+ const NO_CONTAINER_INSTANCE_ERROR = "there is no container instance that can be provided to this durable object";
39
+ const RATE_LIMITED_ERROR = "you are requesting too many containers per second";
40
+ const RUNTIME_SIGNALLED_ERROR = "runtime signalled the container to exit:";
41
+ const UNEXPECTED_EXIT_ERROR = "container exited with unexpected exit code:";
42
+ const NOT_LISTENING_ERROR = "container is not listening";
43
+ const CONTAINER_STATE_KEY = "__CF_CONTAINER_STATE";
44
+ const OUTBOUND_CONFIGURATION_KEY = "OUTBOUND_CONFIGURATION";
45
+ const MAX_ALARM_RETRIES = 3;
46
+ const MIN_ALARM_REARM_MS = 100;
47
+ const MAX_ALARM_REARM_MS = 3 * 60 * 1e3;
48
+ const PING_TIMEOUT_MS = 5e3;
49
+ const DEFAULT_SLEEP_AFTER = "10m";
50
+ const INSTANCE_POLL_INTERVAL_MS = 300;
51
+ const TIMEOUT_TO_GET_CONTAINER_MS = 8e3;
52
+ const TIMEOUT_TO_GET_PORTS_MS = 2e4;
53
+ const FALLBACK_PORT_TO_CHECK = 33;
54
+ function outboundParams(_handler, params) {
55
+ return params;
56
+ }
57
+ const outboundHandlersRegistry = /* @__PURE__ */ new Map();
58
+ const defaultOutboundHandlerNameRegistry = /* @__PURE__ */ new Map();
59
+ const outboundByHostRegistry = /* @__PURE__ */ new Map();
60
+ const signalToNumbers = {
61
+ SIGINT: 2,
62
+ SIGTERM: 15,
63
+ SIGKILL: 9
64
+ };
65
+ function isErrorOfType(e, matchingString) {
66
+ const errorString = e instanceof Error ? e.message : String(e);
67
+ return errorString.toLowerCase().includes(matchingString);
68
+ }
69
+ const isNoInstanceError = (error) => isErrorOfType(error, NO_CONTAINER_INSTANCE_ERROR);
70
+ const isRateLimitedError = (error) => isErrorOfType(error, RATE_LIMITED_ERROR);
71
+ const isRuntimeSignalledError = (error) => isErrorOfType(error, RUNTIME_SIGNALLED_ERROR);
72
+ const isNotListeningError = (error) => isErrorOfType(error, NOT_LISTENING_ERROR);
73
+ const isContainerExitNonZeroError = (error) => isErrorOfType(error, UNEXPECTED_EXIT_ERROR);
74
+ function getExitCodeFromError(error) {
75
+ if (!(error instanceof Error)) {
76
+ return null;
77
+ }
78
+ if (isRuntimeSignalledError(error)) {
79
+ return +error.message.toLowerCase().slice(error.message.toLowerCase().indexOf(RUNTIME_SIGNALLED_ERROR) + RUNTIME_SIGNALLED_ERROR.length + 1);
80
+ }
81
+ if (isContainerExitNonZeroError(error)) {
82
+ return +error.message.toLowerCase().slice(error.message.toLowerCase().indexOf(UNEXPECTED_EXIT_ERROR) + UNEXPECTED_EXIT_ERROR.length + 1);
83
+ }
84
+ return null;
85
+ }
86
+ function addTimeoutSignal(existingSignal, timeoutMs) {
87
+ const controller = new AbortController();
88
+ if (existingSignal?.aborted) {
89
+ controller.abort();
90
+ return controller.signal;
91
+ }
92
+ existingSignal?.addEventListener("abort", () => controller.abort());
93
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
94
+ controller.signal.addEventListener("abort", () => clearTimeout(timeoutId));
95
+ return controller.signal;
96
+ }
97
+ function simpleGlobMatch(pattern, value) {
98
+ const parts = pattern.split("*");
99
+ if (parts.length === 1)
100
+ return pattern === value;
101
+ if (!value.startsWith(parts[0]))
102
+ return false;
103
+ if (!value.endsWith(parts[parts.length - 1]))
104
+ return false;
105
+ let pos = parts[0].length;
106
+ for (let i = 1; i < parts.length - 1; i++) {
107
+ const idx = value.indexOf(parts[i], pos);
108
+ if (idx === -1)
109
+ return false;
110
+ pos = idx + parts[i].length;
111
+ }
112
+ return pos <= value.length - parts[parts.length - 1].length;
113
+ }
114
+ function matchesHostList(hostname, patterns) {
115
+ return patterns.some((pattern) => simpleGlobMatch(pattern, hostname));
116
+ }
117
+ function normalizeHostname(hostname) {
118
+ let end = hostname.length;
119
+ while (end > 0 && hostname[end - 1] === ".") {
120
+ end--;
121
+ }
122
+ return hostname.slice(0, end);
123
+ }
124
+ class ContainerState {
125
+ storage;
126
+ status;
127
+ constructor(storage) {
128
+ this.storage = storage;
129
+ }
130
+ async setRunning() {
131
+ await this.setStatusAndupdate("running");
132
+ }
133
+ async setHealthy() {
134
+ await this.setStatusAndupdate("healthy");
135
+ }
136
+ async setStopping() {
137
+ await this.setStatusAndupdate("stopping");
138
+ }
139
+ async setStopped() {
140
+ await this.setStatusAndupdate("stopped");
141
+ }
142
+ async setStoppedIfUnchanged(previousState) {
143
+ if (this.status !== previousState) {
144
+ return;
145
+ }
146
+ await this.setStopped();
147
+ }
148
+ async setStoppedWithCode(exitCode) {
149
+ this.status = { status: "stopped_with_code", lastChange: Date.now(), exitCode };
150
+ await this.update();
151
+ }
152
+ async getState() {
153
+ if (!this.status) {
154
+ const state = await this.storage.get(CONTAINER_STATE_KEY);
155
+ if (!state) {
156
+ this.status = {
157
+ status: "stopped",
158
+ lastChange: Date.now()
159
+ };
160
+ await this.update();
161
+ } else {
162
+ this.status = state;
163
+ }
164
+ }
165
+ return this.status;
166
+ }
167
+ async setStatusAndupdate(status) {
168
+ this.status = { status, lastChange: Date.now() };
169
+ await this.update();
170
+ }
171
+ async update() {
172
+ if (!this.status)
173
+ throw new Error("status should be init");
174
+ await this.storage.put(CONTAINER_STATE_KEY, this.status);
175
+ }
176
+ }
177
+ class ContainerProxy extends WorkerEntrypoint {
178
+ async fetch(request) {
179
+ const url = new URL(request.url);
180
+ const hostname = normalizeHostname(url.hostname);
181
+ const { className, containerId, outboundByHostOverrides, outboundHandlerOverride, enableInternet, allowedHosts, deniedHosts, interceptAll } = this.ctx.props;
182
+ const baseCtx = { containerId, className };
183
+ if (deniedHosts && matchesHostList(hostname, deniedHosts)) {
184
+ return new Response("Origin is disallowed", { status: 520 });
185
+ }
186
+ if (allowedHosts && !matchesHostList(hostname, allowedHosts)) {
187
+ return new Response("Origin is disallowed", { status: 520 });
188
+ }
189
+ const handlers = outboundHandlersRegistry.get(className);
190
+ if (outboundByHostOverrides && handlers) {
191
+ const override = outboundByHostOverrides[hostname] ?? Object.entries(outboundByHostOverrides).find(([pattern]) => pattern !== hostname && simpleGlobMatch(pattern, hostname))?.[1];
192
+ if (override && handlers[override.method]) {
193
+ return handlers[override.method](request, this.env, {
194
+ ...baseCtx,
195
+ params: override.params
196
+ });
197
+ }
198
+ }
199
+ const handlersByHost = outboundByHostRegistry.get(className);
200
+ if (handlersByHost) {
201
+ const handler = handlersByHost[hostname] ?? Object.entries(handlersByHost).find(([pattern]) => pattern !== hostname && simpleGlobMatch(pattern, hostname))?.[1];
202
+ if (handler) {
203
+ return handler(request, this.env, baseCtx);
204
+ }
205
+ }
206
+ if (!interceptAll) {
207
+ if (allowedHosts || enableInternet) {
208
+ return fetch(request);
209
+ }
210
+ return new Response("Origin is disallowed", { status: 520 });
211
+ }
212
+ if (outboundHandlerOverride && handlers?.[outboundHandlerOverride.method]) {
213
+ return handlers[outboundHandlerOverride.method](request, this.env, {
214
+ ...baseCtx,
215
+ params: outboundHandlerOverride.params
216
+ });
217
+ }
218
+ const defaultOutboundHandlerName = defaultOutboundHandlerNameRegistry.get(className);
219
+ if (defaultOutboundHandlerName && handlers?.[defaultOutboundHandlerName]) {
220
+ return handlers[defaultOutboundHandlerName](request, this.env, baseCtx);
221
+ }
222
+ if (allowedHosts) {
223
+ return fetch(request);
224
+ }
225
+ if (enableInternet) {
226
+ return fetch(request);
227
+ }
228
+ return new Response("Origin is disallowed", { status: 520 });
229
+ }
230
+ }
231
+ class Container extends DurableObject {
232
+ static get outboundByHost() {
233
+ return outboundByHostRegistry.get(this.name);
234
+ }
235
+ static set outboundByHost(handlers) {
236
+ outboundByHostRegistry.set(this.name, handlers);
237
+ }
238
+ static get outboundHandlers() {
239
+ return outboundHandlersRegistry.get(this.name);
240
+ }
241
+ static set outboundHandlers(handlers) {
242
+ const existing = outboundHandlersRegistry.get(this.name) ?? {};
243
+ outboundHandlersRegistry.set(this.name, { ...existing, ...handlers });
244
+ }
245
+ static get outbound() {
246
+ const handlerName = defaultOutboundHandlerNameRegistry.get(this.name);
247
+ if (!handlerName)
248
+ return void 0;
249
+ return outboundHandlersRegistry.get(this.name)?.[handlerName];
250
+ }
251
+ static set outbound(handler) {
252
+ const key = "__outbound__";
253
+ const existing = outboundHandlersRegistry.get(this.name) ?? {};
254
+ outboundHandlersRegistry.set(this.name, { ...existing, [key]: handler });
255
+ defaultOutboundHandlerNameRegistry.set(this.name, key);
256
+ }
257
+ static get outboundProxies() {
258
+ return this.outboundHandlers;
259
+ }
260
+ static set outboundProxies(handlers) {
261
+ this.outboundHandlers = handlers;
262
+ }
263
+ static get outboundProxy() {
264
+ return this.outbound;
265
+ }
266
+ static set outboundProxy(handler) {
267
+ this.outbound = handler;
268
+ }
269
+ // =========================
270
+ // Public Attributes
271
+ // =========================
272
+ // Default port for the container (undefined means no default port)
273
+ defaultPort;
274
+ // Required ports that should be checked for availability during container startup
275
+ // Override this in your subclass to specify ports that must be ready
276
+ requiredPorts;
277
+ // Timeout after which the container will sleep if no activity
278
+ // The signal sent to the container by default is a SIGTERM.
279
+ // The container won't get a SIGKILL if this threshold is triggered.
280
+ sleepAfter = DEFAULT_SLEEP_AFTER;
281
+ // Container configuration properties
282
+ // Set these properties directly in your container instance
283
+ envVars = {};
284
+ entrypoint;
285
+ enableInternet = true;
286
+ labels = {};
287
+ // When true, outbound HTTPS traffic from the container will be intercepted.
288
+ // The container must trust /etc/cloudflare/certs/cloudflare-containers-ca.crt
289
+ interceptHttps = false;
290
+ // Hosts that are allowed to access the internet, even when enableInternet is false.
291
+ // Useful for allowing specific domains on a per-host basis.
292
+ allowedHosts;
293
+ // Hosts that are denied internet access, even when enableInternet is true.
294
+ // Also blocks hosts from being handled by the catch-all outbound handler.
295
+ deniedHosts;
296
+ // pingEndpoint is the host and path value that the class will use to send a request to the container and check if the
297
+ // instance is ready.
298
+ //
299
+ // The user does not have to implement this route by any means,
300
+ // but it's still useful if you want to control the path that
301
+ // the Container class uses to send HTTP requests to.
302
+ pingEndpoint = "ping";
303
+ applyOutboundInterceptionPromise = Promise.resolve();
304
+ usingInterception = false;
305
+ // =========================
306
+ // PUBLIC INTERFACE
307
+ // =========================
308
+ constructor(ctx, env, options) {
309
+ super(ctx, env);
310
+ if (ctx.container === void 0) {
311
+ throw new Error("Containers have not been enabled for this Durable Object class. Have you correctly setup your Wrangler config? More info: https://developers.cloudflare.com/containers/get-started/#configuration");
312
+ }
313
+ this.state = new ContainerState(this.ctx.storage);
314
+ const persistedOutboundConfiguration = this.restoreOutboundConfiguration();
315
+ this.ctx.blockConcurrencyWhile(async () => {
316
+ await this.scheduleNextAlarm();
317
+ this.renewActivityTimeout();
318
+ const ctor = this.constructor;
319
+ if (persistedOutboundConfiguration !== void 0 || ctor.outboundByHost !== void 0 || ctor.outbound !== void 0 || ctor.outboundHandlers !== void 0 || this.effectiveAllowedHosts !== void 0 || this.effectiveDeniedHosts !== void 0) {
320
+ this.usingInterception = true;
321
+ }
322
+ if (this.container.running) {
323
+ this.applyOutboundInterceptionPromise = this.applyOutboundInterception();
324
+ }
325
+ });
326
+ this.container = ctx.container;
327
+ if (options) {
328
+ if (options.defaultPort !== void 0)
329
+ this.defaultPort = options.defaultPort;
330
+ if (options.sleepAfter !== void 0)
331
+ this.sleepAfter = options.sleepAfter;
332
+ if (options.envVars !== void 0)
333
+ this.envVars = options.envVars;
334
+ if (options.entrypoint !== void 0)
335
+ this.entrypoint = options.entrypoint;
336
+ if (options.enableInternet !== void 0)
337
+ this.enableInternet = options.enableInternet;
338
+ }
339
+ this.sql`
340
+ CREATE TABLE IF NOT EXISTS container_schedules (
341
+ id TEXT PRIMARY KEY NOT NULL DEFAULT (randomblob(9)),
342
+ callback TEXT NOT NULL,
343
+ payload TEXT,
344
+ type TEXT NOT NULL CHECK(type IN ('scheduled', 'delayed')),
345
+ time INTEGER NOT NULL,
346
+ delayInSeconds INTEGER,
347
+ created_at INTEGER DEFAULT (unixepoch())
348
+ )
349
+ `;
350
+ if (this.container.running) {
351
+ this.monitor = this.container.monitor();
352
+ this.setupMonitorCallbacks();
353
+ }
354
+ }
355
+ /**
356
+ * Gets the current state of the container
357
+ * @returns Promise<State>
358
+ */
359
+ async getState() {
360
+ return { ...await this.state.getState() };
361
+ }
362
+ // ====================================
363
+ // OUTBOUND INTERCEPTION CONFIG
364
+ // ====================================
365
+ /**
366
+ * Set the catch-all outbound handler to a named method from `outboundHandlers`.
367
+ * Overrides the default `outbound` at runtime via ContainerProxy props.
368
+ *
369
+ * @param methodName - Name of a method defined in `static outboundHandlers`
370
+ * @param params - Optional params passed to the handler as `ctx.params`
371
+ * @throws Error if the method name is not found in `outboundHandlers`
372
+ */
373
+ async setOutboundHandler(methodName, ...paramsArg) {
374
+ this.validateOutboundHandlerMethodName(methodName);
375
+ this.outboundHandlerOverride = paramsArg.length === 0 ? { method: methodName } : { method: methodName, params: paramsArg[0] };
376
+ await this.refreshOutboundInterception();
377
+ }
378
+ /**
379
+ * Add or override a hostname-specific outbound handler at runtime,
380
+ * referencing a named method from `outboundHandlers`.
381
+ * Overrides any matching entry in `static outboundByHost` for this hostname.
382
+ *
383
+ * @param hostname - The hostname or ip:port to intercept (e.g. `'google.com'`)
384
+ * @param methodName - Name of a method defined in `static outboundHandlers`
385
+ * @param params - Optional params passed to the handler as `ctx.params`
386
+ * @throws Error if the method name is not found in `outboundHandlers`
387
+ */
388
+ async setOutboundByHost(hostname, methodName, ...paramsArg) {
389
+ this.validateOutboundHandlerMethodName(methodName);
390
+ this.outboundByHostOverrides[hostname] = paramsArg.length === 0 ? { method: methodName } : { method: methodName, params: paramsArg[0] };
391
+ await this.refreshOutboundInterception();
392
+ }
393
+ /**
394
+ * Remove a runtime hostname override added via `setOutboundByHost`.
395
+ * The default handler from `static outboundByHost` (if any) will be used again.
396
+ *
397
+ * @param hostname - The hostname or ip:port to stop overriding
398
+ */
399
+ async removeOutboundByHost(hostname) {
400
+ delete this.outboundByHostOverrides[hostname];
401
+ await this.refreshOutboundInterception();
402
+ }
403
+ /**
404
+ * Replace all runtime hostname overrides at once.
405
+ * Each value may be either a method name or an object with `method` and `params`.
406
+ *
407
+ * @param handlers - Record mapping hostnames to handler configs in `outboundHandlers`
408
+ * @throws Error if any method name is not found in `outboundHandlers`
409
+ */
410
+ async setOutboundByHosts(handlers) {
411
+ for (const handler of Object.values(handlers)) {
412
+ const methodName = typeof handler === "string" ? handler : handler.method;
413
+ this.validateOutboundHandlerMethodName(methodName);
414
+ }
415
+ this.outboundByHostOverrides = Object.fromEntries(Object.entries(handlers).map(([hostname, handler]) => [
416
+ hostname,
417
+ typeof handler === "string" ? { method: handler } : handler
418
+ ]));
419
+ await this.refreshOutboundInterception();
420
+ }
421
+ // ====================================
422
+ // ALLOWED / DENIED HOSTS CONFIG
423
+ // ====================================
424
+ /**
425
+ * Replace all allowed hosts at runtime.
426
+ * Allowed hosts get internet access even when `enableInternet` is false.
427
+ *
428
+ * @param hosts - Array of hostnames to allow (e.g. `['api.stripe.com', 'example.com']`)
429
+ */
430
+ async setAllowedHosts(hosts) {
431
+ this.allowedHostsOverride = [...hosts];
432
+ this.usingInterception = true;
433
+ await this.refreshOutboundInterception();
434
+ }
435
+ /**
436
+ * Replace all denied hosts at runtime.
437
+ * Denied hosts are blocked unconditionally, even when `enableInternet` is true
438
+ * or a catch-all outbound handler is set.
439
+ *
440
+ * @param hosts - Array of hostnames to deny (e.g. `['evil.com', 'blocked.org']`)
441
+ */
442
+ async setDeniedHosts(hosts) {
443
+ this.deniedHostsOverride = [...hosts];
444
+ this.usingInterception = true;
445
+ await this.refreshOutboundInterception();
446
+ }
447
+ /**
448
+ * Add a single hostname to the allowed hosts list at runtime.
449
+ *
450
+ * @param hostname - The hostname to allow (e.g. `'api.stripe.com'`)
451
+ */
452
+ async allowHost(hostname) {
453
+ const effective = this.effectiveAllowedHosts ?? [];
454
+ if (!effective.includes(hostname)) {
455
+ this.allowedHostsOverride = [...effective, hostname];
456
+ }
457
+ this.usingInterception = true;
458
+ await this.refreshOutboundInterception();
459
+ }
460
+ /**
461
+ * Add a single hostname to the denied hosts list at runtime.
462
+ *
463
+ * @param hostname - The hostname to deny (e.g. `'evil.com'`)
464
+ */
465
+ async denyHost(hostname) {
466
+ const effective = this.effectiveDeniedHosts ?? [];
467
+ if (!effective.includes(hostname)) {
468
+ this.deniedHostsOverride = [...effective, hostname];
469
+ }
470
+ this.usingInterception = true;
471
+ await this.refreshOutboundInterception();
472
+ }
473
+ /**
474
+ * Remove a hostname from the allowed hosts list.
475
+ *
476
+ * @param hostname - The hostname to remove from the allow list
477
+ */
478
+ async removeAllowedHost(hostname) {
479
+ this.allowedHostsOverride = (this.effectiveAllowedHosts ?? []).filter((h) => h !== hostname);
480
+ await this.refreshOutboundInterception();
481
+ }
482
+ /**
483
+ * Remove a hostname from the denied hosts list.
484
+ *
485
+ * @param hostname - The hostname to remove from the deny list
486
+ */
487
+ async removeDeniedHost(hostname) {
488
+ this.deniedHostsOverride = (this.effectiveDeniedHosts ?? []).filter((h) => h !== hostname);
489
+ await this.refreshOutboundInterception();
490
+ }
491
+ // ==========================
492
+ // CONTAINER STARTING
493
+ // ==========================
494
+ /**
495
+ * Start the container if it's not running and set up monitoring and lifecycle hooks,
496
+ * without waiting for ports to be ready.
497
+ *
498
+ * It will automatically retry if the container fails to start, using the specified waitOptions
499
+ *
500
+ *
501
+ * @example
502
+ * await this.start({
503
+ * envVars: { DEBUG: 'true', NODE_ENV: 'development' },
504
+ * entrypoint: ['npm', 'run', 'dev'],
505
+ * enableInternet: false,
506
+ * labels: { tenant: 'acme', env: 'prod' },
507
+ * });
508
+ *
509
+ * @param startOptions - Override `envVars`, `entrypoint`, `enableInternet` and `labels` on a per-instance basis
510
+ * @param waitOptions - Optional wait configuration with abort signal for cancellation. Default ~8s timeout.
511
+ * @returns A promise that resolves when the container start command has been issued
512
+ * @throws Error if no container context is available or if all start attempts fail
513
+ */
514
+ async start(startOptions, waitOptions) {
515
+ const portToCheck = waitOptions?.portToCheck ?? this.defaultPort ?? (this.requiredPorts ? this.requiredPorts[0] : FALLBACK_PORT_TO_CHECK);
516
+ const pollInterval = waitOptions?.waitInterval ?? INSTANCE_POLL_INTERVAL_MS;
517
+ await this.startContainerIfNotRunning({
518
+ signal: waitOptions?.signal,
519
+ waitInterval: pollInterval,
520
+ retries: waitOptions?.retries ?? Math.ceil(TIMEOUT_TO_GET_CONTAINER_MS / pollInterval),
521
+ portToCheck
522
+ }, startOptions);
523
+ this.setupMonitorCallbacks();
524
+ await this.ctx.blockConcurrencyWhile(async () => {
525
+ await this.onStart();
526
+ });
527
+ }
528
+ async startAndWaitForPorts(portsOrArgs, cancellationOptions, startOptions) {
529
+ let ports;
530
+ let resolvedCancellationOptions;
531
+ let resolvedStartOptions;
532
+ if (typeof portsOrArgs === "object" && portsOrArgs !== null && !Array.isArray(portsOrArgs)) {
533
+ ports = portsOrArgs.ports;
534
+ resolvedCancellationOptions = portsOrArgs.cancellationOptions;
535
+ resolvedStartOptions = portsOrArgs.startOptions;
536
+ } else {
537
+ ports = portsOrArgs;
538
+ resolvedCancellationOptions = cancellationOptions;
539
+ resolvedStartOptions = startOptions;
540
+ }
541
+ const portsToCheck = await this.getPortsToCheck(ports);
542
+ await this.syncPendingStoppedEvents();
543
+ resolvedCancellationOptions ??= {};
544
+ const containerGetTimeout = resolvedCancellationOptions.instanceGetTimeoutMS ?? TIMEOUT_TO_GET_CONTAINER_MS;
545
+ const pollInterval = resolvedCancellationOptions.waitInterval ?? INSTANCE_POLL_INTERVAL_MS;
546
+ const containerGetRetries = Math.ceil(containerGetTimeout / pollInterval);
547
+ const waitOptions = {
548
+ signal: resolvedCancellationOptions.abort,
549
+ retries: containerGetRetries,
550
+ waitInterval: pollInterval,
551
+ portToCheck: portsToCheck[0]
552
+ };
553
+ const triesUsed = await this.startContainerIfNotRunning(waitOptions, resolvedStartOptions);
554
+ const totalPortReadyTries = Math.ceil((resolvedCancellationOptions.portReadyTimeoutMS ?? TIMEOUT_TO_GET_PORTS_MS) / pollInterval);
555
+ let triesLeft = totalPortReadyTries - triesUsed;
556
+ for (const port of portsToCheck) {
557
+ triesLeft = await this.waitForPort({
558
+ signal: resolvedCancellationOptions.abort,
559
+ waitInterval: pollInterval,
560
+ retries: triesLeft,
561
+ portToCheck: port
562
+ });
563
+ }
564
+ this.setupMonitorCallbacks();
565
+ await this.ctx.blockConcurrencyWhile(async () => {
566
+ await this.state.setHealthy();
567
+ await this.onStart();
568
+ });
569
+ }
570
+ /**
571
+ *
572
+ * Waits for a specified port to be ready
573
+ *
574
+ * Returns the number of tries used to get the port, or throws if it couldn't get the port within the specified retry limits.
575
+ *
576
+ * @param waitOptions -
577
+ * - `portToCheck`: The port number to check
578
+ * - `abort`: Optional AbortSignal to cancel waiting
579
+ * - `retries`: Number of retries before giving up (default: TRIES_TO_GET_PORTS)
580
+ * - `waitInterval`: Interval between retries in milliseconds (default: INSTANCE_POLL_INTERVAL_MS)
581
+ */
582
+ async waitForPort(waitOptions) {
583
+ const port = waitOptions.portToCheck;
584
+ const tcpPort = this.container.getTcpPort(port);
585
+ const abortedSignal = new Promise((res) => {
586
+ waitOptions.signal?.addEventListener("abort", () => {
587
+ res(true);
588
+ });
589
+ });
590
+ const pollInterval = waitOptions.waitInterval ?? INSTANCE_POLL_INTERVAL_MS;
591
+ const tries = waitOptions.retries ?? Math.ceil(TIMEOUT_TO_GET_PORTS_MS / pollInterval);
592
+ for (let i = 0; i < tries; i++) {
593
+ try {
594
+ const combinedSignal = addTimeoutSignal(waitOptions.signal, PING_TIMEOUT_MS);
595
+ await tcpPort.fetch(`http://${this.pingEndpoint}`, { signal: combinedSignal });
596
+ break;
597
+ } catch (e) {
598
+ const errorMessage = e instanceof Error ? e.message : String(e);
599
+ if (!this.container.running) {
600
+ try {
601
+ await this.onError(new Error(`Container crashed while checking for ports, did you start the container and setup the entrypoint correctly?`));
602
+ } catch {
603
+ }
604
+ throw e;
605
+ }
606
+ if (i === tries - 1) {
607
+ try {
608
+ await this.onError(`Failed to verify port ${port} is available after ${(i + 1) * pollInterval}ms, last error: ${errorMessage}`);
609
+ } catch {
610
+ }
611
+ throw e;
612
+ }
613
+ await Promise.any([
614
+ new Promise((resolve) => setTimeout(resolve, pollInterval)),
615
+ abortedSignal
616
+ ]);
617
+ if (waitOptions.signal?.aborted) {
618
+ throw new Error("Container request aborted.", { cause: e });
619
+ }
620
+ }
621
+ }
622
+ return tries;
623
+ }
624
+ // =======================
625
+ // LIFECYCLE HOOKS
626
+ // =======================
627
+ /**
628
+ * Send a signal to the container.
629
+ * @param signal - The signal to send to the container (default: 15 for SIGTERM)
630
+ */
631
+ async stop(signal = "SIGTERM") {
632
+ if (this.container.running) {
633
+ this.container.signal(typeof signal === "string" ? signalToNumbers[signal] : signal);
634
+ }
635
+ await this.syncPendingStoppedEvents();
636
+ }
637
+ /**
638
+ * Destroys the container with a SIGKILL. Triggers onStop.
639
+ */
640
+ async destroy() {
641
+ await this.container.destroy();
642
+ }
643
+ /**
644
+ * Lifecycle method called when container starts successfully
645
+ * Override this method in subclasses to handle container start events
646
+ */
647
+ onStart() {
648
+ }
649
+ /**
650
+ * Lifecycle method called when container shuts down
651
+ * Override this method in subclasses to handle Container stopped events
652
+ * @param params - Object containing exitCode and reason for the stop
653
+ */
654
+ onStop(params) {
655
+ }
656
+ /**
657
+ * Lifecycle method called when the container is running, and the activity timeout
658
+ * expiration (set by `sleepAfter`) has been reached.
659
+ *
660
+ * If you want to shutdown the container, you should call this.stop() here
661
+ *
662
+ * By default, this method calls `this.stop()`
663
+ */
664
+ async onActivityExpired() {
665
+ console.log("Activity expired, signalling container to stop");
666
+ if (!this.container.running) {
667
+ return;
668
+ }
669
+ await this.stop();
670
+ }
671
+ /**
672
+ * Error handler for container errors
673
+ * Override this method in subclasses to handle container errors
674
+ * @param error - The error that occurred
675
+ * @returns Can return any value or throw the error
676
+ */
677
+ onError(error) {
678
+ console.error("Container error:", error);
679
+ throw error;
680
+ }
681
+ /**
682
+ * Renew the container's activity timeout
683
+ *
684
+ * Call this method whenever there is activity on the container
685
+ */
686
+ renewActivityTimeout() {
687
+ const timeoutInMs = parseTimeExpression(this.sleepAfter) * 1e3;
688
+ this.sleepAfterMs = Date.now() + timeoutInMs;
689
+ }
690
+ /**
691
+ * Decrement the inflight request counter.
692
+ * When the counter transitions to 0, renew the activity timeout so the
693
+ * inactivity window starts fresh from the moment the last request completes.
694
+ */
695
+ decrementInflight() {
696
+ this.inflightRequests = Math.max(0, this.inflightRequests - 1);
697
+ if (this.inflightRequests === 0) {
698
+ this.renewActivityTimeout();
699
+ }
700
+ }
701
+ // ==================
702
+ // SCHEDULING
703
+ // ==================
704
+ /**
705
+ * Schedule a task to be executed in the future.
706
+ *
707
+ * We strongly recommend using this instead of the `alarm` handler.
708
+ *
709
+ * @template T Type of the payload data
710
+ * @param when When to execute the task (Date object or number of seconds delay)
711
+ * @param callback Name of the method to call
712
+ * @param payload Data to pass to the callback
713
+ * @returns Schedule object representing the scheduled task
714
+ */
715
+ async schedule(when, callback, payload) {
716
+ const id = generateId(9);
717
+ if (typeof callback !== "string") {
718
+ throw new Error("Callback must be a string (method name)");
719
+ }
720
+ if (typeof this[callback] !== "function") {
721
+ throw new Error(`this.${callback} is not a function`);
722
+ }
723
+ if (when instanceof Date) {
724
+ const timestamp = Math.floor(when.getTime() / 1e3);
725
+ this.sql`
726
+ INSERT OR REPLACE INTO container_schedules (id, callback, payload, type, time)
727
+ VALUES (${id}, ${callback}, ${JSON.stringify(payload)}, 'scheduled', ${timestamp})
728
+ `;
729
+ await this.scheduleNextAlarm();
730
+ return {
731
+ taskId: id,
732
+ callback,
733
+ payload,
734
+ time: timestamp,
735
+ type: "scheduled"
736
+ };
737
+ }
738
+ if (typeof when === "number") {
739
+ const time = Math.floor(Date.now() / 1e3 + when);
740
+ this.sql`
741
+ INSERT OR REPLACE INTO container_schedules (id, callback, payload, type, delayInSeconds, time)
742
+ VALUES (${id}, ${callback}, ${JSON.stringify(payload)}, 'delayed', ${when}, ${time})
743
+ `;
744
+ await this.scheduleNextAlarm();
745
+ return {
746
+ taskId: id,
747
+ callback,
748
+ payload,
749
+ delayInSeconds: when,
750
+ time,
751
+ type: "delayed"
752
+ };
753
+ }
754
+ throw new Error("Invalid schedule type. 'when' must be a Date or number of seconds");
755
+ }
756
+ // ============
757
+ // HTTP
758
+ // ============
759
+ /**
760
+ * Send a request to the container (HTTP or WebSocket) using standard fetch API signature
761
+ *
762
+ * This method handles HTTP requests to the container.
763
+ *
764
+ * WebSocket requests done outside the DO won't work until https://github.com/cloudflare/workerd/issues/2319 is addressed.
765
+ * Until then, please use `switchPort` + `fetch()`.
766
+ *
767
+ * Method supports multiple signatures to match standard fetch API:
768
+ * - containerFetch(request: Request, port?: number)
769
+ * - containerFetch(url: string | URL, init?: RequestInit, port?: number)
770
+ *
771
+ * Starts the container if not already running, and waits for the target port to be ready.
772
+ *
773
+ * @returns A Response from the container
774
+ */
775
+ async containerFetch(requestOrUrl, portOrInit, portParam) {
776
+ const { request, port } = this.requestAndPortFromContainerFetchArgs(requestOrUrl, portOrInit, portParam);
777
+ const state = await this.state.getState();
778
+ if (!this.container.running || state.status !== "healthy") {
779
+ try {
780
+ await this.startAndWaitForPorts(port, { abort: request.signal });
781
+ } catch (e) {
782
+ if (isNoInstanceError(e)) {
783
+ return new Response("There is no Container instance available at this time.\nThis is likely because you have reached your max concurrent instance count (set in wrangler config) or are you currently provisioning the Container.\nIf you are deploying your Container for the first time, check your dashboard to see provisioning status, this may take a few minutes.", { status: 503 });
784
+ }
785
+ if (isRateLimitedError(e)) {
786
+ return new Response(e instanceof Error ? e.message : String(e), { status: 429 });
787
+ }
788
+ return new Response(`Failed to start container: ${e instanceof Error ? e.message : String(e)}`, {
789
+ status: 500
790
+ });
791
+ }
792
+ }
793
+ const tcpPort = this.container.getTcpPort(port);
794
+ const containerUrl = request.url.replace("https:", "http:");
795
+ this.inflightRequests++;
796
+ try {
797
+ this.renewActivityTimeout();
798
+ const res = await tcpPort.fetch(containerUrl, request);
799
+ if (res.webSocket !== null) {
800
+ const containerWs = res.webSocket;
801
+ const [client, server] = Object.values(new WebSocketPair());
802
+ let settled = false;
803
+ const settleInflight = () => {
804
+ if (!settled) {
805
+ settled = true;
806
+ this.decrementInflight();
807
+ }
808
+ };
809
+ containerWs.accept();
810
+ server.accept();
811
+ server.addEventListener("message", async (event) => {
812
+ this.renewActivityTimeout();
813
+ try {
814
+ const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
815
+ containerWs.send(data);
816
+ } catch {
817
+ server.close(1011, "Failed to forward message to container");
818
+ }
819
+ });
820
+ containerWs.addEventListener("message", async (event) => {
821
+ this.renewActivityTimeout();
822
+ try {
823
+ const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
824
+ server.send(data);
825
+ } catch {
826
+ containerWs.close(1011, "Failed to forward message to client");
827
+ }
828
+ });
829
+ server.addEventListener("close", (event) => {
830
+ settleInflight();
831
+ const code = event.code === 1005 || event.code === 1006 ? 1e3 : event.code;
832
+ containerWs.close(code, event.reason);
833
+ });
834
+ containerWs.addEventListener("close", (event) => {
835
+ settleInflight();
836
+ const code = event.code === 1005 || event.code === 1006 ? 1e3 : event.code;
837
+ server.close(code, event.reason);
838
+ });
839
+ server.addEventListener("error", () => {
840
+ settleInflight();
841
+ containerWs.close(1011, "Client WebSocket error");
842
+ });
843
+ containerWs.addEventListener("error", () => {
844
+ settleInflight();
845
+ server.close(1011, "Container WebSocket error");
846
+ });
847
+ return new Response(null, { status: res.status, webSocket: client, headers: res.headers });
848
+ }
849
+ if (res.body !== null) {
850
+ const { readable, writable } = new IdentityTransformStream();
851
+ res.body?.pipeTo(writable).finally(() => {
852
+ this.decrementInflight();
853
+ });
854
+ return new Response(readable, res);
855
+ }
856
+ this.decrementInflight();
857
+ return res;
858
+ } catch (e) {
859
+ this.decrementInflight();
860
+ if (!(e instanceof Error)) {
861
+ throw e;
862
+ }
863
+ if (e.message.includes("Network connection lost.")) {
864
+ return new Response("Container suddenly disconnected, try again", { status: 500 });
865
+ }
866
+ console.error(`Error proxying request to container ${this.ctx.id}:`, e);
867
+ return new Response(`Error proxying request to container: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
868
+ }
869
+ }
870
+ /**
871
+ *
872
+ * Fetch handler on the Container class.
873
+ * By default this forwards all requests to the container by calling `containerFetch`.
874
+ * Use `switchPort` to specify which port on the container to target, or this will use `defaultPort`.
875
+ * @param request The request to handle
876
+ */
877
+ async fetch(request) {
878
+ if (this.defaultPort === void 0 && !request.headers.has("cf-container-target-port")) {
879
+ throw new Error("No port configured for this container. Set the `defaultPort` in your Container subclass, or specify a port with `container.fetch(switchPort(request, port))`.");
880
+ }
881
+ let portValue = this.defaultPort;
882
+ if (request.headers.has("cf-container-target-port")) {
883
+ const portFromHeaders = parseInt(request.headers.get("cf-container-target-port") ?? "");
884
+ if (isNaN(portFromHeaders)) {
885
+ throw new Error("port value from switchPort is not a number");
886
+ } else {
887
+ portValue = portFromHeaders;
888
+ }
889
+ }
890
+ return await this.containerFetch(request, portValue);
891
+ }
892
+ // ===============================
893
+ // ===============================
894
+ // PRIVATE METHODS & ATTRS
895
+ // ===============================
896
+ // ===============================
897
+ // ==========================
898
+ // PRIVATE ATTRIBUTES
899
+ // ==========================
900
+ container;
901
+ // onStopCalled will be true when we are in the middle of an onStop call
902
+ onStopCalled = false;
903
+ state;
904
+ monitor;
905
+ // Coalesces concurrent calls to startContainerIfNotRunning so we never
906
+ // call `this.container.start()` twice. Without this guard, two requests
907
+ // racing the readiness path can both pass the `if (this.container.running)`
908
+ // early-return (each yielding the DO input gate at storage awaits) and
909
+ // both reach the synchronous workerd `start()`, causing the second to
910
+ // throw "start() cannot be called on a container that is already running."
911
+ // See https://github.com/cloudflare/containers/issues/173.
912
+ startInFlight;
913
+ monitoredPromise;
914
+ sleepAfterMs = 0;
915
+ inflightRequests = 0;
916
+ // Outbound interception runtime overrides (passed through ContainerProxy props)
917
+ outboundByHostOverrides = {};
918
+ outboundHandlerOverride;
919
+ // Only set when the user calls setAllowedHosts/setDeniedHosts at runtime
920
+ allowedHostsOverride;
921
+ deniedHostsOverride;
922
+ // The runtime does not expose a way to remove outbound interceptions yet, so
923
+ // once we promote an instance to intercept-all we must keep using it.
924
+ hasInterceptAllRegistration = false;
925
+ // ==========================
926
+ // GENERAL HELPERS
927
+ // ==========================
928
+ /**
929
+ * Validates that a method name exists in the outboundHandlers registry for this class.
930
+ * @throws Error if the method name is not found
931
+ */
932
+ validateOutboundHandlerMethodName(methodName) {
933
+ const handlers = outboundHandlersRegistry.get(this.constructor.name);
934
+ if (!handlers || !(methodName in handlers)) {
935
+ throw new Error(`Outbound handler method '${methodName}' not found in outboundHandlers for ${this.constructor.name}`);
936
+ }
937
+ }
938
+ get effectiveAllowedHosts() {
939
+ return this.allowedHostsOverride ?? this.allowedHosts;
940
+ }
941
+ get effectiveDeniedHosts() {
942
+ return this.deniedHostsOverride ?? this.deniedHosts;
943
+ }
944
+ getOutboundConfiguration() {
945
+ return {
946
+ outboundByHostOverrides: Object.keys(this.outboundByHostOverrides).length > 0 ? this.outboundByHostOverrides : void 0,
947
+ outboundHandlerOverride: this.outboundHandlerOverride,
948
+ allowedHosts: this.effectiveAllowedHosts,
949
+ deniedHosts: this.effectiveDeniedHosts,
950
+ hasInterceptAllRegistration: this.hasInterceptAllRegistration || void 0
951
+ };
952
+ }
953
+ persistOutboundConfiguration(configuration) {
954
+ this.ctx.storage.kv.put(OUTBOUND_CONFIGURATION_KEY, {
955
+ ...configuration,
956
+ allowedHosts: this.allowedHostsOverride,
957
+ deniedHosts: this.deniedHostsOverride
958
+ });
959
+ }
960
+ restoreOutboundConfiguration() {
961
+ const configuration = this.ctx.storage.kv.get(OUTBOUND_CONFIGURATION_KEY);
962
+ if (!configuration) {
963
+ return void 0;
964
+ }
965
+ this.outboundHandlerOverride = void 0;
966
+ if (configuration.outboundHandlerOverride !== void 0) {
967
+ try {
968
+ this.validateOutboundHandlerMethodName(configuration.outboundHandlerOverride.method);
969
+ this.outboundHandlerOverride = configuration.outboundHandlerOverride;
970
+ } catch (error) {
971
+ console.warn("Ignoring invalid persisted outbound handler override:", error);
972
+ }
973
+ }
974
+ this.outboundByHostOverrides = {};
975
+ for (const [hostname, override] of Object.entries(configuration.outboundByHostOverrides ?? {})) {
976
+ try {
977
+ this.validateOutboundHandlerMethodName(override.method);
978
+ this.outboundByHostOverrides[hostname] = override;
979
+ } catch (error) {
980
+ console.warn(`Ignoring invalid persisted outbound override for ${hostname}:`, error);
981
+ }
982
+ }
983
+ this.hasInterceptAllRegistration = configuration.hasInterceptAllRegistration === true;
984
+ if (configuration.allowedHosts) {
985
+ this.allowedHostsOverride = configuration.allowedHosts;
986
+ }
987
+ if (configuration.deniedHosts) {
988
+ this.deniedHostsOverride = configuration.deniedHosts;
989
+ }
990
+ return this.getOutboundConfiguration();
991
+ }
992
+ /**
993
+ * Returns true if a catch-all outbound HTTP interception is needed.
994
+ * This is the case when a static `outbound` handler or a runtime
995
+ * `outboundHandlerOverride` (catch-all) is configured.
996
+ * When false, we only intercept specific hosts to avoid overhead.
997
+ */
998
+ needsCatchAllInterception() {
999
+ const ctor = this.constructor;
1000
+ return ctor.outbound !== void 0 || this.outboundHandlerOverride !== void 0;
1001
+ }
1002
+ hasMutableOutboundConfiguration() {
1003
+ return Object.keys(this.outboundByHostOverrides).length > 0 || this.allowedHostsOverride !== void 0 || this.deniedHostsOverride !== void 0;
1004
+ }
1005
+ shouldInterceptAllOutbound() {
1006
+ return this.hasInterceptAllRegistration || this.needsCatchAllInterception() || this.effectiveAllowedHosts !== void 0 || this.effectiveDeniedHosts !== void 0 || this.hasMutableOutboundConfiguration();
1007
+ }
1008
+ getStaticOutboundByHostKeys() {
1009
+ const ctor = this.constructor;
1010
+ return ctor.outboundByHost ? Object.keys(ctor.outboundByHost) : [];
1011
+ }
1012
+ /**
1013
+ * Collects all hostnames that need per-host outbound interception.
1014
+ * This path is only used for the narrow optimized case where outbound
1015
+ * handling is static and host-specific.
1016
+ */
1017
+ getHostsToIntercept() {
1018
+ const hosts = /* @__PURE__ */ new Set();
1019
+ const ctor = this.constructor;
1020
+ if (ctor.outboundByHost) {
1021
+ for (const hostname of Object.keys(ctor.outboundByHost)) {
1022
+ hosts.add(hostname);
1023
+ }
1024
+ }
1025
+ for (const hostname of Object.keys(this.outboundByHostOverrides)) {
1026
+ hosts.add(hostname);
1027
+ }
1028
+ return [...hosts];
1029
+ }
1030
+ async refreshOutboundInterception() {
1031
+ if (!this.usingInterception) {
1032
+ return;
1033
+ }
1034
+ this.applyOutboundInterceptionPromise = this.applyOutboundInterception();
1035
+ await this.applyOutboundInterceptionPromise;
1036
+ }
1037
+ /**
1038
+ * Applies (or re-applies) outbound HTTP interception with the current
1039
+ * default registries + runtime overrides passed through ContainerProxy props.
1040
+ *
1041
+ * Uses per-host interception only for static host-specific outbound handlers.
1042
+ * As soon as the config needs to evaluate all hosts (catch-all outbound,
1043
+ * allow/deny lists, or runtime-mutated outbound config), we promote the
1044
+ * container to intercept-all and keep it there until the instance restarts.
1045
+ *
1046
+ * When `interceptHttps` is enabled, also applies HTTPS interception:
1047
+ * - Intercept-all mode: `interceptOutboundHttps('*', ...)` for all HTTPS traffic
1048
+ * - Per-host mode: `interceptOutboundHttps(host, ...)` for each known host
1049
+ */
1050
+ async applyOutboundInterception() {
1051
+ const ctx = this.ctx;
1052
+ if (ctx.exports === void 0) {
1053
+ throw new Error("ctx.exports is undefined, please try to update your compatibility date or export ContainerProxy from the containers package in your worker entrypoint");
1054
+ }
1055
+ if (ctx.exports.ContainerProxy === void 0) {
1056
+ throw new Error("ctx.exports.ContainerProxy is undefined, export ContainerProxy from the containers package in your worker entrypoint");
1057
+ }
1058
+ const interceptAll = this.shouldInterceptAllOutbound();
1059
+ if (interceptAll) {
1060
+ this.hasInterceptAllRegistration = interceptAll;
1061
+ }
1062
+ const outboundConfiguration = this.getOutboundConfiguration();
1063
+ this.persistOutboundConfiguration(outboundConfiguration);
1064
+ const hosts = this.getHostsToIntercept();
1065
+ const props = {
1066
+ enableInternet: this.enableInternet,
1067
+ containerId: this.ctx.id.toString(),
1068
+ className: this.constructor.name,
1069
+ outboundByHostOverrides: outboundConfiguration.outboundByHostOverrides,
1070
+ outboundHandlerOverride: outboundConfiguration.outboundHandlerOverride,
1071
+ allowedHosts: outboundConfiguration.allowedHosts,
1072
+ deniedHosts: outboundConfiguration.deniedHosts,
1073
+ interceptAll
1074
+ };
1075
+ const fetcher = ctx.exports.ContainerProxy({
1076
+ props
1077
+ });
1078
+ if (interceptAll) {
1079
+ for (const host of this.getStaticOutboundByHostKeys()) {
1080
+ await this.container.interceptOutboundHttp(host, fetcher);
1081
+ if (this.interceptHttps) {
1082
+ await this.container.interceptOutboundHttps(host, fetcher);
1083
+ }
1084
+ }
1085
+ if (this.interceptHttps) {
1086
+ await this.container.interceptOutboundHttps("*", fetcher);
1087
+ }
1088
+ await this.container.interceptAllOutboundHttp(fetcher);
1089
+ } else {
1090
+ for (const host of hosts) {
1091
+ await this.container.interceptOutboundHttp(host, fetcher);
1092
+ if (this.interceptHttps) {
1093
+ await this.container.interceptOutboundHttps(host, fetcher);
1094
+ }
1095
+ }
1096
+ }
1097
+ }
1098
+ /**
1099
+ * Execute SQL queries against the Container's database
1100
+ */
1101
+ sql(strings, ...values) {
1102
+ const query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? "?" : ""), "");
1103
+ return [...this.ctx.storage.sql.exec(query, ...values)];
1104
+ }
1105
+ requestAndPortFromContainerFetchArgs(requestOrUrl, portOrInit, portParam) {
1106
+ let request;
1107
+ let port;
1108
+ if (requestOrUrl instanceof Request) {
1109
+ request = requestOrUrl;
1110
+ port = typeof portOrInit === "number" ? portOrInit : void 0;
1111
+ } else {
1112
+ const url = typeof requestOrUrl === "string" ? requestOrUrl : requestOrUrl.toString();
1113
+ const init = typeof portOrInit === "number" ? {} : portOrInit || {};
1114
+ port = typeof portOrInit === "number" ? portOrInit : typeof portParam === "number" ? portParam : void 0;
1115
+ request = new Request(url, init);
1116
+ }
1117
+ port ??= this.defaultPort;
1118
+ if (port === void 0) {
1119
+ throw new Error("No port specified for container fetch. Set defaultPort or specify a port parameter.");
1120
+ }
1121
+ return { request, port };
1122
+ }
1123
+ /**
1124
+ *
1125
+ * The method prioritizes port sources in this order:
1126
+ * 1. Ports specified directly in the method call
1127
+ * 2. `requiredPorts` class property (if set)
1128
+ * 3. `defaultPort` (if neither of the above is specified)
1129
+ * 4. Falls back to port 33 if none of the above are set
1130
+ */
1131
+ async getPortsToCheck(overridePorts) {
1132
+ if (overridePorts !== void 0) {
1133
+ return Array.isArray(overridePorts) ? overridePorts : [overridePorts];
1134
+ }
1135
+ if (this.requiredPorts && this.requiredPorts.length > 0) {
1136
+ return [...this.requiredPorts];
1137
+ }
1138
+ return [this.defaultPort ?? FALLBACK_PORT_TO_CHECK];
1139
+ }
1140
+ // ===========================================
1141
+ // CONTAINER INTERACTION & MONITORING
1142
+ // ===========================================
1143
+ /**
1144
+ * Tries to start a container if it's not already running
1145
+ * Returns the number of tries used
1146
+ */
1147
+ async startContainerIfNotRunning(waitOptions, options) {
1148
+ if (this.startInFlight) {
1149
+ return this.startInFlight;
1150
+ }
1151
+ if (this.container.running) {
1152
+ if (!this.monitor) {
1153
+ this.monitor = this.container.monitor();
1154
+ }
1155
+ return 0;
1156
+ }
1157
+ const startPromise = this.doStartContainer(waitOptions, options);
1158
+ this.startInFlight = startPromise;
1159
+ try {
1160
+ return await startPromise;
1161
+ } finally {
1162
+ if (this.startInFlight === startPromise) {
1163
+ this.startInFlight = void 0;
1164
+ }
1165
+ }
1166
+ }
1167
+ async doStartContainer(waitOptions, options) {
1168
+ const abortedSignal = new Promise((res) => {
1169
+ waitOptions.signal?.addEventListener("abort", () => {
1170
+ res(true);
1171
+ });
1172
+ });
1173
+ const pollInterval = waitOptions.waitInterval ?? INSTANCE_POLL_INTERVAL_MS;
1174
+ const totalTries = waitOptions.retries ?? Math.ceil(TIMEOUT_TO_GET_CONTAINER_MS / pollInterval);
1175
+ for (let tries = 0; tries < totalTries; tries++) {
1176
+ const envVars = options?.envVars ?? this.envVars;
1177
+ const entrypoint = options?.entrypoint ?? this.entrypoint;
1178
+ const enableInternet = options?.enableInternet ?? this.enableInternet;
1179
+ const labels = options?.labels ?? this.labels;
1180
+ const startConfig = {
1181
+ enableInternet
1182
+ };
1183
+ if (envVars && Object.keys(envVars).length > 0)
1184
+ startConfig.env = envVars;
1185
+ if (entrypoint)
1186
+ startConfig.entrypoint = entrypoint;
1187
+ if (labels && Object.keys(labels).length > 0)
1188
+ startConfig.labels = labels;
1189
+ this.renewActivityTimeout();
1190
+ const handleError = async () => {
1191
+ const err = await this.monitor?.catch((err2) => err2);
1192
+ if (typeof err === "number") {
1193
+ const toThrow = new Error(`Container exited before we could determine the container health, exit code: ${err}`);
1194
+ await this.state.setStoppedWithCode(err);
1195
+ this.monitor = void 0;
1196
+ try {
1197
+ await this.onError(toThrow);
1198
+ } catch {
1199
+ }
1200
+ throw toThrow;
1201
+ } else if (!isNoInstanceError(err)) {
1202
+ await this.state.setStopped();
1203
+ this.monitor = void 0;
1204
+ try {
1205
+ await this.onError(err);
1206
+ } catch {
1207
+ }
1208
+ throw err;
1209
+ }
1210
+ };
1211
+ if (tries > 0 && !this.container.running) {
1212
+ await handleError();
1213
+ }
1214
+ await this.scheduleNextAlarm();
1215
+ if (!this.container.running) {
1216
+ await this.refreshOutboundInterception();
1217
+ this.container.start(startConfig);
1218
+ this.monitor = this.container.monitor();
1219
+ await this.state.setRunning();
1220
+ } else {
1221
+ await this.scheduleNextAlarm();
1222
+ }
1223
+ this.renewActivityTimeout();
1224
+ const port = this.container.getTcpPort(waitOptions.portToCheck);
1225
+ try {
1226
+ const combinedSignal = addTimeoutSignal(waitOptions.signal, PING_TIMEOUT_MS);
1227
+ await port.fetch("http://containerstarthealthcheck", { signal: combinedSignal });
1228
+ return tries;
1229
+ } catch (error) {
1230
+ if (isNotListeningError(error) && this.container.running) {
1231
+ return tries;
1232
+ }
1233
+ if (!this.container.running && isNotListeningError(error)) {
1234
+ await handleError();
1235
+ }
1236
+ await Promise.any([
1237
+ new Promise((res) => setTimeout(res, waitOptions.waitInterval)),
1238
+ abortedSignal
1239
+ ]);
1240
+ if (waitOptions.signal?.aborted) {
1241
+ throw new Error("Aborted waiting for container to start as we received a cancellation signal", { cause: error });
1242
+ }
1243
+ if (totalTries === tries + 1) {
1244
+ if (error instanceof Error && error.message.includes("Network connection lost")) {
1245
+ this.ctx.abort();
1246
+ }
1247
+ await handleError();
1248
+ await this.state.setStopped();
1249
+ this.monitor = void 0;
1250
+ throw new Error(NO_CONTAINER_INSTANCE_ERROR, { cause: error });
1251
+ }
1252
+ continue;
1253
+ }
1254
+ }
1255
+ throw new Error(`Container did not start after ${totalTries * pollInterval}ms`);
1256
+ }
1257
+ setupMonitorCallbacks() {
1258
+ const monitor = this.monitor;
1259
+ if (!monitor || this.monitoredPromise === monitor) {
1260
+ return;
1261
+ }
1262
+ this.monitoredPromise = monitor;
1263
+ monitor.then(async () => {
1264
+ await this.ctx.blockConcurrencyWhile(async () => {
1265
+ if (this.monitor === monitor) {
1266
+ await this.state.setStoppedWithCode(0);
1267
+ }
1268
+ });
1269
+ }).catch(async (error) => {
1270
+ if (this.monitor !== monitor) {
1271
+ return;
1272
+ }
1273
+ if (isNoInstanceError(error)) {
1274
+ await this.ctx.blockConcurrencyWhile(async () => {
1275
+ if (this.monitor === monitor) {
1276
+ await this.state.setStopped();
1277
+ }
1278
+ });
1279
+ return;
1280
+ }
1281
+ const exitCode = getExitCodeFromError(error);
1282
+ if (exitCode !== null) {
1283
+ await this.ctx.blockConcurrencyWhile(async () => {
1284
+ if (this.monitor === monitor) {
1285
+ await this.state.setStoppedWithCode(exitCode);
1286
+ }
1287
+ });
1288
+ return;
1289
+ }
1290
+ await this.ctx.blockConcurrencyWhile(async () => {
1291
+ if (this.monitor === monitor) {
1292
+ await this.state.setStopped();
1293
+ }
1294
+ });
1295
+ if (this.monitor !== monitor) {
1296
+ return;
1297
+ }
1298
+ try {
1299
+ await this.onError(error);
1300
+ } catch {
1301
+ }
1302
+ }).finally(() => {
1303
+ if (this.monitor !== monitor) {
1304
+ return;
1305
+ }
1306
+ this.monitoredPromise = void 0;
1307
+ this.monitor = void 0;
1308
+ });
1309
+ }
1310
+ deleteSchedules(name) {
1311
+ this.sql`DELETE FROM container_schedules WHERE callback = ${name}`;
1312
+ }
1313
+ // ============================
1314
+ // ALARMS AND SCHEDULES
1315
+ // ============================
1316
+ /**
1317
+ * Method called when an alarm fires
1318
+ * Executes any scheduled tasks that are due
1319
+ */
1320
+ async alarm(alarmProps) {
1321
+ if (alarmProps !== void 0 && alarmProps.isRetry && alarmProps.retryCount > MAX_ALARM_RETRIES) {
1322
+ const scheduleCount = Number(this.sql`SELECT COUNT(*) as count FROM container_schedules`[0]?.count) || 0;
1323
+ const hasScheduledTasks = scheduleCount > 0;
1324
+ if (hasScheduledTasks || this.container.running) {
1325
+ await this.scheduleNextAlarm();
1326
+ }
1327
+ return;
1328
+ }
1329
+ const result = this.sql`
1330
+ SELECT * FROM container_schedules;
1331
+ `;
1332
+ let minTime = Date.now() + MAX_ALARM_REARM_MS;
1333
+ const now = Date.now() / 1e3;
1334
+ for (const row of result) {
1335
+ if (row.time > now) {
1336
+ continue;
1337
+ }
1338
+ const callback = this[row.callback];
1339
+ if (!callback || typeof callback !== "function") {
1340
+ console.error(`Callback ${row.callback} not found or is not a function`);
1341
+ continue;
1342
+ }
1343
+ const schedule = this.getSchedule(row.id);
1344
+ try {
1345
+ const payload = row.payload ? JSON.parse(row.payload) : void 0;
1346
+ await callback.call(this, payload, await schedule);
1347
+ } catch (e) {
1348
+ console.error(`Error executing scheduled callback "${row.callback}":`, e);
1349
+ }
1350
+ this.sql`DELETE FROM container_schedules WHERE id = ${row.id}`;
1351
+ }
1352
+ const resultForMinTime = this.sql`
1353
+ SELECT * FROM container_schedules;
1354
+ `;
1355
+ const minTimeFromSchedules = Math.min(...resultForMinTime.map((r) => r.time * 1e3));
1356
+ if (!this.container.running) {
1357
+ await this.syncPendingStoppedEvents();
1358
+ if (resultForMinTime.length == 0) {
1359
+ await this.ctx.storage.deleteAlarm();
1360
+ } else {
1361
+ await this.ctx.storage.setAlarm(minTimeFromSchedules);
1362
+ }
1363
+ return;
1364
+ }
1365
+ if (this.isActivityExpired()) {
1366
+ await this.onActivityExpired();
1367
+ this.renewActivityTimeout();
1368
+ await this.ctx.storage.setAlarm(Date.now() + MIN_ALARM_REARM_MS);
1369
+ return;
1370
+ }
1371
+ minTime = Math.min(minTimeFromSchedules, minTime, this.sleepAfterMs);
1372
+ const nextAlarm = Math.max(minTime, Date.now() + MIN_ALARM_REARM_MS);
1373
+ await this.ctx.storage.setAlarm(nextAlarm);
1374
+ }
1375
+ // synchronises container state with the container source of truth to process events
1376
+ async syncPendingStoppedEvents() {
1377
+ const state = await this.state.getState();
1378
+ if (!this.container.running && (state.status === "healthy" || state.status === "running")) {
1379
+ await this.callOnStop({ exitCode: 0, reason: "exit" }, state);
1380
+ return;
1381
+ }
1382
+ if (!this.container.running && state.status === "stopped_with_code") {
1383
+ await this.callOnStop({ exitCode: state.exitCode ?? 0, reason: "exit" }, state);
1384
+ return;
1385
+ }
1386
+ }
1387
+ async callOnStop(onStopParams, stateBeforeOnStop) {
1388
+ if (this.onStopCalled) {
1389
+ return;
1390
+ }
1391
+ this.onStopCalled = true;
1392
+ const promise = this.onStop(onStopParams);
1393
+ if (promise instanceof Promise) {
1394
+ await promise.finally(() => {
1395
+ this.onStopCalled = false;
1396
+ });
1397
+ } else {
1398
+ this.onStopCalled = false;
1399
+ }
1400
+ await this.state.setStoppedIfUnchanged(stateBeforeOnStop);
1401
+ }
1402
+ /**
1403
+ * Schedule the next alarm based on upcoming tasks
1404
+ */
1405
+ async scheduleNextAlarm(ms = 1e3) {
1406
+ const nextTime = Date.now() + Math.max(ms, MIN_ALARM_REARM_MS);
1407
+ const existing = await this.ctx.storage.getAlarm();
1408
+ if (existing !== null && existing <= nextTime) {
1409
+ return;
1410
+ }
1411
+ await this.ctx.storage.setAlarm(nextTime);
1412
+ await this.ctx.storage.sync();
1413
+ }
1414
+ async listSchedules(name) {
1415
+ const result = this.sql`
1416
+ SELECT * FROM container_schedules WHERE callback = ${name} LIMIT 1
1417
+ `;
1418
+ if (!result || result.length === 0) {
1419
+ return [];
1420
+ }
1421
+ return result.map(this.toSchedule);
1422
+ }
1423
+ toSchedule(schedule) {
1424
+ let payload;
1425
+ try {
1426
+ payload = JSON.parse(schedule.payload);
1427
+ } catch (e) {
1428
+ console.error(`Error parsing payload for schedule ${schedule.id}:`, e);
1429
+ payload = void 0;
1430
+ }
1431
+ if (schedule.type === "delayed") {
1432
+ return {
1433
+ taskId: schedule.id,
1434
+ callback: schedule.callback,
1435
+ payload,
1436
+ type: "delayed",
1437
+ time: schedule.time,
1438
+ delayInSeconds: schedule.delayInSeconds
1439
+ };
1440
+ }
1441
+ return {
1442
+ taskId: schedule.id,
1443
+ callback: schedule.callback,
1444
+ payload,
1445
+ type: "scheduled",
1446
+ time: schedule.time
1447
+ };
1448
+ }
1449
+ /**
1450
+ * Get a scheduled task by ID
1451
+ * @template T Type of the payload data
1452
+ * @param id ID of the scheduled task
1453
+ * @returns The Schedule object or undefined if not found
1454
+ */
1455
+ async getSchedule(id) {
1456
+ const result = this.sql`
1457
+ SELECT * FROM container_schedules WHERE id = ${id} LIMIT 1
1458
+ `;
1459
+ if (!result || result.length === 0) {
1460
+ return void 0;
1461
+ }
1462
+ const schedule = result[0];
1463
+ return this.toSchedule(schedule);
1464
+ }
1465
+ isActivityExpired() {
1466
+ if (this.inflightRequests > 0) {
1467
+ this.renewActivityTimeout();
1468
+ return false;
1469
+ }
1470
+ return this.sleepAfterMs <= Date.now();
1471
+ }
1472
+ }
1473
+
1474
+ export { Container, ContainerProxy, outboundParams };