@lunora/container 1.0.0-alpha.2 → 1.0.0-alpha.21

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/dist/otel.d.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * An attribute value carried on a span or log.
3
+ * @experimental
4
+ */
5
+ type ContainerAttributeValue = boolean | number | string;
6
+ /**
7
+ * A `fetch` implementation — defaults to the runtime global. The exporter passes
8
+ * an abort `signal` (for the per-request timeout) and, once the promise settles,
9
+ * cancels the response `body` so Node/undici can release the socket for
10
+ * keep-alive reuse instead of leaving it occupied by an unread stream. It reads
11
+ * `ok`/`status` to detect a rejected export and nothing else from the response.
12
+ * @experimental
13
+ */
14
+ type OtelFetchLike = (input: string, init: {
15
+ body: string;
16
+ headers: Record<string, string>;
17
+ method: string;
18
+ signal?: AbortSignal;
19
+ }) => Promise<{
20
+ body?: {
21
+ cancel: () => Promise<void>;
22
+ } | null;
23
+ ok: boolean;
24
+ status: number;
25
+ }>;
26
+ /**
27
+ * A single span the container process asks the exporter to record.
28
+ * @experimental
29
+ */
30
+ interface ContainerSpanInput {
31
+ /** Attributes attached to the span (rendered under the OTLP `attributes` list). */
32
+ attributes?: Record<string, ContainerAttributeValue>;
33
+ /** Wall-clock millis when the operation ended. */
34
+ endMs: number;
35
+ /** When set, the span is marked errored with this message (and optional `error.type`). */
36
+ error?: {
37
+ message: string;
38
+ type?: string;
39
+ };
40
+ /** Span name — the operation being timed, e.g. `"transcode"`. */
41
+ name: string;
42
+ /** Wall-clock millis when the operation started. */
43
+ startMs: number;
44
+ }
45
+ /**
46
+ * A single log line the container process asks the exporter to record.
47
+ * @experimental
48
+ */
49
+ interface ContainerLogInput {
50
+ /** Attributes attached to the log record. */
51
+ attributes?: Record<string, ContainerAttributeValue>;
52
+ /** Severity — defaults to `"info"`. */
53
+ level?: "debug" | "error" | "info" | "warn";
54
+ /** The log message body. */
55
+ message: string;
56
+ /** Wall-clock millis the line was emitted; defaults to now. */
57
+ ts?: number;
58
+ }
59
+ /**
60
+ * Options for {@link createContainerTelemetry}.
61
+ * @experimental
62
+ */
63
+ interface ContainerTelemetryOptions {
64
+ /**
65
+ * Value of the `deployment.environment` resource attribute. Falls back to
66
+ * the `DEPLOYMENT_ENVIRONMENT` / `ENVIRONMENT` / `NODE_ENV` env vars **only
67
+ * when {@link ContainerTelemetryOptions.detectResources} is `true`** —
68
+ * unlike `serviceName`, env detection here is opt-in so a stray `NODE_ENV`
69
+ * never silently labels a deployment.
70
+ */
71
+ deploymentEnvironment?: string;
72
+ /**
73
+ * When `true`, auto-detect OTLP resource attributes from the container
74
+ * environment (`HOSTNAME`, `KUBERNETES_*`, `SERVICE_VERSION`, etc.).
75
+ * Explicit options and `resourceAttributes` win on collision.
76
+ */
77
+ detectResources?: boolean;
78
+ /** Base OTLP collector endpoint; defaults to the `LUNORA_OTLP_ENDPOINT` env var. */
79
+ endpoint?: string;
80
+ /** Injectable `fetch` (tests / non-global runtimes). Defaults to `globalThis.fetch`. */
81
+ fetch?: OtelFetchLike;
82
+ /** Extra headers merged onto every POST — e.g. deployment/org correlation. `content-type` is set by default. */
83
+ headers?: Record<string, string>;
84
+ /** Called with any send failure so the caller can surface it; the export itself always swallows. */
85
+ onError?: (error: unknown) => void;
86
+ /** Additional resource attributes merged onto every signal. */
87
+ resourceAttributes?: Record<string, ContainerAttributeValue>;
88
+ /** `service.name` resource attribute; defaults to the `LUNORA_SERVICE_NAME` env var then `"lunora-container"`. */
89
+ serviceName?: string;
90
+ /**
91
+ * `service.version` resource attribute. Falls back to `SERVICE_VERSION` /
92
+ * `CF_VERSION_METADATA` / `VERCEL_GIT_COMMIT_SHA` / `GITHUB_SHA` /
93
+ * `COMMIT_SHA` env vars **only when
94
+ * {@link ContainerTelemetryOptions.detectResources} is `true`**.
95
+ */
96
+ serviceVersion?: string;
97
+ /** Per-POST timeout in ms; a collector that never responds aborts after this so a stuck send can't stall `flush()`. Defaults to {@link DEFAULT_TIMEOUT_MS} (10s). */
98
+ timeoutMs?: number;
99
+ /** Bearer token sent as an `Authorization: Bearer` header; defaults to the `LUNORA_OTLP_TOKEN` env var. */
100
+ token?: string;
101
+ /**
102
+ * W3C `traceparent` of the Worker RPC that invoked this container; defaults to
103
+ * the `LUNORA_TRACEPARENT` env var. When present (and well-formed) every span
104
+ * inherits its trace id and hangs off its span id, so container spans stitch
105
+ * under the Worker's trace instead of forming a fresh, disconnected trace.
106
+ *
107
+ * `@lunora/container` stamps this trace context as the **`traceparent` request
108
+ * header** on every proxied fetch (`ctx.containers.&lt;name>.…`), so a container
109
+ * that serves many requests should read it per request and create a telemetry
110
+ * instance scoped to that request — the trace context differs each call, so a
111
+ * single process-lifetime instance can't carry it:
112
+ *
113
+ * ```ts
114
+ * // inside the container's request handler
115
+ * const telemetry = createContainerTelemetry({ traceparent: request.headers.get("traceparent") ?? undefined });
116
+ * await telemetry.trace("transcode", () => transcode(job));
117
+ * await telemetry.flush();
118
+ * ```
119
+ *
120
+ * The `LUNORA_TRACEPARENT` env fallback fits a one-shot container that
121
+ * processes a single job per start (the value is fixed for the process).
122
+ */
123
+ traceparent?: string;
124
+ }
125
+ /**
126
+ * The exporter handle {@link createContainerTelemetry} returns.
127
+ * @experimental
128
+ */
129
+ interface ContainerTelemetry {
130
+ /** Record one log line (no-op when disabled). */
131
+ emitLog: (log: ContainerLogInput) => void;
132
+ /** Record one span (no-op when disabled). */
133
+ emitSpan: (span: ContainerSpanInput) => void;
134
+ /** True when an endpoint resolved and exports are actually sent. */
135
+ readonly enabled: boolean;
136
+ /** Await all in-flight sends — call before the process exits. */
137
+ flush: () => Promise<void>;
138
+ /** Time `run()`, recording a span named `name` (ok, or errored if it throws). Always runs `run()`, even when disabled. */
139
+ trace: <T>(name: string, run: () => Promise<T>, attributes?: Record<string, ContainerAttributeValue>) => Promise<T>;
140
+ }
141
+ /**
142
+ * Create a zero-config OTLP exporter for the container process.
143
+ *
144
+ * ```ts
145
+ * const telemetry = createContainerTelemetry(); // reads LUNORA_OTLP_ENDPOINT / _TOKEN
146
+ * await telemetry.trace("transcode", () => transcode(job), { jobId: job.id });
147
+ * telemetry.emitLog({ level: "info", message: "done", attributes: { jobId: job.id } });
148
+ * await telemetry.flush(); // before the process exits
149
+ * ```
150
+ *
151
+ * With no endpoint resolvable the returned exporter is disabled (`enabled ===
152
+ * false`): `emitSpan`/`emitLog` no-op and `trace` still runs its work but records
153
+ * nothing — so the same code runs unchanged locally and in the cloud.
154
+ * @param options Exporter options. Connection fields (`endpoint`, `token`,
155
+ * `serviceName`, `traceparent`) always fall back to their `LUNORA_*` env var;
156
+ * resource fields (`serviceVersion`, `deploymentEnvironment`) only do so under
157
+ * `detectResources: true`.
158
+ * @experimental
159
+ */
160
+ declare const createContainerTelemetry: (options?: ContainerTelemetryOptions) => ContainerTelemetry;
161
+ export { type ContainerAttributeValue, type ContainerLogInput, type ContainerSpanInput, type ContainerTelemetry, type ContainerTelemetryOptions, type OtelFetchLike, createContainerTelemetry };
package/dist/otel.mjs ADDED
@@ -0,0 +1 @@
1
+ const L={debug:5,error:17,fatal:21,info:9,log:9,trace:1,warn:13},b=e=>`${String(Math.round(e))}000000`,A=e=>{const t=new Uint8Array(e);crypto.getRandomValues(t);let r="";for(const o of t)r+=o.toString(16).padStart(2,"0");return r},y=/^[0-9a-f]+$/,C=e=>{if(e==null)return;const t=e.trim().toLowerCase().split("-"),[r,o,n,s]=t;if(!(t.length<4||r===void 0||r.length!==2||!y.test(r)||r==="ff"||r==="00"&&t.length!==4||o===void 0||n===void 0||s===void 0||s.length!==2||!y.test(s)||o.length!==32||n.length!==16||!y.test(o)||!y.test(n)||o==="00000000000000000000000000000000"||n==="0000000000000000"))return{parentSpanId:n,sampled:(Number.parseInt(s,16)&1)===1,traceId:o}},v=(e,t)=>typeof t=="boolean"?{key:e,value:{boolValue:t}}:typeof t=="number"?Number.isFinite(t)?Number.isSafeInteger(t)?{key:e,value:{intValue:String(t)}}:{key:e,value:{doubleValue:t}}:{key:e,value:{stringValue:String(t)}}:{key:e,value:{stringValue:t}},M=e=>e===void 0?[]:Object.entries(e).map(([t,r])=>v(t,r)),U=(e,t,r)=>{const o={},n=new Map,s=(a,i)=>{const E=a.toLowerCase(),f=n.get(E);f===void 0?(n.set(E,a),o[a]=i):o[f]=i};for(const[a,i]of Object.entries(e))s(a,i);for(const[a,i]of Object.entries(t??{}))s(a,i);return r!==void 0&&r.length>0&&s("authorization",`Bearer ${r}`),o},R=e=>Array.isArray(e)?e:[e],_=(e,t)=>{const r={"service.name":e};for(const[o,n]of Object.entries(t??{}))r[o]=n;return Object.entries(r).map(([o,n])=>v(o,n))},$=(e,t,r,o)=>({resourceSpans:[{resource:{attributes:_(r,o)},scopeSpans:[{scope:{name:t},spans:R(e)}]}]}),x=(e,t,r,o)=>({resourceLogs:[{resource:{attributes:_(r,o)},scopeLogs:[{logRecords:R(e),scope:{name:t}}]}]}),D=e=>{const t={},r=e("SERVICE_VERSION")??e("CF_VERSION_METADATA")??e("VERCEL_GIT_COMMIT_SHA")??e("GITHUB_SHA")??e("COMMIT_SHA");r!==void 0&&(t["service.version"]=r);const o=e("DEPLOYMENT_ENVIRONMENT")??e("ENVIRONMENT")??e("NODE_ENV");return o!==void 0&&(t["deployment.environment"]=o),t},P=(e,t)=>{const r={},o=e("HOSTNAME")??e("COMPUTERNAME");o!==void 0&&(r["host.name"]=o);const n=e("KUBERNETES_POD_NAME")??e("HOSTNAME");return n!==void 0&&e("KUBERNETES_SERVICE_HOST")!==void 0&&(r["k8s.pod.name"]=n),t!==void 0&&Number.isFinite(t)&&(r["process.pid"]=t),r},I=(...e)=>{const t={};for(const r of e)if(r!==void 0)for(const[o,n]of Object.entries(r))t[o]=n;return t},j=1e4,l=e=>process.env[e],k=()=>{const e=typeof process.pid!="number"?void 0:process.pid;return I(D(l),P(l,e))},H=e=>{if(e!==void 0)return e;if(typeof globalThis.fetch=="function")return globalThis.fetch},B=(e,t,r,o)=>{const n=M(e.attributes);e.error?.type!==void 0&&n.push(v("error.type",e.error.type));const s={attributes:n,endTimeUnixNano:b(e.endMs),kind:1,name:e.name,...r===void 0?{}:{parentSpanId:r.parentSpanId},spanId:A(8),startTimeUnixNano:b(e.startMs),status:e.error===void 0?{code:1}:{code:2,message:e.error.message},traceId:r?.traceId??A(16)};return e.error&&(s.events=[{attributes:[v("exception.type",e.error.type??"Error"),v("exception.message",e.error.message)],name:"exception",timeUnixNano:b(e.endMs)}]),$(s,"@lunora/container",t,o)},F=(e,t,r,o)=>{const n=e.level??"info",s={attributes:M(e.attributes),body:{stringValue:e.message},severityNumber:L[n],severityText:n.toUpperCase(),timeUnixNano:b(e.ts??r)};return x(s,"@lunora/container",t,o)},K=(e={})=>{const t=e.endpoint??l("LUNORA_OTLP_ENDPOINT"),r=t!==void 0&&t.length>0,o=e.token??l("LUNORA_OTLP_TOKEN"),n=e.serviceName??l("LUNORA_SERVICE_NAME")??"lunora-container",s=C(e.traceparent??l("LUNORA_TRACEPARENT")),a=e.timeoutMs??j,i=H(e.fetch),E=U({"content-type":"application/json"},e.headers,o),f=e.detectResources===!0?k():void 0,g={};e.serviceVersion!==void 0&&(g["service.version"]=e.serviceVersion),e.deploymentEnvironment!==void 0&&(g["deployment.environment"]=e.deploymentEnvironment),e.resourceAttributes!==void 0&&Object.assign(g,e.resourceAttributes);const T=I(f,g);let p=t??"";for(;p.endsWith("/");)p=p.slice(0,-1);const w=`${p}/v1/traces`,V=`${p}/v1/logs`,N=new Set,h=(c,S)=>{if(i===void 0){e.onError?.(new TypeError("createContainerTelemetry: no `fetch` available — pass `fetch` in options for this runtime."));return}const m=(async()=>{try{const d=await i(c,{body:JSON.stringify(S),headers:E,method:"POST",signal:AbortSignal.timeout(a)});d.ok||e.onError?.(new Error(`createContainerTelemetry: OTLP export to ${c} failed with status ${String(d.status)}.`));try{await d.body?.cancel()}catch{}}catch(d){e.onError?.(d)}})().finally(()=>{N.delete(m)});N.add(m)},O=c=>{r&&h(w,B(c,n,s,T))};return{emitLog:c=>{r&&h(V,F(c,n,Date.now(),T))},emitSpan:O,enabled:r,flush:async()=>{await Promise.allSettled(N)},trace:async(c,S,m)=>{const d=Date.now();try{const u=await S();return O({attributes:m,endMs:Date.now(),name:c,startMs:d}),u}catch(u){throw O({attributes:m,endMs:Date.now(),error:{message:u instanceof Error?u.message:String(u),type:u instanceof Error?u.name:void 0},name:c,startMs:d}),u}}}};export{K as createContainerTelemetry};
@@ -0,0 +1,27 @@
1
+ import{DurableObject as F,WorkerEntrypoint as $}from"cloudflare:workers";function D(i=9){const t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",e=new Uint8Array(i);crypto.getRandomValues(e);let n="";for(let s=0;s<i;s++)n+=t[e[s]%t.length];return n}function W(i){if(typeof i=="number")return i;if(typeof i=="string"){const t=i.match(/^(\d+)([smh])$/);if(!t)throw new Error(`invalid time expression ${i}`);const e=parseInt(t[1]),n=t[2];switch(n){case"s":return e;case"m":return e*60;case"h":return e*60*60;default:throw new Error(`unknown time unit ${n}`)}}throw new Error(`invalid type for a time expression: ${typeof i}`)}const B="there is no container instance that can be provided to this durable object",_="you are requesting too many containers per second",E="runtime signalled the container to exit:",C="container exited with unexpected exit code:",j="container is not listening",T="__CF_CONTAINER_STATE",k="OUTBOUND_CONFIGURATION",U=3,O=100,G=180*1e3,P=5e3,V="10m",v=300,H=8e3,R=2e4,N=33;function et(i,t){return t}const g=new Map,S=new Map,A=new Map,K={SIGINT:2,SIGTERM:15,SIGKILL:9};function m(i,t){return(i instanceof Error?i.message:String(i)).toLowerCase().includes(t)}const I=i=>m(i,B),X=i=>m(i,_),J=i=>m(i,E),M=i=>m(i,j),Y=i=>m(i,C);function z(i){return i instanceof Error?J(i)?+i.message.toLowerCase().slice(i.message.toLowerCase().indexOf(E)+E.length+1):Y(i)?+i.message.toLowerCase().slice(i.message.toLowerCase().indexOf(C)+C.length+1):null:null}function L(i,t){const e=new AbortController;if(i?.aborted)return e.abort(),e.signal;i?.addEventListener("abort",()=>e.abort());const n=setTimeout(()=>e.abort(),t);return e.signal.addEventListener("abort",()=>clearTimeout(n)),e.signal}function x(i,t){const e=i.split("*");if(e.length===1)return i===t;if(!t.startsWith(e[0])||!t.endsWith(e[e.length-1]))return!1;let n=e[0].length;for(let s=1;s<e.length-1;s++){const o=t.indexOf(e[s],n);if(o===-1)return!1;n=o+e[s].length}return n<=t.length-e[e.length-1].length}function q(i,t){return t.some(e=>x(e,i))}function Q(i){let t=i.length;for(;t>0&&i[t-1]===".";)t--;return i.slice(0,t)}class Z{storage;status;constructor(t){this.storage=t}async setRunning(){await this.setStatusAndupdate("running")}async setHealthy(){await this.setStatusAndupdate("healthy")}async setStopping(){await this.setStatusAndupdate("stopping")}async setStopped(){await this.setStatusAndupdate("stopped")}async setStoppedIfUnchanged(t){this.status===t&&await this.setStopped()}async setStoppedWithCode(t){this.status={status:"stopped_with_code",lastChange:Date.now(),exitCode:t},await this.update()}async getState(){if(!this.status){const t=await this.storage.get(T);t?this.status=t:(this.status={status:"stopped",lastChange:Date.now()},await this.update())}return this.status}async setStatusAndupdate(t){this.status={status:t,lastChange:Date.now()},await this.update()}async update(){if(!this.status)throw new Error("status should be init");await this.storage.put(T,this.status)}}class nt extends ${async fetch(t){const e=new URL(t.url),n=Q(e.hostname),{className:s,containerId:o,outboundByHostOverrides:a,outboundHandlerOverride:c,enableInternet:d,allowedHosts:r,deniedHosts:l,interceptAll:f}=this.ctx.props,p={containerId:o,className:s};if(l&&q(n,l))return new Response("Origin is disallowed",{status:520});if(r&&!q(n,r))return new Response("Origin is disallowed",{status:520});const y=g.get(s);if(a&&y){const w=a[n]??Object.entries(a).find(([b])=>b!==n&&x(b,n))?.[1];if(w&&y[w.method])return y[w.method](t,this.env,{...p,params:w.params})}const u=A.get(s);if(u){const w=u[n]??Object.entries(u).find(([b])=>b!==n&&x(b,n))?.[1];if(w)return w(t,this.env,p)}if(!f)return r||d?fetch(t):new Response("Origin is disallowed",{status:520});if(c&&y?.[c.method])return y[c.method](t,this.env,{...p,params:c.params});const h=S.get(s);return h&&y?.[h]?y[h](t,this.env,p):r||d?fetch(t):new Response("Origin is disallowed",{status:520})}}class st extends F{static get outboundByHost(){return A.get(this.name)}static set outboundByHost(t){A.set(this.name,t)}static get outboundHandlers(){return g.get(this.name)}static set outboundHandlers(t){const e=g.get(this.name)??{};g.set(this.name,{...e,...t})}static get outbound(){const t=S.get(this.name);if(t)return g.get(this.name)?.[t]}static set outbound(t){const e="__outbound__",n=g.get(this.name)??{};g.set(this.name,{...n,[e]:t}),S.set(this.name,e)}static get outboundProxies(){return this.outboundHandlers}static set outboundProxies(t){this.outboundHandlers=t}static get outboundProxy(){return this.outbound}static set outboundProxy(t){this.outbound=t}defaultPort;requiredPorts;sleepAfter=V;envVars={};entrypoint;enableInternet=!0;labels={};interceptHttps=!1;allowedHosts;deniedHosts;pingEndpoint="ping";applyOutboundInterceptionPromise=Promise.resolve();usingInterception=!1;constructor(t,e,n){if(super(t,e),t.container===void 0)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");this.state=new Z(this.ctx.storage);const s=this.restoreOutboundConfiguration();this.ctx.blockConcurrencyWhile(async()=>{await this.scheduleNextAlarm(),this.renewActivityTimeout();const o=this.constructor;(s!==void 0||o.outboundByHost!==void 0||o.outbound!==void 0||o.outboundHandlers!==void 0||this.effectiveAllowedHosts!==void 0||this.effectiveDeniedHosts!==void 0)&&(this.usingInterception=!0),this.container.running&&(this.applyOutboundInterceptionPromise=this.applyOutboundInterception())}),this.container=t.container,n&&(n.defaultPort!==void 0&&(this.defaultPort=n.defaultPort),n.sleepAfter!==void 0&&(this.sleepAfter=n.sleepAfter),n.envVars!==void 0&&(this.envVars=n.envVars),n.entrypoint!==void 0&&(this.entrypoint=n.entrypoint),n.enableInternet!==void 0&&(this.enableInternet=n.enableInternet)),this.sql`
2
+ CREATE TABLE IF NOT EXISTS container_schedules (
3
+ id TEXT PRIMARY KEY NOT NULL DEFAULT (randomblob(9)),
4
+ callback TEXT NOT NULL,
5
+ payload TEXT,
6
+ type TEXT NOT NULL CHECK(type IN ('scheduled', 'delayed')),
7
+ time INTEGER NOT NULL,
8
+ delayInSeconds INTEGER,
9
+ created_at INTEGER DEFAULT (unixepoch())
10
+ )
11
+ `,this.container.running&&(this.monitor=this.container.monitor(),this.setupMonitorCallbacks())}async getState(){return{...await this.state.getState()}}async setOutboundHandler(t,...e){this.validateOutboundHandlerMethodName(t),this.outboundHandlerOverride=e.length===0?{method:t}:{method:t,params:e[0]},await this.refreshOutboundInterception()}async setOutboundByHost(t,e,...n){this.validateOutboundHandlerMethodName(e),this.outboundByHostOverrides[t]=n.length===0?{method:e}:{method:e,params:n[0]},await this.refreshOutboundInterception()}async removeOutboundByHost(t){delete this.outboundByHostOverrides[t],await this.refreshOutboundInterception()}async setOutboundByHosts(t){for(const e of Object.values(t)){const n=typeof e=="string"?e:e.method;this.validateOutboundHandlerMethodName(n)}this.outboundByHostOverrides=Object.fromEntries(Object.entries(t).map(([e,n])=>[e,typeof n=="string"?{method:n}:n])),await this.refreshOutboundInterception()}async setAllowedHosts(t){this.allowedHostsOverride=[...t],this.usingInterception=!0,await this.refreshOutboundInterception()}async setDeniedHosts(t){this.deniedHostsOverride=[...t],this.usingInterception=!0,await this.refreshOutboundInterception()}async allowHost(t){const e=this.effectiveAllowedHosts??[];e.includes(t)||(this.allowedHostsOverride=[...e,t]),this.usingInterception=!0,await this.refreshOutboundInterception()}async denyHost(t){const e=this.effectiveDeniedHosts??[];e.includes(t)||(this.deniedHostsOverride=[...e,t]),this.usingInterception=!0,await this.refreshOutboundInterception()}async removeAllowedHost(t){this.allowedHostsOverride=(this.effectiveAllowedHosts??[]).filter(e=>e!==t),await this.refreshOutboundInterception()}async removeDeniedHost(t){this.deniedHostsOverride=(this.effectiveDeniedHosts??[]).filter(e=>e!==t),await this.refreshOutboundInterception()}async start(t,e){const n=e?.portToCheck??this.defaultPort??(this.requiredPorts?this.requiredPorts[0]:N),s=e?.waitInterval??v;await this.startContainerIfNotRunning({signal:e?.signal,waitInterval:s,retries:e?.retries??Math.ceil(H/s),portToCheck:n},t),this.setupMonitorCallbacks(),await this.ctx.blockConcurrencyWhile(async()=>{await this.onStart()})}async startAndWaitForPorts(t,e,n){let s,o,a;typeof t=="object"&&t!==null&&!Array.isArray(t)?(s=t.ports,o=t.cancellationOptions,a=t.startOptions):(s=t,o=e,a=n);const c=await this.getPortsToCheck(s);await this.syncPendingStoppedEvents(),o??={};const d=o.instanceGetTimeoutMS??H,r=o.waitInterval??v,l=Math.ceil(d/r),f={signal:o.abort,retries:l,waitInterval:r,portToCheck:c[0]},p=await this.startContainerIfNotRunning(f,a);let y=Math.ceil((o.portReadyTimeoutMS??R)/r)-p;for(const u of c)y=await this.waitForPort({signal:o.abort,waitInterval:r,retries:y,portToCheck:u});this.setupMonitorCallbacks(),await this.ctx.blockConcurrencyWhile(async()=>{await this.state.setHealthy(),await this.onStart()})}async waitForPort(t){const e=t.portToCheck,n=this.container.getTcpPort(e),s=new Promise(c=>{t.signal?.addEventListener("abort",()=>{c(!0)})}),o=t.waitInterval??v,a=t.retries??Math.ceil(R/o);for(let c=0;c<a;c++)try{const d=L(t.signal,P);await n.fetch(`http://${this.pingEndpoint}`,{signal:d});break}catch(d){const r=d instanceof Error?d.message:String(d);if(!this.container.running){try{await this.onError(new Error("Container crashed while checking for ports, did you start the container and setup the entrypoint correctly?"))}catch{}throw d}if(c===a-1){try{await this.onError(`Failed to verify port ${e} is available after ${(c+1)*o}ms, last error: ${r}`)}catch{}throw d}if(await Promise.any([new Promise(l=>setTimeout(l,o)),s]),t.signal?.aborted)throw new Error("Container request aborted.",{cause:d})}return a}async stop(t="SIGTERM"){this.container.running&&this.container.signal(typeof t=="string"?K[t]:t),await this.syncPendingStoppedEvents()}async destroy(){await this.container.destroy()}onStart(){}onStop(t){}async onActivityExpired(){console.log("Activity expired, signalling container to stop"),this.container.running&&await this.stop()}onError(t){throw console.error("Container error:",t),t}renewActivityTimeout(){const t=W(this.sleepAfter)*1e3;this.sleepAfterMs=Date.now()+t}decrementInflight(){this.inflightRequests=Math.max(0,this.inflightRequests-1),this.inflightRequests===0&&this.renewActivityTimeout()}async schedule(t,e,n){const s=D(9);if(typeof e!="string")throw new Error("Callback must be a string (method name)");if(typeof this[e]!="function")throw new Error(`this.${e} is not a function`);if(t instanceof Date){const o=Math.floor(t.getTime()/1e3);return this.sql`
12
+ INSERT OR REPLACE INTO container_schedules (id, callback, payload, type, time)
13
+ VALUES (${s}, ${e}, ${JSON.stringify(n)}, 'scheduled', ${o})
14
+ `,await this.scheduleNextAlarm(),{taskId:s,callback:e,payload:n,time:o,type:"scheduled"}}if(typeof t=="number"){const o=Math.floor(Date.now()/1e3+t);return this.sql`
15
+ INSERT OR REPLACE INTO container_schedules (id, callback, payload, type, delayInSeconds, time)
16
+ VALUES (${s}, ${e}, ${JSON.stringify(n)}, 'delayed', ${t}, ${o})
17
+ `,await this.scheduleNextAlarm(),{taskId:s,callback:e,payload:n,delayInSeconds:t,time:o,type:"delayed"}}throw new Error("Invalid schedule type. 'when' must be a Date or number of seconds")}async containerFetch(t,e,n){const{request:s,port:o}=this.requestAndPortFromContainerFetchArgs(t,e,n),a=await this.state.getState();if(!this.container.running||a.status!=="healthy")try{await this.startAndWaitForPorts(o,{abort:s.signal})}catch(r){return I(r)?new Response(`There is no Container instance available at this time.
18
+ This is likely because you have reached your max concurrent instance count (set in wrangler config) or are you currently provisioning the Container.
19
+ If you are deploying your Container for the first time, check your dashboard to see provisioning status, this may take a few minutes.`,{status:503}):X(r)?new Response(r instanceof Error?r.message:String(r),{status:429}):new Response(`Failed to start container: ${r instanceof Error?r.message:String(r)}`,{status:500})}const c=this.container.getTcpPort(o),d=s.url.replace("https:","http:");this.inflightRequests++;try{this.renewActivityTimeout();const r=await c.fetch(d,s);if(r.webSocket!==null){const l=r.webSocket,[f,p]=Object.values(new WebSocketPair);let y=!1;const u=()=>{y||(y=!0,this.decrementInflight())};return l.accept(),p.accept(),p.addEventListener("message",async h=>{this.renewActivityTimeout();try{const w=h.data instanceof Blob?await h.data.arrayBuffer():h.data;l.send(w)}catch{p.close(1011,"Failed to forward message to container")}}),l.addEventListener("message",async h=>{this.renewActivityTimeout();try{const w=h.data instanceof Blob?await h.data.arrayBuffer():h.data;p.send(w)}catch{l.close(1011,"Failed to forward message to client")}}),p.addEventListener("close",h=>{u();const w=h.code===1005||h.code===1006?1e3:h.code;l.close(w,h.reason)}),l.addEventListener("close",h=>{u();const w=h.code===1005||h.code===1006?1e3:h.code;p.close(w,h.reason)}),p.addEventListener("error",()=>{u(),l.close(1011,"Client WebSocket error")}),l.addEventListener("error",()=>{u(),p.close(1011,"Container WebSocket error")}),new Response(null,{status:r.status,webSocket:f,headers:r.headers})}if(r.body!==null){const{readable:l,writable:f}=new IdentityTransformStream;return r.body?.pipeTo(f).finally(()=>{this.decrementInflight()}),new Response(l,r)}return this.decrementInflight(),r}catch(r){if(this.decrementInflight(),!(r instanceof Error))throw r;return r.message.includes("Network connection lost.")?new Response("Container suddenly disconnected, try again",{status:500}):(console.error(`Error proxying request to container ${this.ctx.id}:`,r),new Response(`Error proxying request to container: ${r instanceof Error?r.message:String(r)}`,{status:500}))}}async fetch(t){if(this.defaultPort===void 0&&!t.headers.has("cf-container-target-port"))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))`.");let e=this.defaultPort;if(t.headers.has("cf-container-target-port")){const n=parseInt(t.headers.get("cf-container-target-port")??"");if(isNaN(n))throw new Error("port value from switchPort is not a number");e=n}return await this.containerFetch(t,e)}container;onStopCalled=!1;state;monitor;startInFlight;monitoredPromise;sleepAfterMs=0;inflightRequests=0;outboundByHostOverrides={};outboundHandlerOverride;allowedHostsOverride;deniedHostsOverride;hasInterceptAllRegistration=!1;validateOutboundHandlerMethodName(t){const e=g.get(this.constructor.name);if(!e||!(t in e))throw new Error(`Outbound handler method '${t}' not found in outboundHandlers for ${this.constructor.name}`)}get effectiveAllowedHosts(){return this.allowedHostsOverride??this.allowedHosts}get effectiveDeniedHosts(){return this.deniedHostsOverride??this.deniedHosts}getOutboundConfiguration(){return{outboundByHostOverrides:Object.keys(this.outboundByHostOverrides).length>0?this.outboundByHostOverrides:void 0,outboundHandlerOverride:this.outboundHandlerOverride,allowedHosts:this.effectiveAllowedHosts,deniedHosts:this.effectiveDeniedHosts,hasInterceptAllRegistration:this.hasInterceptAllRegistration||void 0}}persistOutboundConfiguration(t){this.ctx.storage.kv.put(k,{...t,allowedHosts:this.allowedHostsOverride,deniedHosts:this.deniedHostsOverride})}restoreOutboundConfiguration(){const t=this.ctx.storage.kv.get(k);if(t){if(this.outboundHandlerOverride=void 0,t.outboundHandlerOverride!==void 0)try{this.validateOutboundHandlerMethodName(t.outboundHandlerOverride.method),this.outboundHandlerOverride=t.outboundHandlerOverride}catch(e){console.warn("Ignoring invalid persisted outbound handler override:",e)}this.outboundByHostOverrides={};for(const[e,n]of Object.entries(t.outboundByHostOverrides??{}))try{this.validateOutboundHandlerMethodName(n.method),this.outboundByHostOverrides[e]=n}catch(s){console.warn(`Ignoring invalid persisted outbound override for ${e}:`,s)}return this.hasInterceptAllRegistration=t.hasInterceptAllRegistration===!0,t.allowedHosts&&(this.allowedHostsOverride=t.allowedHosts),t.deniedHosts&&(this.deniedHostsOverride=t.deniedHosts),this.getOutboundConfiguration()}}needsCatchAllInterception(){return this.constructor.outbound!==void 0||this.outboundHandlerOverride!==void 0}hasMutableOutboundConfiguration(){return Object.keys(this.outboundByHostOverrides).length>0||this.allowedHostsOverride!==void 0||this.deniedHostsOverride!==void 0}shouldInterceptAllOutbound(){return this.hasInterceptAllRegistration||this.needsCatchAllInterception()||this.effectiveAllowedHosts!==void 0||this.effectiveDeniedHosts!==void 0||this.hasMutableOutboundConfiguration()}getStaticOutboundByHostKeys(){const t=this.constructor;return t.outboundByHost?Object.keys(t.outboundByHost):[]}getHostsToIntercept(){const t=new Set,e=this.constructor;if(e.outboundByHost)for(const n of Object.keys(e.outboundByHost))t.add(n);for(const n of Object.keys(this.outboundByHostOverrides))t.add(n);return[...t]}async refreshOutboundInterception(){this.usingInterception&&(this.applyOutboundInterceptionPromise=this.applyOutboundInterception(),await this.applyOutboundInterceptionPromise)}async applyOutboundInterception(){const t=this.ctx;if(t.exports===void 0)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");if(t.exports.ContainerProxy===void 0)throw new Error("ctx.exports.ContainerProxy is undefined, export ContainerProxy from the containers package in your worker entrypoint");const e=this.shouldInterceptAllOutbound();e&&(this.hasInterceptAllRegistration=e);const n=this.getOutboundConfiguration();this.persistOutboundConfiguration(n);const s=this.getHostsToIntercept(),o={enableInternet:this.enableInternet,containerId:this.ctx.id.toString(),className:this.constructor.name,outboundByHostOverrides:n.outboundByHostOverrides,outboundHandlerOverride:n.outboundHandlerOverride,allowedHosts:n.allowedHosts,deniedHosts:n.deniedHosts,interceptAll:e},a=t.exports.ContainerProxy({props:o});if(e){for(const c of this.getStaticOutboundByHostKeys())await this.container.interceptOutboundHttp(c,a),this.interceptHttps&&await this.container.interceptOutboundHttps(c,a);this.interceptHttps&&await this.container.interceptOutboundHttps("*",a),await this.container.interceptAllOutboundHttp(a)}else for(const c of s)await this.container.interceptOutboundHttp(c,a),this.interceptHttps&&await this.container.interceptOutboundHttps(c,a)}sql(t,...e){const n=t.reduce((s,o,a)=>s+o+(a<e.length?"?":""),"");return[...this.ctx.storage.sql.exec(n,...e)]}requestAndPortFromContainerFetchArgs(t,e,n){let s,o;if(t instanceof Request)s=t,o=typeof e=="number"?e:void 0;else{const a=typeof t=="string"?t:t.toString(),c=typeof e=="number"?{}:e||{};o=typeof e=="number"?e:typeof n=="number"?n:void 0,s=new Request(a,c)}if(o??=this.defaultPort,o===void 0)throw new Error("No port specified for container fetch. Set defaultPort or specify a port parameter.");return{request:s,port:o}}async getPortsToCheck(t){return t!==void 0?Array.isArray(t)?t:[t]:this.requiredPorts&&this.requiredPorts.length>0?[...this.requiredPorts]:[this.defaultPort??N]}async startContainerIfNotRunning(t,e){if(this.startInFlight)return this.startInFlight;if(this.container.running)return this.monitor||(this.monitor=this.container.monitor()),0;const n=this.doStartContainer(t,e);this.startInFlight=n;try{return await n}finally{this.startInFlight===n&&(this.startInFlight=void 0)}}async doStartContainer(t,e){const n=new Promise(a=>{t.signal?.addEventListener("abort",()=>{a(!0)})}),s=t.waitInterval??v,o=t.retries??Math.ceil(H/s);for(let a=0;a<o;a++){const c=e?.envVars??this.envVars,d=e?.entrypoint??this.entrypoint,r=e?.enableInternet??this.enableInternet,l=e?.labels??this.labels,f={enableInternet:r};c&&Object.keys(c).length>0&&(f.env=c),d&&(f.entrypoint=d),l&&Object.keys(l).length>0&&(f.labels=l),this.renewActivityTimeout();const p=async()=>{const u=await this.monitor?.catch(h=>h);if(typeof u=="number"){const h=new Error(`Container exited before we could determine the container health, exit code: ${u}`);await this.state.setStoppedWithCode(u),this.monitor=void 0;try{await this.onError(h)}catch{}throw h}else if(!I(u)){await this.state.setStopped(),this.monitor=void 0;try{await this.onError(u)}catch{}throw u}};a>0&&!this.container.running&&await p(),await this.scheduleNextAlarm(),this.container.running?await this.scheduleNextAlarm():(await this.refreshOutboundInterception(),this.container.start(f),this.monitor=this.container.monitor(),await this.state.setRunning()),this.renewActivityTimeout();const y=this.container.getTcpPort(t.portToCheck);try{const u=L(t.signal,P);return await y.fetch("http://containerstarthealthcheck",{signal:u}),a}catch(u){if(M(u)&&this.container.running)return a;if(!this.container.running&&M(u)&&await p(),await Promise.any([new Promise(h=>setTimeout(h,t.waitInterval)),n]),t.signal?.aborted)throw new Error("Aborted waiting for container to start as we received a cancellation signal",{cause:u});if(o===a+1)throw u instanceof Error&&u.message.includes("Network connection lost")&&this.ctx.abort(),await p(),await this.state.setStopped(),this.monitor=void 0,new Error(B,{cause:u});continue}}throw new Error(`Container did not start after ${o*s}ms`)}setupMonitorCallbacks(){const t=this.monitor;!t||this.monitoredPromise===t||(this.monitoredPromise=t,t.then(async()=>{await this.ctx.blockConcurrencyWhile(async()=>{this.monitor===t&&await this.state.setStoppedWithCode(0)})}).catch(async e=>{if(this.monitor!==t)return;if(I(e)){await this.ctx.blockConcurrencyWhile(async()=>{this.monitor===t&&await this.state.setStopped()});return}const n=z(e);if(n!==null){await this.ctx.blockConcurrencyWhile(async()=>{this.monitor===t&&await this.state.setStoppedWithCode(n)});return}if(await this.ctx.blockConcurrencyWhile(async()=>{this.monitor===t&&await this.state.setStopped()}),this.monitor===t)try{await this.onError(e)}catch{}}).finally(()=>{this.monitor===t&&(this.monitoredPromise=void 0,this.monitor=void 0)}))}deleteSchedules(t){this.sql`DELETE FROM container_schedules WHERE callback = ${t}`}async alarm(t){if(t!==void 0&&t.isRetry&&t.retryCount>U){((Number(this.sql`SELECT COUNT(*) as count FROM container_schedules`[0]?.count)||0)>0||this.container.running)&&await this.scheduleNextAlarm();return}const e=this.sql`
20
+ SELECT * FROM container_schedules;
21
+ `;let n=Date.now()+G;const s=Date.now()/1e3;for(const d of e){if(d.time>s)continue;const r=this[d.callback];if(!r||typeof r!="function"){console.error(`Callback ${d.callback} not found or is not a function`);continue}const l=this.getSchedule(d.id);try{const f=d.payload?JSON.parse(d.payload):void 0;await r.call(this,f,await l)}catch(f){console.error(`Error executing scheduled callback "${d.callback}":`,f)}this.sql`DELETE FROM container_schedules WHERE id = ${d.id}`}const o=this.sql`
22
+ SELECT * FROM container_schedules;
23
+ `,a=Math.min(...o.map(d=>d.time*1e3));if(!this.container.running){await this.syncPendingStoppedEvents(),o.length==0?await this.ctx.storage.deleteAlarm():await this.ctx.storage.setAlarm(a);return}if(this.isActivityExpired()){await this.onActivityExpired(),this.renewActivityTimeout(),await this.ctx.storage.setAlarm(Date.now()+O);return}n=Math.min(a,n,this.sleepAfterMs);const c=Math.max(n,Date.now()+O);await this.ctx.storage.setAlarm(c)}async syncPendingStoppedEvents(){const t=await this.state.getState();if(!this.container.running&&(t.status==="healthy"||t.status==="running")){await this.callOnStop({exitCode:0,reason:"exit"},t);return}if(!this.container.running&&t.status==="stopped_with_code"){await this.callOnStop({exitCode:t.exitCode??0,reason:"exit"},t);return}}async callOnStop(t,e){if(this.onStopCalled)return;this.onStopCalled=!0;const n=this.onStop(t);n instanceof Promise?await n.finally(()=>{this.onStopCalled=!1}):this.onStopCalled=!1,await this.state.setStoppedIfUnchanged(e)}async scheduleNextAlarm(t=1e3){const e=Date.now()+Math.max(t,O),n=await this.ctx.storage.getAlarm();n!==null&&n<=e||(await this.ctx.storage.setAlarm(e),await this.ctx.storage.sync())}async listSchedules(t){const e=this.sql`
24
+ SELECT * FROM container_schedules WHERE callback = ${t} LIMIT 1
25
+ `;return!e||e.length===0?[]:e.map(this.toSchedule)}toSchedule(t){let e;try{e=JSON.parse(t.payload)}catch(n){console.error(`Error parsing payload for schedule ${t.id}:`,n),e=void 0}return t.type==="delayed"?{taskId:t.id,callback:t.callback,payload:e,type:"delayed",time:t.time,delayInSeconds:t.delayInSeconds}:{taskId:t.id,callback:t.callback,payload:e,type:"scheduled",time:t.time}}async getSchedule(t){const e=this.sql`
26
+ SELECT * FROM container_schedules WHERE id = ${t} LIMIT 1
27
+ `;if(!e||e.length===0)return;const n=e[0];return this.toSchedule(n)}isActivityExpired(){return this.inflightRequests>0?(this.renewActivityTimeout(),!1):this.sleepAfterMs<=Date.now()}}export{st as Container,nt as ContainerProxy,et as outboundParams};
@@ -0,0 +1 @@
1
+ import{LunoraError as p}from"@lunora/errors";const d=new Set(["basic","lite","standard-1","standard-2","standard-3","standard-4"]),o=/^[A-Z_]\w*$/i,s=/^\d+[smh]$/,u={h:3600,m:60,s:1},E=e=>{if(typeof e=="number")return Math.floor(e);if(s.exec(e)===null)throw new TypeError(`Invalid duration "${e}" — expected a number of seconds or "<n>[smh]"`);return Number(e.slice(0,-1))*(u[e.slice(-1)]??1)},c=e=>{const r=e.endsWith("/")?e.slice(0,-1):e,n=r.lastIndexOf("/");return n===-1?r:r.slice(n+1)},m=e=>{const r=e.lastIndexOf("/");return r===-1?".":e.slice(0,r)||"/"},$=e=>{if(typeof e!="string")return"build"in e?{buildDir:e.build.endsWith("/")?e.build.slice(0,-1):e.build,kind:"build"}:{kind:"registry",reference:e.registry};if(c(e).startsWith("Dockerfile"))return{buildContext:m(e),dockerfilePath:e,kind:"dockerfile"};const r=e.endsWith("/")?e.slice(0,-1):e;return{buildContext:r,dockerfilePath:`${r}/Dockerfile`,kind:"dockerfile"}},k=e=>`${e.charAt(0).toUpperCase()}${e.slice(1)}Container`,A=e=>`CONTAINER_${e.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g,"_").toUpperCase()}`,I=e=>`lunora-${e.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g,"-").toLowerCase()}:build`,g=e=>{if(typeof e=="string"){if(e.length===0)throw new TypeError("defineContainer: `image` must be a non-empty path or a { registry } reference");if(e.includes(":"))throw new TypeError(`defineContainer: \`image\` string "${e}" looks like a registry reference — pass it as { registry: "${e}" } instead. Plain strings are local Dockerfile paths.`);return}if("build"in e){if(typeof e.build!="string"||e.build.length===0)throw new TypeError("defineContainer: `image.build` must be a non-empty source directory for Railpack to build");return}if(typeof e.registry!="string"||e.registry.length===0)throw new TypeError("defineContainer: `image.registry` must be a non-empty fully-qualified image reference")},y=(e,r,n)=>{for(const[t,i]of Object.entries(e.secretsStore??{})){if(!o.test(t))throw new TypeError(`defineContainer: secretsStore env name "${t}" is not a valid environment variable name`);if(typeof i!="string"||i.trim().length===0)throw new TypeError(`defineContainer: \`secretsStore["${t}"]\` must be a non-empty Secrets Store binding name`);if(r.has(t)||n.has(t))throw new TypeError(`defineContainer: "${t}" is declared in both \`secretsStore\` and \`env\`/\`secrets\` — pick one source for the value`)}},h=e=>{for(const t of Object.keys(e.env??{}))if(!o.test(t))throw new TypeError(`defineContainer: env variable name "${t}" is not a valid environment variable name`);for(const t of Object.keys(e.buildArgs??{}))if(!o.test(t))throw new TypeError(`defineContainer: buildArg name "${t}" is not a valid environment variable name`);const r=new Set(Object.keys(e.env??{})),n=new Set(e.secrets);for(const t of e.secrets??[]){if(!o.test(t))throw new TypeError(`defineContainer: secret name "${t}" is not a valid environment variable name`);if(r.has(t))throw new TypeError(`defineContainer: "${t}" is declared in both \`env\` and \`secrets\` — a secret would silently overwrite the static env value; pick one`)}y(e,r,n)},a=(e,r)=>{if(!Number.isInteger(e)||e<1||e>65535)throw new TypeError(`defineContainer: \`${r}\` must be an integer in 1–65535 (got ${String(e)})`)},w=e=>{for(const r of e.readyOn??[]){if(typeof r.path!="string"||r.path.trim().length===0)throw new TypeError("defineContainer: `readyOn[].path` must be a non-empty HTTP path string");if(r.path!==r.path.trim())throw new TypeError("defineContainer: `readyOn[].path` must not have leading or trailing whitespace");if(r.port!==void 0&&a(r.port,"readyOn[].port"),r.status!==void 0&&(!Number.isInteger(r.status)||r.status<100||r.status>599))throw new TypeError(`defineContainer: \`readyOn[].status\` must be an HTTP status code in 100–599 (got ${String(r.status)})`)}},b=e=>{if(e!==void 0){if(typeof e=="string"){if(!s.test(e))throw new TypeError(`defineContainer: \`hardTimeout\` string "${e}" must be a number of seconds followed by a unit, e.g. "30s", "5m", or "1h"`)}else if(!Number.isInteger(e)||e<1)throw new TypeError(`defineContainer: \`hardTimeout\` must be a positive integer number of seconds or a duration string like "5m" (got ${String(e)})`)}},v=e=>{for(const r of["allowedHosts","deniedHosts"])if(e[r]?.some(n=>typeof n!="string"||n.trim().length===0))throw new TypeError(`defineContainer: \`${r}\` must be an array of non-empty hostname patterns`);if(e.interceptHttps!==void 0&&typeof e.interceptHttps!="boolean")throw new TypeError("defineContainer: `interceptHttps` must be a boolean, or omitted")},T=e=>{if(e.requiredPorts!==void 0){if(e.requiredPorts.length===0)throw new TypeError("defineContainer: `requiredPorts` must be a non-empty array of ports, or omitted");for(const r of e.requiredPorts)a(r,"requiredPorts[]")}if(e.entrypoint!==void 0&&(e.entrypoint.length===0||e.entrypoint.some(r=>typeof r!="string"||r.trim().length===0)))throw new TypeError("defineContainer: `entrypoint` must be a non-empty array of non-empty strings, or omitted");if(v(e),e.pingEndpoint!==void 0&&(typeof e.pingEndpoint!="string"||e.pingEndpoint.trim().length===0))throw new TypeError("defineContainer: `pingEndpoint` must be a non-empty path string");for(const[r,n]of Object.entries(e.labels??{}))if(r.trim().length===0||typeof n!="string")throw new TypeError("defineContainer: `labels` must be a record of non-empty keys to string values");w(e)},S=e=>{g(e.image),e.defaultPort!==void 0&&a(e.defaultPort,"defaultPort");const r=e.rollout?.stepPercentage;if(r!==void 0&&(!Number.isInteger(r)||r<1||r>100))throw new TypeError(`defineContainer: \`rollout.stepPercentage\` must be an integer in 1–100 (got ${String(r)})`);if(e.maxInstances!==void 0&&(!Number.isInteger(e.maxInstances)||e.maxInstances<1))throw new TypeError(`defineContainer: \`maxInstances\` must be a positive integer (got ${String(e.maxInstances)})`);if(typeof e.instanceType=="string"&&!d.has(e.instanceType))throw new TypeError(`defineContainer: unknown \`instanceType\` "${e.instanceType}" — use one of ${[...d].join(", ")}, or a custom { vcpu, memoryMib, diskMb } object`);if(typeof e.sleepAfter=="string"){if(!s.test(e.sleepAfter))throw new TypeError(`defineContainer: \`sleepAfter\` string "${e.sleepAfter}" must be a number of seconds followed by a unit, e.g. "30s", "5m", or "1h"`)}else if(e.sleepAfter!==void 0&&(!Number.isInteger(e.sleepAfter)||e.sleepAfter<1))throw new TypeError(`defineContainer: \`sleepAfter\` must be a positive integer number of seconds or a duration string like "5m" (got ${String(e.sleepAfter)})`);return b(e.hardTimeout),h(e),T(e),{...e,isLunoraContainer:!0}},P=e=>typeof e=="object"&&e!==null&&e.isLunoraContainer===!0,N=(e,r,n)=>{const t={...e.env};for(const i of e.secrets??[]){const f=r[i];if(typeof f!="string"){const l=n===void 0?"container":`container "${n}"`;throw new p("INTERNAL",`${l}: declared secret "${i}" is not set on the Worker environment. Add it to .dev.vars for local dev and run \`wrangler secret put ${i}\` for production.`)}t[i]=f}return t};export{A as containerBindingName,I as containerBuildTag,k as containerClassName,S as defineContainer,P as isContainerDefinition,$ as normalizeContainerImage,E as parseDurationSeconds,N as resolveContainerEnvVars};
@@ -0,0 +1 @@
1
+ import{LunoraError as D}from"@lunora/errors";import{containerBindingName as $}from"./containerBindingName-DP2NqQV-.mjs";import{e as E}from"./jurisdiction-BKRNOTip.mjs";const v=3,x=3e4,k=3,S=500,T="cf-container-target-port",b=(t,e,o,n)=>{const r=typeof t=="string"&&t.startsWith("/")?new Request(`http://container${t}`,e):new Request(t,e);return o!==void 0&&r.headers.set(T,String(o)),n!==void 0&&r.headers.set("traceparent",n),r},P=async t=>{t<=0||await new Promise(e=>{setTimeout(e,t)})},C=/no container instance|not listening|try again later|rate.?limit|provision/i,F="no Container instance available",R="Failed to start container:",j=1024,B=async t=>{const e=t.clone().body;if(e===null)return"";const o=e.getReader(),n=new TextDecoder;let r="";try{for(;r.length<j;){const{done:s,value:a}=await o.read();if(s)break;r+=n.decode(a,{stream:!0})}}finally{await o.cancel()}return r},I=t=>t instanceof Error&&C.test(t.message),O=async t=>{if(t.status===429)return!0;if(t.status!==500&&t.status!==503)return!1;try{const e=await B(t);return e.includes(F)||e.startsWith(R)}catch{return!1}},h=(t,e={},o,n)=>{const r=Math.max(1,e.attempts??k),s=e.backoffMs??S,a=e.maxBackoffMs??x;return{fetch:async(d,g)=>{const u=typeof d=="string"?r:1;let l;for(let m=0;m<u;m+=1){const f=m===u-1;m>0&&await P(Math.min(s*2**(m-1),a));try{const c=await t(b(d,g,o,n));if(f||!await O(c))return c}catch(c){if(l=c,f||!I(c))throw c}}throw l instanceof Error?l:new Error("ctx.containers: cold-start retry exhausted")},port:d=>h(t,e,d,n)}},w=(t,e,o,n)=>h(async r=>t.get(t.idFromName(e)).fetch(r),o,void 0,n),i=async(t,e,o,n)=>{const r=t[e];if(typeof r!="function")throw new TypeError(`ctx.containers: the "${o}" container DO does not expose ${e}() — is @lunora/container/do up to date?`);return r(n)},q=(t,e)=>({allow:async o=>i(t(),"allowHost",e,o),deny:async o=>i(t(),"denyHost",e,o),removeAllowed:async o=>i(t(),"removeAllowedHost",e,o),removeDenied:async o=>i(t(),"removeDeniedHost",e,o),setAllowed:async o=>i(t(),"setAllowedHosts",e,[...o]),setDenied:async o=>i(t(),"setDeniedHosts",e,[...o])}),H=(t,e,o,n,r)=>{const s=()=>t.get(t.idFromName(o));return{...h(async a=>s().fetch(a),n,void 0,r),destroy:async()=>i(s(),"destroy",e.binding),egress:q(s,e.binding),getState:async()=>i(s(),"getState",e.binding),renewActivityTimeout:async()=>i(s(),"renewActivityTimeout",e.binding),start:async a=>i(s(),"start",e.binding,a),stop:async a=>i(s(),"stop",e.binding,a)}},N=t=>`pool-${String(Math.floor(Math.random()*t))}`,L=t=>t.status>=500,A=(t,e,o={},n,r)=>{const s=o.size??e.maxInstances??v,a=Math.max(1,o.attempts??3),d=o.backoffMs??100,g=o.maxBackoffMs??x,u=o.retryOn??L;return{fetch:async(l,m)=>{const f=typeof l=="string"?a:1;let c;for(let y=0;y<f;y+=1){y>0&&await P(Math.min(d*2**(y-1),g));const M=b(l,m,n,r);try{const p=await t.get(t.idFromName(N(s))).fetch(M);if(y===f-1||!u(p))return p}catch(p){c=p}}throw c instanceof Error?c:new Error(`ctx.containers.${e.exportName}.pool(): all ${String(f)} attempts failed`)},port:l=>A(t,e,o,l,r)}},z=(t,e,o)=>({any:(n,r)=>w(t,N(n??e.maxInstances??v),r,o),get:(n,r)=>H(t,e,n,r,o),pool:n=>A(t,e,n,void 0,o)}),W=t=>{const e=()=>{throw new D("INTERNAL",`ctx.containers.${t.exportName}: no "${t.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.`)};return{any:e,get:e,pool:e}},G=(t,e,o,n)=>{const r={};for(const s of e){const a=t[s.binding];r[s.exportName]=a&&typeof a.idFromName=="function"&&typeof a.get=="function"?z(E(a,o),s,n):W(s)}return r},J=t=>{const e=o=>({allowHost:()=>Promise.resolve(),denyHost:()=>Promise.resolve(),destroy:()=>Promise.resolve(),fetch:n=>Promise.resolve(t(n,{name:o})),getState:()=>Promise.resolve({lastChange:0}),removeAllowedHost:()=>Promise.resolve(),removeDeniedHost:()=>Promise.resolve(),renewActivityTimeout:()=>Promise.resolve(),setAllowedHosts:()=>Promise.resolve(),setDeniedHosts:()=>Promise.resolve(),start:()=>Promise.resolve(),stop:()=>Promise.resolve()});return{get:o=>e(String(o)),idFromName:o=>o}},K=t=>{const e={};for(const[o,n]of Object.entries(t)){const r=J(n),s={binding:$(o)};e[o]={any:()=>w(r,"pool-0",{attempts:1}),get:a=>H(r,s,a,{attempts:1}),pool:()=>w(r,"pool-0",{attempts:1})}}return e};export{G as createContainerContext,K as createContainerTestContext};
@@ -0,0 +1 @@
1
+ const r=(o,e)=>{if(e===void 0)return o;if(typeof o.jurisdiction!="function")throw new TypeError(`@lunora/container: Durable Object namespace does not support jurisdiction("${e}") — update @cloudflare/workers-types or remove the jurisdiction option`);return o.jurisdiction(e)};export{r as e};
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Public configuration types for `@lunora/container`.
3
+ *
4
+ * Everything in this module is pure data — no Cloudflare runtime imports — so
5
+ * it is safe to import from Node tooling (codegen, the config layer) as well
6
+ * as from worker code.
7
+ */
8
+ /**
9
+ * Named instance types Cloudflare Containers provides.
10
+ * @experimental
11
+ */
12
+ type NamedContainerInstanceType = "basic" | "lite" | "standard-1" | "standard-2" | "standard-3" | "standard-4";
13
+ /**
14
+ * A custom instance type. Cloudflare's bounds at the time of writing: up to
15
+ * 4 vCPU, 12 GiB memory, 20 GB disk, ≥ 3 GiB memory per vCPU and ≤ 2 GB disk
16
+ * per GiB memory. The config-layer validator enforces the documented ranges.
17
+ * @experimental
18
+ */
19
+ interface CustomContainerInstanceType {
20
+ /** Disk in MB. Cloudflare's default is 2000 (2 GB). */
21
+ diskMb?: number;
22
+ /** Memory in MiB. Cloudflare's default is 256. */
23
+ memoryMib?: number;
24
+ /** vCPU count. Cloudflare's default is 0.0625 (1/16 vCPU). */
25
+ vcpu?: number;
26
+ }
27
+ /**
28
+ * `ContainerInstanceType` is part of the experimental `@lunora/container` API and may change without a major version bump.
29
+ * @experimental
30
+ */
31
+ type ContainerInstanceType = CustomContainerInstanceType | NamedContainerInstanceType;
32
+ /**
33
+ * Rolling-deploy tuning for a container.
34
+ * @experimental
35
+ */
36
+ interface ContainerRollout {
37
+ /** Seconds an active instance runs before it's eligible for update (wrangler `rollout_active_grace_period`). */
38
+ gracePeriodSeconds?: number;
39
+ /** Percentage of instances updated per rollout step, 1–100 (wrangler `rollout_step_percentage`). */
40
+ stepPercentage?: number;
41
+ }
42
+ /**
43
+ * A pre-built image pulled from a registry — the Cloudflare Registry, Docker
44
+ * Hub, or Amazon ECR (the registries `wrangler deploy` supports). The
45
+ * reference must be fully qualified, e.g. `docker.io/acme/transcoder:1.4`.
46
+ * @experimental
47
+ */
48
+ interface RegistryImageSource {
49
+ registry: string;
50
+ }
51
+ /**
52
+ * A Dockerfile-less build via [Railpack](https://railpack.com): point at a
53
+ * source directory and `lunora deploy` builds an OCI image with Railpack
54
+ * (needs a BuildKit instance) and pushes it to the Cloudflare Registry before
55
+ * wrangler runs. Opt-in — the Dockerfile path is the zero-extra-deps default.
56
+ * @experimental
57
+ */
58
+ interface BuildImageSource {
59
+ build: string;
60
+ }
61
+ /**
62
+ * Where the container image comes from. A `string` is a **local path** —
63
+ * either a directory containing a `Dockerfile` (normalized to
64
+ * `&lt;dir>/Dockerfile` with the directory as the build context) or a path to
65
+ * the Dockerfile itself — while `{ registry }` is a pre-built image reference.
66
+ * @experimental
67
+ */
68
+ type ContainerImageSource = BuildImageSource | RegistryImageSource | string;
69
+ /**
70
+ * An application-level readiness probe that gates request proxying. Layered on
71
+ * top of the platform's own port/`pingEndpoint` health wait, it lets you hold
72
+ * traffic back until the app inside the container is *functionally* ready —
73
+ * migrations applied, caches warmed — which an open-port check can't see.
74
+ *
75
+ * Declarative on purpose: a `defineContainer` value stays pure data (no handler
76
+ * functions), so codegen and the config layer can read it without evaluating
77
+ * code. (Upstream cloudflare/containers#188 expresses the same idea as handler
78
+ * functions; the Lunora config is data-only, so it's modelled as descriptors.)
79
+ * @experimental
80
+ */
81
+ interface ContainerReadinessCheck {
82
+ /** HTTP path probed on the container, e.g. `"/ready"` (a leading slash is optional). */
83
+ path: string;
84
+ /** Port to probe. Defaults to {@link ContainerConfig.defaultPort}. */
85
+ port?: number;
86
+ /** HTTP status that means "ready". Defaults to `200`. */
87
+ status?: number;
88
+ }
89
+ /**
90
+ * `ContainerConfig` is part of the experimental `@lunora/container` API and may change without a major version bump.
91
+ * @experimental
92
+ */
93
+ interface ContainerConfig {
94
+ /**
95
+ * Hostnames the container may reach **even when {@link ContainerConfig.enableInternet}
96
+ * is `false`** — an egress allow-list (Cloudflare's `allowedHosts`). Glob
97
+ * patterns like `*.stripe.com` are supported. Pair with `enableInternet:
98
+ * false` to deny all egress except these hosts (the firewall pattern
99
+ * upstream issue cloudflare/containers#30 asked for). The interception path
100
+ * needs the `ContainerProxy` worker entrypoint, which codegen re-exports
101
+ * from the generated container file automatically; the named-instance
102
+ * handle's `egress` controls adjust the lists at runtime.
103
+ */
104
+ allowedHosts?: ReadonlyArray<string>;
105
+ /**
106
+ * Build-time variables for a Dockerfile/Railpack image — wrangler's
107
+ * `image_vars` (equivalent to `docker build --build-arg`). For *runtime*
108
+ * values use {@link ContainerConfig.env} / {@link ContainerConfig.secrets}.
109
+ * Ignored for a pre-built `{ registry }` image.
110
+ */
111
+ buildArgs?: Readonly<Record<string, string>>;
112
+ /**
113
+ * The port the container listens on. Worker → container requests target
114
+ * this port. Locally the Dockerfile must also `EXPOSE` it. For a
115
+ * multi-port container also declare {@link ContainerConfig.requiredPorts}
116
+ * and route per request with the handle's `.port(n)`.
117
+ */
118
+ defaultPort?: number;
119
+ /**
120
+ * Hostnames the container may **never** reach — an egress deny-list
121
+ * (Cloudflare's `deniedHosts`). Overrides everything else, including
122
+ * `enableInternet: true` and {@link ContainerConfig.allowedHosts}. Glob
123
+ * patterns like `*.evil.com` are supported.
124
+ */
125
+ deniedHosts?: ReadonlyArray<string>;
126
+ /**
127
+ * Whether the container may open outbound internet connections. Defaults
128
+ * to `true` — the platform default. Note that container egress is billed
129
+ * per GB by Cloudflare. Combine with {@link ContainerConfig.allowedHosts} /
130
+ * {@link ContainerConfig.deniedHosts} for a precise egress firewall.
131
+ */
132
+ enableInternet?: boolean;
133
+ /**
134
+ * Default command to run inside the container, overriding the image's
135
+ * `ENTRYPOINT`/`CMD` (Cloudflare's `entrypoint`). A per-start override is
136
+ * still available via the named-instance handle's `start({ entrypoint })`.
137
+ */
138
+ entrypoint?: ReadonlyArray<string>;
139
+ /**
140
+ * Static environment variables passed to the container on every start.
141
+ * For secret values use {@link ContainerConfig.secrets} instead so they
142
+ * flow through Worker Secrets rather than source code.
143
+ */
144
+ env?: Readonly<Record<string, string>>;
145
+ /**
146
+ * Hard cap on how long an instance may run, measured from start regardless
147
+ * of activity — a runaway-cost backstop on top of the idle
148
+ * {@link ContainerConfig.sleepAfter}. Same grammar as `sleepAfter`
149
+ * (`"30s"`, `"5m"`, `"1h"`, or a plain number of seconds). When it elapses,
150
+ * the `LunoraContainer.onHardTimeoutExpired` hook runs (default: `stop()`).
151
+ * (Upstream cloudflare/containers#85.)
152
+ */
153
+ hardTimeout?: number | string;
154
+ /** Image source — a local Dockerfile path/directory or a registry reference. */
155
+ image: ContainerImageSource;
156
+ /**
157
+ * Resource class for each instance: a named Cloudflare instance type or a
158
+ * custom `{ vcpu, memoryMib, diskMb }` object.
159
+ */
160
+ instanceType?: ContainerInstanceType;
161
+ /**
162
+ * Intercept the container's outbound **HTTPS** traffic so the egress
163
+ * allow/deny lists apply to TLS connections too (Cloudflare's
164
+ * `interceptHttps`). Requires the image to trust the Cloudflare CA at
165
+ * `/etc/cloudflare/certs/cloudflare-containers-ca.crt`. Defaults to `false`
166
+ * (HTTP egress is gated regardless).
167
+ */
168
+ interceptHttps?: boolean;
169
+ /**
170
+ * Key-value metadata attached to every instance for metrics/observability
171
+ * (Cloudflare's container `labels`), e.g. `{ tenant: "acme", env: "prod" }`.
172
+ * A per-start override is available via the named-instance handle's
173
+ * `start({ labels })`.
174
+ */
175
+ labels?: Readonly<Record<string, string>>;
176
+ /**
177
+ * Maximum number of concurrently *running* instances. Stopped (slept)
178
+ * containers don't count. Also the default pool size for `.any()`.
179
+ */
180
+ maxInstances?: number;
181
+ /**
182
+ * Override for the wrangler `containers[].name` identifier. Defaults to
183
+ * wrangler's own default (worker name + class name + environment).
184
+ */
185
+ name?: string;
186
+ /**
187
+ * HTTP path Cloudflare polls to decide an instance is healthy
188
+ * (Cloudflare's `pingEndpoint`). Defaults to upstream's slash-less `"ping"`;
189
+ * either `"ping"` or `"/healthz"`-style paths are accepted. Set this when
190
+ * the container exposes its readiness check under a different route.
191
+ */
192
+ pingEndpoint?: string;
193
+ /**
194
+ * Application-level readiness probes that gate request proxying: a
195
+ * `ctx.containers.&lt;name>` fetch waits until every probe responds with its
196
+ * expected status before the request reaches the container — on top of the
197
+ * platform's port/`pingEndpoint` health wait. All probes run in parallel.
198
+ * Use these for readiness an open-port check can't see (migrations applied,
199
+ * caches warm). (Upstream cloudflare/containers#188.)
200
+ */
201
+ readyOn?: ReadonlyArray<ContainerReadinessCheck>;
202
+ /**
203
+ * Ports the container must be listening on before it's considered ready
204
+ * (Cloudflare's `requiredPorts`) — for multi-port containers. Start-up
205
+ * waits for every listed port, and the handle's `.port(n)` routes a request
206
+ * to any of them; {@link ContainerConfig.defaultPort} is the target when a
207
+ * request doesn't pick one.
208
+ */
209
+ requiredPorts?: ReadonlyArray<number>;
210
+ /**
211
+ * Rolling-deploy tuning. `stepPercentage` is the share of instances updated
212
+ * per rollout step (wrangler `rollout_step_percentage`); `gracePeriodSeconds`
213
+ * is how long an active instance is left running before it's eligible for
214
+ * update (wrangler `rollout_active_grace_period`).
215
+ */
216
+ rollout?: ContainerRollout;
217
+ /**
218
+ * Names of Worker secrets (from `wrangler secret` / `.dev.vars`) forwarded
219
+ * into the container's environment at instance start. Each declared name
220
+ * must exist on the Worker `env` — a missing one fails fast with a
221
+ * directed error instead of starting the container without it.
222
+ */
223
+ secrets?: ReadonlyArray<string>;
224
+ /**
225
+ * Cloudflare **Secrets Store** secrets forwarded into the container's
226
+ * environment, as a map of *container env-var name → Worker Secrets Store
227
+ * binding name*. Each binding is resolved with its async `.get()` the first
228
+ * time the instance starts, then injected as that env var — e.g.
229
+ * `{ STRIPE_KEY: "STRIPE_SECRET" }` runs `env.STRIPE_SECRET.get()` and sets
230
+ * `STRIPE_KEY` inside the container. Unlike {@link ContainerConfig.secrets}
231
+ * (plain Worker text secrets), this pulls from a `secrets_store_secrets`
232
+ * binding. A name already used by `env`/`secrets` is rejected at authoring
233
+ * time; a missing binding or unreadable value fails the start. Applies
234
+ * to the default start (the `ctx.containers` proxy path and a bare
235
+ * `start()`); a per-instance `start({ envVars })` replaces the env set
236
+ * wholesale, as it does for `env`/`secrets`. (Upstream
237
+ * cloudflare/containers#96.)
238
+ */
239
+ secretsStore?: Readonly<Record<string, string>>;
240
+ /**
241
+ * Idle timeout after which the instance is put to sleep, e.g. `"5m"`,
242
+ * `"30s"`, or a number of seconds. Cloudflare's default is `"10m"`.
243
+ */
244
+ sleepAfter?: number | string;
245
+ }
246
+ /**
247
+ * The value `defineContainer` returns: the validated config plus a brand the
248
+ * codegen discovery and the generated Container DO class key on.
249
+ * @experimental
250
+ */
251
+ interface ContainerDefinition extends ContainerConfig {
252
+ /** Brand marking a value as a Lunora container definition. */
253
+ readonly isLunoraContainer: true;
254
+ }
255
+ /**
256
+ * A normalized image source, as written into `wrangler.jsonc`.
257
+ * @experimental
258
+ */
259
+ type NormalizedContainerImage = {
260
+ /** Build context directory (wrangler `image_build_context`). */
261
+ buildContext: string;
262
+ /** Path to the Dockerfile (wrangler `image`). */
263
+ dockerfilePath: string;
264
+ kind: "dockerfile";
265
+ } | {
266
+ /** Railpack source directory built + pushed at deploy time. */
267
+ buildDir: string;
268
+ kind: "build";
269
+ } | {
270
+ kind: "registry";
271
+ /** Fully-qualified image reference (wrangler `image`). */
272
+ reference: string;
273
+ };
274
+ /**
275
+ * Cloudflare Durable Object data-residency jurisdiction. Widening union —
276
+ * Cloudflare adds values over time.
277
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
278
+ * @experimental
279
+ */
280
+ type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
281
+ export { BuildImageSource as B, ContainerConfig as C, DurableObjectJurisdiction as D, 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 };