1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Redacted from "effect/Redacted";
6import type { HttpEffect } from "./Http.ts";
7import type { CallbackFactory } from "./Callback.ts";
8import type { Output } from "./Output.ts";
9
10export interface BaseRuntimeContext {
11 Type: string;
12 id: string;
13 env: Record<string, any>;
14 /**
15 * Read a value by its (already-canonical) key. The key is used verbatim;
16 * callers must {@link sanitizeKey} first. See {@link sanitizeKey}.
17 */
18 get<T>(key: string): Effect.Effect<T | undefined>;
19 /**
20 * Store an output under the given (already-canonical) key, returning the key.
21 * The key is used verbatim; callers must {@link sanitizeKey} first.
22 */
23 set(id: string, output: Output): Effect.Effect<string>;
24 exports?: Effect.Effect<Record<string, any>>;
25 /** Register a durable callback in the current host instance, when supported. */
26 makeCallback?: CallbackFactory;
27 serve?<Req = never>(
28 handler: HttpEffect<Req>,
29 options?: { shape?: Record<string, unknown> },
30 ): Effect.Effect<void, never, Req>;
31 shape?: () => Record<string, unknown>;
32 /** additional services to provide to the plan */
33 planServices?: Layer.Layer<any>;
34 /**
35 * Telemetry exporter Layer registered during init via
36 * `Telemetry.layer(...)` / `Telemetry.layerOtlp(...)` (see Telemetry.ts).
37 * The runtime bridges build it into every event's request scope,
38 * overriding the env-driven default.
39 */
40 telemetry?: Layer.Layer<never, any, any>;
41}
42
43/**
44 * Keys a Platform's Init captured through {@link BaseRuntimeContext.set}
45 * (`yield* Config.x(...)`, `yield* output`). `Platform.make` attaches the
46 * runtime context to the resource and merges these values into its
47 * `props.env`.
48 */
49export const capturedEnvKeys = (resource: unknown): string[] => {
50 const ctx = (resource as { RuntimeContext?: Partial<BaseRuntimeContext> })
51 ?.RuntimeContext;
52 return ctx?.env ? Object.keys(ctx.env) : [];
53};
54
55/**
56 * Canonicalize a logical key into a key that is safe to use as the name of an
57 * environment variable / binding (`[a-zA-Z][a-zA-Z0-9_]*`).
58 *
59 * `RuntimeContext.set`/`get` are dumb key/value stores: they read and write the
60 * key **verbatim**. It is the *caller's* responsibility to hand them a
61 * canonical key, since the caller is the one that knows the logical key may
62 * contain `.`/`-` (e.g. a dotted config name from `Platform`, or an
63 * `Output.toString()` like `"QueueSinkQueue.queueUrl"`). Callers run the key
64 * through this before calling `set`/`get` so both sides agree.
65 */
66export const sanitizeKey = (key: string): string =>
67 key.replaceAll(/[^a-zA-Z0-9]/g, "_");
68
69/**
70 * The wire format `RuntimeContext.set`/`get` use to carry a `Redacted` value
71 * through an environment variable. `JSON.stringify(Redacted)` emits the
72 * literal string `"<redacted>"` and loses the value, so secrets are
73 * serialized as this marker and the runtime `get` path rebuilds the wrapper.
74 */
75export interface RedactedMarker {
76 readonly _tag: "Redacted";
77 readonly value: unknown;
78}
79
80/**
81 * Detect the (already JSON-parsed) {@link RedactedMarker} shape. After
82 * `JSON.parse` the marker is a plain object — `Redacted.isRedacted` is
83 * always `false` on it — so detection is structural.
84 */
85export const isRedactedMarker = (value: unknown): value is RedactedMarker =>
86 typeof value === "object" &&
87 value !== null &&
88 (value as { _tag?: unknown })._tag === "Redacted" &&
89 "value" in value;
90
91/**
92 * Returns true when {@link unpackEnvValue}'s `JSON.parse` would reinterpret
93 * the raw string as something other than itself — a number (`"123"`), a
94 * boolean, `null`, or a JSON document. Such strings must stay
95 * `JSON.stringify`-packed; everything else round-trips verbatim through the
96 * parse-failure fallback.
97 */
98const parsesAsJson = (value: string): boolean => {
99 try {
100 JSON.parse(value);
101 return true;
102 } catch {
103 return false;
104 }
105};
106
107/**
108 * Serialize a binding value for an env var: `Redacted` values are packed as
109 * a {@link RedactedMarker}, non-string values as JSON.
110 *
111 * A plain string is stored **verbatim** whenever `JSON.parse` would reject
112 * it — which is almost every real-world string (names, URLs, ids). Packing
113 * those too would put `"my-queue"` (quote characters included) on the wire,
114 * where anything that consumes the raw binding without going through
115 * {@link unpackEnvValue} — the Cloudflare dashboard, a hand-written env
116 * read, a queue-name comparison against `MessageBatch.queue` — sees the
117 * quoted form and mismatches (#1243). Only ambiguous strings (`"123"`,
118 * `"null"`, JSON documents) keep the pack so the read side can't
119 * reinterpret them.
120 */
121export const packEnvValue = (value: unknown): string =>
122 Redacted.isRedacted(value)
123 ? JSON.stringify({
124 _tag: "Redacted",
125 value: Redacted.value(value),
126 } satisfies RedactedMarker)
127 : typeof value === "string" && !parsesAsJson(value)
128 ? value
129 : JSON.stringify(value);
130
131/**
132 * Like {@link packEnvValue}, but a `Redacted` input keeps its `Redacted`
133 * wrapper on the *outside* of the packed string, so deploy-time code can
134 * route secrets through a dedicated channel (Cloudflare `secret_text`,
135 * Secrets Store) instead of leaking them as plain env vars. The inner
136 * payload still carries the marker for the runtime `get` accessor.
137 */
138export const packEnvValueKeepRedacted = (
139 value: unknown,
140): string | Redacted.Redacted<string> =>
141 Redacted.isRedacted(value)
142 ? Redacted.make(packEnvValue(value))
143 : packEnvValue(value);
144
145/**
146 * Parse an env-var string produced by {@link packEnvValue} back into its
147 * value: rebuild `Redacted` from the marker, return other JSON values
148 * as-is, and fall back to the raw string for non-JSON input (a verbatim
149 * string from `packEnvValue`, or an env var the user set directly).
150 * `undefined` passes through.
151 *
152 * Runtime `get` accessors MUST feed this from the raw environment
153 * (`process.env[key]` / the platform env object) — never through
154 * `Config.String`: the ambient runtime `ConfigProvider` reifies bound
155 * values (unwrapping the marker before it could be detected here), and
156 * during init the ambient provider is the interceptor installed in
157 * `Platform.ts`, whose runtime branch calls back into `ctx.get(key)` —
158 * resolving through `Config` would re-enter it for the same key and
159 * recurse forever.
160 */
161export const unpackEnvValue = <T>(raw: string | undefined): T | undefined => {
162 if (raw === undefined) {
163 return undefined;
164 }
165 try {
166 const parsed: unknown = JSON.parse(raw);
167 if (isRedactedMarker(parsed)) {
168 return Redacted.make(parsed.value) as unknown as T;
169 }
170 return parsed as T;
171 } catch {
172 return raw as unknown as T; // assume it's just a string
173 }
174};
175
176/**
177 * Context of the runtime environment.
178 *
179 * E.g. the context of a running Worker, Task, Process, Function
180 */
181export class RuntimeContext extends Context.Service<
182 RuntimeContext,
183 BaseRuntimeContext
184>()("RuntimeContext") {
185 static phantom = Layer.empty as Layer.Layer<RuntimeContext>;
186}
187
188export const CurrentRuntimeContext = Effect.serviceOption(RuntimeContext).pipe(
189 Effect.map(Option.getOrUndefined),
190);