|
| 1 | +/** |
| 2 | + * Durable fetch — persistent, replay-safe HTTP request. |
| 3 | + * |
| 4 | + * Uses `createDurableOperation` from @effectionx/durable-streams. |
| 5 | + * During live execution, the operation runs and persists a Yield event. |
| 6 | + * During replay, the stored result is returned without executing. |
| 7 | + */ |
| 8 | + |
| 9 | +import { |
| 10 | + type Json, |
| 11 | + type Workflow, |
| 12 | + createDurableOperation, |
| 13 | +} from "@effectionx/durable-streams"; |
| 14 | +import { useScope } from "effection"; |
| 15 | +import { computeSHA256 } from "./hash.ts"; |
| 16 | +import { type DurableRuntime, DurableRuntimeCtx } from "./runtime.ts"; |
| 17 | + |
| 18 | +export interface FetchOptions { |
| 19 | + url: string; |
| 20 | + method?: string; |
| 21 | + headers?: Record<string, string>; |
| 22 | + body?: string; |
| 23 | + timeout?: number; |
| 24 | +} |
| 25 | + |
| 26 | +export interface FetchResult { |
| 27 | + status: number; |
| 28 | + headers: Record<string, string>; |
| 29 | + body: string; |
| 30 | + bodyHash: string; |
| 31 | +} |
| 32 | + |
| 33 | +/** Header names that are safe to record in the journal. */ |
| 34 | +const SAFE_REQUEST_HEADERS = new Set([ |
| 35 | + "content-type", |
| 36 | + "accept", |
| 37 | + "accept-language", |
| 38 | + "cache-control", |
| 39 | + "user-agent", |
| 40 | +]); |
| 41 | + |
| 42 | +/** |
| 43 | + * HTTP request durably. |
| 44 | + * |
| 45 | + * HTTP error status codes (404, 500) are successful effect results — |
| 46 | + * only network failures are effect errors. |
| 47 | + * |
| 48 | + * **Security note**: Only safe request header *names* are recorded in |
| 49 | + * the description — values of sensitive headers (Authorization, Cookie, |
| 50 | + * etc.) are never persisted. A body hash is included in the description |
| 51 | + * when a request body is present, so different payloads to the same URL |
| 52 | + * produce distinct journal entries. |
| 53 | + */ |
| 54 | +export function* durableFetch( |
| 55 | + name: string, |
| 56 | + options: FetchOptions, |
| 57 | +): Workflow<FetchResult> { |
| 58 | + const { url, method = "GET", headers = {}, body, timeout = 30_000 } = options; |
| 59 | + |
| 60 | + // Record only safe header names + values; redact sensitive ones to key-only |
| 61 | + const safeHeaders: Record<string, string> = {}; |
| 62 | + for (const [key, value] of Object.entries(headers)) { |
| 63 | + const lower = key.toLowerCase(); |
| 64 | + if (SAFE_REQUEST_HEADERS.has(lower)) { |
| 65 | + safeHeaders[key] = value; |
| 66 | + } else { |
| 67 | + safeHeaders[key] = "[REDACTED]"; |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + return (yield createDurableOperation<Json>( |
| 72 | + { |
| 73 | + type: "fetch", |
| 74 | + name, |
| 75 | + url, |
| 76 | + method, |
| 77 | + headers: safeHeaders as Json, |
| 78 | + // Include body hash so different payloads produce distinct entries |
| 79 | + ...(body ? { bodyHash: `len:${body.length}` } : {}), |
| 80 | + }, |
| 81 | + function* () { |
| 82 | + const scope = yield* useScope(); |
| 83 | + const runtime = scope.expect<DurableRuntime>(DurableRuntimeCtx); |
| 84 | + |
| 85 | + const response = yield* runtime.fetch(url, { |
| 86 | + method, |
| 87 | + headers, |
| 88 | + body, |
| 89 | + timeout, |
| 90 | + }); |
| 91 | + const responseBody = yield* response.text(); |
| 92 | + const bodyHash = yield* computeSHA256(responseBody); |
| 93 | + |
| 94 | + // Filter response headers to keep only useful ones |
| 95 | + const responseHeaders: Record<string, string> = {}; |
| 96 | + for (const key of [ |
| 97 | + "content-type", |
| 98 | + "etag", |
| 99 | + "last-modified", |
| 100 | + "cache-control", |
| 101 | + ]) { |
| 102 | + const val = response.headers.get(key); |
| 103 | + if (val) responseHeaders[key] = val; |
| 104 | + } |
| 105 | + |
| 106 | + return { |
| 107 | + status: response.status, |
| 108 | + headers: responseHeaders, |
| 109 | + body: responseBody, |
| 110 | + bodyHash, |
| 111 | + } as unknown as Json; |
| 112 | + }, |
| 113 | + )) as FetchResult; |
| 114 | +} |
0 commit comments