Skip to content
Source
Sign inSign up
Open History to see the last commit

RuntimeContext.ts

195 lines7.6 KB

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