| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Redacted from "effect/Redacted"; |
| 6 | import type { HttpEffect } from "./Http.ts"; |
| 7 | import type { CallbackFactory } from "./Callback.ts"; |
| 8 | import type { Output } from "./Output.ts"; |
| 9 | |
| 10 | export 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 | */ |
| 49 | export 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 | */ |
| 66 | export 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 | */ |
| 75 | export 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 | */ |
| 85 | export 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 | */ |
| 98 | const 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 | */ |
| 121 | export 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 | */ |
| 138 | export 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 | */ |
| 161 | export 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 | */ |
| 181 | export class RuntimeContext extends Context.Service< |
| 182 | RuntimeContext, |
| 183 | BaseRuntimeContext |
| 184 | >()("RuntimeContext") { |
| 185 | static phantom = Layer.empty as Layer.Layer<RuntimeContext>; |
| 186 | } |
| 187 | |
| 188 | export const CurrentRuntimeContext = Effect.serviceOption(RuntimeContext).pipe( |
| 189 | Effect.map(Option.getOrUndefined), |
| 190 | ); |