@gcoredev/fastedge-test 0.1.7 → 0.2.0

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.
Files changed (40) hide show
  1. package/dist/frontend/assets/{index-BCXfEMSq.js → index-CiqeJ9rz.js} +24 -24
  2. package/dist/frontend/index.html +1 -1
  3. package/dist/lib/index.cjs +130 -62
  4. package/dist/lib/index.d.ts +1 -0
  5. package/dist/lib/index.js +130 -62
  6. package/dist/lib/runner/HeaderManager.d.ts +6 -4
  7. package/dist/lib/runner/HostFunctions.d.ts +5 -5
  8. package/dist/lib/runner/HttpWasmRunner.d.ts +13 -4
  9. package/dist/lib/runner/IStateManager.d.ts +7 -7
  10. package/dist/lib/runner/IWasmRunner.d.ts +17 -9
  11. package/dist/lib/runner/PropertyResolver.d.ts +3 -3
  12. package/dist/lib/runner/ProxyWasmRunner.d.ts +5 -2
  13. package/dist/lib/runner/standalone.d.ts +1 -1
  14. package/dist/lib/runner/types.d.ts +17 -8
  15. package/dist/lib/schemas/api.d.ts +0 -8
  16. package/dist/lib/schemas/config.d.ts +0 -13
  17. package/dist/lib/schemas/index.d.ts +2 -2
  18. package/dist/lib/test-framework/assertions.d.ts +18 -4
  19. package/dist/lib/test-framework/index.cjs +18593 -111
  20. package/dist/lib/test-framework/index.d.ts +2 -0
  21. package/dist/lib/test-framework/index.js +18610 -100
  22. package/dist/lib/test-framework/mock-origins.d.ts +56 -0
  23. package/dist/lib/test-framework/types.d.ts +1 -5
  24. package/dist/server.js +33 -33
  25. package/docs/API.md +19 -49
  26. package/docs/DEBUGGER.md +6 -7
  27. package/docs/INDEX.md +4 -1
  28. package/docs/RUNNER.md +96 -81
  29. package/docs/TEST_CONFIG.md +9 -22
  30. package/docs/TEST_FRAMEWORK.md +206 -31
  31. package/docs/WEBSOCKET.md +25 -21
  32. package/docs/quickstart.md +1 -13
  33. package/package.json +4 -1
  34. package/schemas/api-config.schema.json +0 -24
  35. package/schemas/api-send.schema.json +0 -20
  36. package/schemas/fastedge-config.test.schema.json +0 -24
  37. package/schemas/full-flow-result.schema.json +17 -7
  38. package/schemas/hook-call.schema.json +16 -6
  39. package/schemas/hook-result.schema.json +16 -6
  40. package/schemas/http-response.schema.json +227 -5
package/docs/API.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  The `@gcoredev/fastedge-test` debugger server exposes a REST API for loading WASM modules, executing requests, and managing test configuration.
4
4
 
5
+ > **Note on header values.** Response-side and hook-result headers use `Record<string, string | string[]>` — single-valued headers are a `string`, multi-valued headers (notably `Set-Cookie` per RFC 6265) are a `string[]`. Request-side header inputs are single-valued `Record<string, string>`.
6
+
5
7
  ## Base URL
6
8
 
7
9
  ```
@@ -257,22 +259,16 @@ For **HTTP-WASM**, provide either `path` (preferred) or `url` (legacy). When `pa
257
259
  }
258
260
  ```
259
261
 
260
- For **Proxy-WASM**, the top-level `url` field is required. The full CDN flow is controlled via nested `request`, `response`, and `properties` fields:
262
+ For **Proxy-WASM**, the top-level `url` field is required. The full CDN flow is controlled via nested `request` and `properties` fields. The upstream response is generated at runtime — either by a real fetch against `url`, or by the built-in responder when `url === "built-in"`:
261
263
 
262
264
  ```typescript
263
265
  {
264
- url: string; // Request URL (required)
266
+ url: string; // Request URL, or "built-in" (required)
265
267
  request?: {
266
268
  method?: string; // HTTP method (default: "GET")
267
269
  headers?: Record<string, string>; // Request headers (default: {})
268
270
  body?: string; // Request body (default: "")
269
271
  };
270
- response?: {
271
- headers?: Record<string, string>; // Simulated upstream response headers (default: {})
272
- body?: string; // Simulated upstream response body (default: "")
273
- status?: number; // Simulated upstream response status (default: 200)
274
- statusText?: string; // Simulated upstream response status text (default: "OK")
275
- };
276
272
  properties?: Record<string, unknown>; // CDN properties (default: {})
277
273
  }
278
274
  ```
@@ -285,7 +281,7 @@ For **Proxy-WASM**, the top-level `url` field is required. The full CDN flow is
285
281
  result: {
286
282
  status: number;
287
283
  statusText: string;
288
- headers: Record<string, string>;
284
+ headers: Record<string, string | string[]>;
289
285
  body: string;
290
286
  contentType: string | null;
291
287
  isBase64?: boolean;
@@ -303,7 +299,7 @@ For **Proxy-WASM**, the top-level `url` field is required. The full CDN flow is
303
299
  finalResponse: {
304
300
  status: number;
305
301
  statusText: string;
306
- headers: Record<string, string>;
302
+ headers: Record<string, string | string[]>;
307
303
  body: string;
308
304
  contentType: string;
309
305
  isBase64?: boolean;
@@ -319,13 +315,13 @@ type HookResult = {
319
315
  returnCode: number | null;
320
316
  logs: Array<{ level: number; message: string }>;
321
317
  input: {
322
- request: { headers: Record<string, string>; body: string };
323
- response: { headers: Record<string, string>; body: string };
318
+ request: { headers: Record<string, string | string[]>; body: string };
319
+ response: { headers: Record<string, string | string[]>; body: string };
324
320
  properties?: Record<string, unknown>;
325
321
  };
326
322
  output: {
327
- request: { headers: Record<string, string>; body: string };
328
- response: { headers: Record<string, string>; body: string };
323
+ request: { headers: Record<string, string | string[]>; body: string };
324
+ response: { headers: Record<string, string | string[]>; body: string };
329
325
  properties?: Record<string, unknown>;
330
326
  };
331
327
  properties: Record<string, unknown>;
@@ -377,12 +373,6 @@ curl -X POST http://localhost:5179/api/execute \
377
373
  "headers": { "host": "example.com" },
378
374
  "body": ""
379
375
  },
380
- "response": {
381
- "headers": { "content-type": "text/html" },
382
- "body": "<html/>",
383
- "status": 200,
384
- "statusText": "OK"
385
- },
386
376
  "properties": {}
387
377
  }'
388
378
  ```
@@ -475,13 +465,13 @@ type HookResult = {
475
465
  returnCode: number | null;
476
466
  logs: Array<{ level: number; message: string }>;
477
467
  input: {
478
- request: { headers: Record<string, string>; body: string };
479
- response: { headers: Record<string, string>; body: string };
468
+ request: { headers: Record<string, string | string[]>; body: string };
469
+ response: { headers: Record<string, string | string[]>; body: string };
480
470
  properties?: Record<string, unknown>;
481
471
  };
482
472
  output: {
483
- request: { headers: Record<string, string>; body: string };
484
- response: { headers: Record<string, string>; body: string };
473
+ request: { headers: Record<string, string | string[]>; body: string };
474
+ response: { headers: Record<string, string | string[]>; body: string };
485
475
  properties?: Record<string, unknown>;
486
476
  };
487
477
  properties: Record<string, unknown>;
@@ -556,22 +546,18 @@ Requires a WASM module to be loaded via `POST /api/load`. Accepts an optional [`
556
546
 
557
547
  ```typescript
558
548
  {
559
- url: string | "built-in"; // Full request URL, or "built-in" to use the URL from loaded config
549
+ url: string | "built-in"; // Full request URL, or "built-in" to use the built-in responder
560
550
  request?: {
561
551
  method?: string; // HTTP method (default: "GET")
562
552
  url?: string;
563
553
  headers?: Record<string, string>; // Request headers (default: {})
564
554
  body?: string; // Request body (default: "")
565
555
  };
566
- response?: {
567
- headers?: Record<string, string>; // Simulated upstream response headers (default: {})
568
- body?: string; // Simulated upstream response body (default: "")
569
- };
570
556
  properties: Record<string, unknown>; // CDN properties (required; use {} if none)
571
557
  }
572
558
  ```
573
559
 
574
- The `response` object for this endpoint does not accept `status` or `statusText` the full flow always uses `200 OK` as the simulated upstream status. Use `POST /api/execute` if you need to control those values.
560
+ The upstream response is generated at runtimeeither by a real fetch against `url`, or by the built-in responder when `url === "built-in"`.
575
561
 
576
562
  **Response**
577
563
 
@@ -582,7 +568,7 @@ The `response` object for this endpoint does not accept `status` or `statusText`
582
568
  finalResponse: {
583
569
  status: number;
584
570
  statusText: string;
585
- headers: Record<string, string>;
571
+ headers: Record<string, string | string[]>;
586
572
  body: string;
587
573
  contentType: string;
588
574
  isBase64?: boolean;
@@ -606,10 +592,6 @@ curl -X POST http://localhost:5179/api/send \
606
592
  "headers": { "content-type": "application/json" },
607
593
  "body": "{\"key\":\"value\"}"
608
594
  },
609
- "response": {
610
- "headers": { "content-type": "application/json" },
611
- "body": "{\"result\":\"ok\"}"
612
- },
613
595
  "properties": {
614
596
  "client.geo.country": "DE"
615
597
  }
@@ -670,8 +652,8 @@ curl -X POST http://localhost:5179/api/send \
670
652
 
671
653
  **Error Responses**
672
654
 
673
- | Status | Condition |
674
- | ------ | --------------------------------------------------------------------------- |
655
+ | Status | Condition |
656
+ | ------ | ---------------------------------------------------------------------------- |
675
657
  | `400` | Validation failed (missing `url` or `properties`), or no WASM module loaded |
676
658
  | `500` | Execution failed |
677
659
 
@@ -712,10 +694,6 @@ type ProxyWasmConfig = {
712
694
  headers: Record<string, string>;
713
695
  body: string;
714
696
  };
715
- response?: {
716
- headers: Record<string, string>;
717
- body: string;
718
- };
719
697
  properties: Record<string, unknown>;
720
698
  dotenv?: { enabled?: boolean; path?: string };
721
699
  };
@@ -758,10 +736,6 @@ curl http://localhost:5179/api/config
758
736
  "headers": {},
759
737
  "body": ""
760
738
  },
761
- "response": {
762
- "headers": {},
763
- "body": ""
764
- },
765
739
  "properties": {}
766
740
  },
767
741
  "valid": true
@@ -816,10 +790,6 @@ curl -X POST http://localhost:5179/api/config \
816
790
  "headers": { "accept": "text/html" },
817
791
  "body": ""
818
792
  },
819
- "response": {
820
- "headers": {},
821
- "body": ""
822
- },
823
793
  "properties": {
824
794
  "client.geo.country": "US"
825
795
  }
package/docs/DEBUGGER.md CHANGED
@@ -98,13 +98,12 @@ curl http://localhost:5179/health
98
98
 
99
99
  ## Environment Variables
100
100
 
101
- | Variable | Type | Default | Description |
102
- | -------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
103
- | `PORT` | `number` | unset | Port the HTTP server listens on. Defaults to `5179` when not set. |
104
- | `PROXY_RUNNER_DEBUG` | `"1"` | unset | Enable verbose debug logging for WebSocket and runner activity. |
105
- | `VSCODE_INTEGRATION` | `"true"` | unset | Set to `"true"` when running in VSCode extension context; enables the `<workspace>` path placeholder in WASM path loading. |
106
- | `WORKSPACE_PATH` | `string` | unset | Absolute path to the workspace root; used as the `.env` file base and for port file placement. |
107
- | `FASTEDGE_RUN_PATH` | `string` | unset | Override the path to the `fastedge-run` CLI binary used to execute WASM modules. |
101
+ | Variable | Type | Default | Description |
102
+ | -------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------- |
103
+ | `PORT` | `number` | unset | Port the HTTP server listens on. Defaults to `5179` when not set. |
104
+ | `PROXY_RUNNER_DEBUG` | `"1"` | unset | Enable verbose debug logging for WebSocket and runner activity. |
105
+ | `WORKSPACE_PATH` | `string` | unset | Absolute path to the workspace root; used as the `.env` file base and for port file placement. |
106
+ | `FASTEDGE_RUN_PATH` | `string` | unset | Override the path to the `fastedge-run` CLI binary used to execute WASM modules. |
108
107
 
109
108
  ### Usage examples
110
109
 
package/docs/INDEX.md CHANGED
@@ -79,12 +79,13 @@ import { defineTestSuite, runAndExit } from "@gcoredev/fastedge-test/test";
79
79
  | `runFlow` | function | Executes a single request flow directly |
80
80
  | `runHttpRequest` | function | Executes a single HTTP request directly |
81
81
  | `loadConfigFile` | function | Loads and validates a `fastedge-config.test.json` file |
82
+ | `mockOrigins` | function | Creates mock HTTP origins that respond to outgoing HTTP calls |
82
83
  | `assertRequestHeader` | function | Asserts a header is present on the outgoing request |
83
84
  | `assertNoRequestHeader` | function | Asserts a header is absent from the outgoing request |
84
85
  | `assertResponseHeader` | function | Asserts a header is present on the final response |
85
86
  | `assertNoResponseHeader` | function | Asserts a header is absent from the final response |
86
87
  | `assertFinalStatus` | function | Asserts the final HTTP status code |
87
- | `assertFinalHeader` | function | Asserts a header on the final response (alias for response header) |
88
+ | `assertFinalHeader` | function | Asserts a header on the final response |
88
89
  | `assertReturnCode` | function | Asserts the proxy-wasm return code |
89
90
  | `assertLog` | function | Asserts a log entry was emitted |
90
91
  | `assertNoLog` | function | Asserts a log entry was not emitted |
@@ -101,6 +102,8 @@ import { defineTestSuite, runAndExit } from "@gcoredev/fastedge-test/test";
101
102
  | `assertHttpContentType` | function | Asserts the HTTP response Content-Type header |
102
103
  | `assertHttpLog` | function | Asserts a log entry was emitted during HTTP request handling |
103
104
  | `assertHttpNoLog` | function | Asserts a log entry was not emitted during HTTP request handling |
105
+ | `MockOriginsHandle` | type | Handle returned by `mockOrigins` — use to stop the mock servers |
106
+ | `MockOriginsOptions` | type | Options accepted by `mockOrigins` |
104
107
  | `TestSuite` | type | Suite definition — one of `wasmPath` or `wasmBuffer` plus test cases |
105
108
  | `TestCase` | type | A single test scenario with config and assertions |
106
109
  | `TestResult` | type | Result of a single test case execution |
package/docs/RUNNER.md CHANGED
@@ -102,10 +102,6 @@ interface IWasmRunner {
102
102
  method: string,
103
103
  headers: Record<string, string>,
104
104
  body: string,
105
- responseHeaders: Record<string, string>,
106
- responseBody: string,
107
- responseStatus: number,
108
- responseStatusText: string,
109
105
  properties: Record<string, unknown>,
110
106
  enforceProductionPropertyRules: boolean
111
107
  ): Promise<FullFlowResult>;
@@ -161,7 +157,7 @@ const runner = await createRunner('./my-http-app.wasm');
161
157
  let response = await runner.execute({ path: '/moved', method: 'GET', headers: {} });
162
158
  if (response.status >= 300 && response.status < 400 && response.headers['location']) {
163
159
  response = await runner.execute({
164
- path: response.headers['location'], // e.g. "/new-location"
160
+ path: response.headers['location'] as string, // e.g. "/new-location"
165
161
  method: 'GET',
166
162
  headers: {},
167
163
  });
@@ -192,10 +188,6 @@ callFullFlow(
192
188
  method: string,
193
189
  headers: Record<string, string>,
194
190
  body: string,
195
- responseHeaders: Record<string, string>,
196
- responseBody: string,
197
- responseStatus: number,
198
- responseStatusText: string,
199
191
  properties: Record<string, unknown>,
200
192
  enforceProductionPropertyRules: boolean
201
193
  ): Promise<FullFlowResult>
@@ -203,22 +195,20 @@ callFullFlow(
203
195
 
204
196
  **Parameters**
205
197
 
206
- | Parameter | Type | Description |
207
- | -------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- |
208
- | `url` | `string` | Full request URL, or `BUILTIN_SHORTHAND` (`"built-in"`) to use the built-in responder instead of a real origin fetch |
209
- | `method` | `string` | HTTP method |
210
- | `headers` | `Record<string, string>` | Request headers |
211
- | `body` | `string` | Request body |
212
- | `responseHeaders` | `Record<string, string>` | Upstream response headers (used as initial state for response hooks) |
213
- | `responseBody` | `string` | Upstream response body |
214
- | `responseStatus` | `number` | Upstream response status code |
215
- | `responseStatusText` | `string` | Upstream response status text |
216
- | `properties` | `Record<string, unknown>` | Shared properties passed to all hooks |
217
- | `enforceProductionPropertyRules` | `boolean` | When `true`, restricts property access to match CDN production behavior |
198
+ | Parameter | Type | Description |
199
+ | -------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
200
+ | `url` | `string` | Full request URL, or `BUILTIN_SHORTHAND` (`"built-in"`) to use the built-in responder instead of a real origin fetch |
201
+ | `method` | `string` | HTTP method |
202
+ | `headers` | `Record<string, string>` | Request headers |
203
+ | `body` | `string` | Request body |
204
+ | `properties` | `Record<string, unknown>` | Shared properties passed to all hooks |
205
+ | `enforceProductionPropertyRules` | `boolean` | When `true`, restricts property access to match CDN production behavior |
206
+
207
+ The upstream response is generated at runtime — either by a real HTTP fetch against `url`, or by the built-in responder when `url === "built-in"`. There is no caller-provided mock response.
218
208
 
219
209
  Hook execution order: `onRequestHeaders` → `onRequestBody` → *(real HTTP fetch or built-in responder)* → `onResponseHeaders` → `onResponseBody`.
220
210
 
221
- **Local response short-circuit:** If a WASM module calls `send_http_response` (proxy-wasm: `proxy_send_local_response`) during `onRequestHeaders` or `onRequestBody` and returns `StopIteration` (return code `1`), the remaining hooks and origin fetch are **skipped**. The `finalResponse` in the result is built from the locally-sent status, headers, and body — matching CDN production behavior. This is how redirect modules (e.g., geo-redirect) and early error responses work.
211
+ **Local response short-circuit.** If a WASM module calls `proxy_send_local_response` during `onRequestHeaders` or `onRequestBody` and returns `StopIteration` (return code `1`), the remaining hooks and origin fetch are skipped. The `finalResponse` in the result is built from the locally-sent status, headers, and body — matching CDN production behavior. This is how redirect modules (e.g., geo-redirect) and early error responses work.
222
212
 
223
213
  Only available on `ProxyWasmRunner`. Calling on `HttpWasmRunner` throws.
224
214
 
@@ -314,16 +304,17 @@ const result: FullFlowResult = await runner.callFullFlow(
314
304
  'GET',
315
305
  { 'accept': 'application/json' },
316
306
  '',
317
- {}, '', 200, 'OK', {}, true,
307
+ {}, // properties
308
+ true, // enforceProductionPropertyRules
318
309
  );
319
310
  ```
320
311
 
321
312
  **Built-in responder behavior** — controlled by request headers set before the origin phase:
322
313
 
323
- | Header | Effect |
324
- | -------------------- | ------------------------------------------------------------------------------- |
325
- | `x-debugger-status` | HTTP status code for the generated response (default: `200`) |
326
- | `x-debugger-content` | Response body mode: `"body-only"`, `"status-only"`, or full JSON echo (default) |
314
+ | Header | Effect |
315
+ | -------------------- | -------------------------------------------------------------------------------- |
316
+ | `x-debugger-status` | HTTP status code for the generated response (default: `200`) |
317
+ | `x-debugger-content` | Response body mode: `"body-only"`, `"status-only"`, or full JSON echo (default) |
327
318
 
328
319
  When `x-debugger-content` is omitted, the built-in responder returns a JSON echo of the request method, headers, body, and URL. Both control headers are stripped before response hooks execute so they do not appear in hook input state.
329
320
 
@@ -345,13 +336,13 @@ interface RunnerConfig {
345
336
  }
346
337
  ```
347
338
 
348
- | Field | Type | Default | Description |
349
- | -------------------------------- | ---------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
350
- | `dotenv.enabled` | `boolean` | `false` | Whether to load `.env` files |
351
- | `dotenv.path` | `string` | `undefined` | Directory to load dotenv files from. When omitted, `fastedge-run` uses the process CWD — correct for most npm package users whose `.env` files live at the project root. Only set this when your dotenv files are in a non-standard location (e.g. a test fixture directory). |
352
- | `enforceProductionPropertyRules` | `boolean` | `true` | Restrict property access to match CDN production behavior |
353
- | `runnerType` | `WasmType` | auto-detected | Override WASM type detection |
354
- | `httpPort` | `number` | `undefined` | HTTP-WASM only. Pin the spawned `fastedge-run` subprocess to a specific port instead of allocating from the dynamic pool (8100–8199). `load()` throws if the port is busy — there is no fallback to dynamic allocation. Intended for Codespaces/Docker port-forwarding or external tooling requiring a fixed address. Ignored for proxy-wasm runners. |
339
+ | Field | Type | Default | Description |
340
+ | -------------------------------- | ---------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
341
+ | `dotenv.enabled` | `boolean` | `false` | Whether to load `.env` files |
342
+ | `dotenv.path` | `string` | `undefined` | Directory to load dotenv files from. When omitted, `fastedge-run` uses the process CWD — correct for most npm package users whose `.env` files live at the project root. Only set this when your dotenv files are in a non-standard location (e.g. a test fixture directory). |
343
+ | `enforceProductionPropertyRules` | `boolean` | `true` | Restrict property access to match CDN production behavior |
344
+ | `runnerType` | `WasmType` | *auto-detected* | Override WASM type detection |
345
+ | `httpPort` | `number` | `undefined` | HTTP-WASM only. Pin the spawned `fastedge-run` subprocess to a specific port instead of allocating from the dynamic pool (8100–8199). `load()` throws if the port is busy — there is no fallback to dynamic allocation. Intended for Codespaces/Docker port-forwarding or external tooling requiring a fixed address. Ignored for proxy-wasm. |
355
346
 
356
347
  ### HttpRequest & HttpResponse
357
348
 
@@ -366,7 +357,11 @@ interface HttpRequest {
366
357
  interface HttpResponse {
367
358
  status: number;
368
359
  statusText: string;
369
- headers: Record<string, string>;
360
+ // Node's IncomingHttpHeaders (from "node:http"):
361
+ // known single-valued headers (content-type, location, etag, …) are typed as string
362
+ // set-cookie is always string[] when present
363
+ // unknown headers are string | string[] | undefined
364
+ headers: IncomingHttpHeaders;
370
365
  body: string;
371
366
  contentType: string | null;
372
367
  isBase64?: boolean;
@@ -380,20 +375,37 @@ interface HttpResponse {
380
375
 
381
376
  `HttpResponse.logs` contains log entries captured from the `fastedge-run` process stdout/stderr during the request.
382
377
 
378
+ **Multi-valued headers.** `Set-Cookie` is preserved as a `string[]` — each `Set-Cookie` header emitted by the WASM app (or an upstream origin) becomes a separate array entry. This matches RFC 6265 §3 and Node's fetch behavior. Example:
379
+
380
+ ```typescript
381
+ const response = await runner.execute({ path: '/login', method: 'POST', headers: {} });
382
+ const cookies = response.headers['set-cookie']; // string[] | undefined
383
+ for (const cookie of cookies ?? []) {
384
+ console.log(cookie);
385
+ }
386
+ ```
387
+
388
+ Single-valued headers read as plain strings with no narrowing needed:
389
+
390
+ ```typescript
391
+ const location = response.headers['location']; // string | undefined
392
+ const contentType = response.headers['content-type']; // string | undefined
393
+ ```
394
+
383
395
  ### HookCall
384
396
 
385
397
  ```typescript
386
398
  type HookCall = {
387
399
  hook: string;
388
400
  request: {
389
- headers: HeaderMap;
401
+ headers: HeaderRecord;
390
402
  body: string;
391
403
  method?: string;
392
404
  path?: string;
393
405
  scheme?: string;
394
406
  };
395
- response: {
396
- headers: HeaderMap;
407
+ response?: {
408
+ headers: HeaderRecord;
397
409
  body: string;
398
410
  status?: number;
399
411
  statusText?: string;
@@ -404,14 +416,16 @@ type HookCall = {
404
416
  };
405
417
  ```
406
418
 
407
- | Field | Description |
408
- | -------------------------------- | --------------------------------------------------------------------------------------------------- |
409
- | `hook` | Hook name: `"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"` |
410
- | `request` | Request state passed to the hook |
411
- | `response` | Response state passed to the hook |
412
- | `properties` | Shared properties (e.g. `request.path`, `vm_config`, `plugin_config`) |
413
- | `dotenvEnabled` | Optional per-call dotenv override. Use `applyDotenv()` for persistent changes. |
414
- | `enforceProductionPropertyRules` | Defaults to `true`. Set to `false` to allow property reads that would be blocked on production CDN. |
419
+ | Field | Description |
420
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
421
+ | `hook` | Hook name: `"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"` |
422
+ | `request` | Request state passed to the hook |
423
+ | `response` | Seed state for response hooks called via `callHook()`. Ignored by `callFullFlow()` and by request hooks — the full-flow path generates the upstream response at runtime. |
424
+ | `properties` | Shared properties (e.g. `request.path`, `vm_config`, `plugin_config`) |
425
+ | `dotenvEnabled` | Optional per-call dotenv override. Use `applyDotenv()` for persistent changes. |
426
+ | `enforceProductionPropertyRules` | Defaults to `true`. Set to `false` to allow property reads that would be blocked on production CDN. |
427
+
428
+ `HeaderRecord` is `Record<string, string | string[]>` — multi-valued headers (e.g. multiple `Set-Cookie`) are represented as `string[]`.
415
429
 
416
430
  ### HookResult
417
431
 
@@ -420,26 +434,26 @@ type HookResult = {
420
434
  returnCode: number | null;
421
435
  logs: { level: number; message: string }[];
422
436
  input: {
423
- request: { headers: HeaderMap; body: string };
424
- response: { headers: HeaderMap; body: string };
437
+ request: { headers: HeaderRecord; body: string };
438
+ response: { headers: HeaderRecord; body: string };
425
439
  properties?: Record<string, unknown>;
426
440
  };
427
441
  output: {
428
- request: { headers: HeaderMap; body: string };
429
- response: { headers: HeaderMap; body: string };
442
+ request: { headers: HeaderRecord; body: string };
443
+ response: { headers: HeaderRecord; body: string };
430
444
  properties?: Record<string, unknown>;
431
445
  };
432
446
  properties: Record<string, unknown>;
433
447
  };
434
448
  ```
435
449
 
436
- | Field | Description |
437
- | ------------ | ------------------------------------------------------------------------------------------ |
438
- | `returnCode` | The numeric value returned by the WASM hook export, or `null` if the export was not found |
439
- | `logs` | Log entries emitted via `proxy_log` during hook execution |
440
- | `input` | Request/response state as seen by the hook before execution |
441
- | `output` | Request/response state after hook execution (reflects WASM mutations) |
442
- | `properties` | All shared properties after hook execution |
450
+ | Field | Description |
451
+ | ------------ | ------------------------------------------------------------------------------------------- |
452
+ | `returnCode` | The numeric value returned by the WASM hook export, or `null` if the export was not found |
453
+ | `logs` | Log entries emitted via `proxy_log` during hook execution |
454
+ | `input` | Request/response state as seen by the hook before execution |
455
+ | `output` | Request/response state after hook execution (reflects WASM mutations) |
456
+ | `properties` | All shared properties after hook execution |
443
457
 
444
458
  ### FullFlowResult
445
459
 
@@ -449,7 +463,7 @@ type FullFlowResult = {
449
463
  finalResponse: {
450
464
  status: number;
451
465
  statusText: string;
452
- headers: HeaderMap;
466
+ headers: HeaderRecord;
453
467
  body: string;
454
468
  contentType: string;
455
469
  isBase64?: boolean;
@@ -458,19 +472,23 @@ type FullFlowResult = {
458
472
  };
459
473
  ```
460
474
 
461
- | Field | Description |
462
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
463
- | `hookResults` | A `Record` keyed by hook name (`"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"`), each containing a `HookResult` |
464
- | `finalResponse` | The final response after all hooks have executed, or the local response if a hook short-circuited (see `callFullFlow`). `body` is base64-encoded when `isBase64` is `true`. |
465
- | `calculatedProperties` | Runtime properties computed from the request URL (e.g. `request.path`, `request.host`) |
475
+ | Field | Description |
476
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
477
+ | `hookResults` | A `Record` keyed by hook name (`"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"`), each containing a `HookResult` |
478
+ | `finalResponse` | The final response after all hooks have executed, or the local response if a hook short-circuited (see `callFullFlow`). `body` is base64-encoded when `isBase64` is `true`. |
479
+ | `calculatedProperties` | Runtime properties computed from the request URL (e.g. `request.path`, `request.host`) |
466
480
 
467
481
  ### Supporting Types
468
482
 
469
483
  ```typescript
470
484
  type WasmType = 'http-wasm' | 'proxy-wasm';
471
485
 
486
+ // Single-valued headers only — used as callFullFlow input parameters
472
487
  type HeaderMap = Record<string, string>;
473
488
 
489
+ // Single- or multi-valued headers — used in HookCall, HookResult, and FullFlowResult
490
+ type HeaderRecord = Record<string, string | string[]>;
491
+
474
492
  type LogEntry = {
475
493
  level: number;
476
494
  message: string;
@@ -498,7 +516,7 @@ interface IStateManager {
498
516
  emitRequestStarted(
499
517
  url: string,
500
518
  method: string,
501
- headers: Record<string, string>,
519
+ headers: Record<string, string | string[]>,
502
520
  source?: EventSource,
503
521
  ): void;
504
522
 
@@ -507,12 +525,12 @@ interface IStateManager {
507
525
  returnCode: number | null,
508
526
  logCount: number,
509
527
  input: {
510
- request: { headers: Record<string, string>; body: string };
511
- response: { headers: Record<string, string>; body: string };
528
+ request: { headers: Record<string, string | string[]>; body: string };
529
+ response: { headers: Record<string, string | string[]>; body: string };
512
530
  },
513
531
  output: {
514
- request: { headers: Record<string, string>; body: string };
515
- response: { headers: Record<string, string>; body: string };
532
+ request: { headers: Record<string, string | string[]>; body: string };
533
+ response: { headers: Record<string, string | string[]>; body: string };
516
534
  },
517
535
  source?: EventSource,
518
536
  ): void;
@@ -522,7 +540,7 @@ interface IStateManager {
522
540
  finalResponse: {
523
541
  status: number;
524
542
  statusText: string;
525
- headers: Record<string, string>;
543
+ headers: Record<string, string | string[]>;
526
544
  body: string;
527
545
  contentType: string;
528
546
  isBase64?: boolean;
@@ -544,7 +562,7 @@ interface IStateManager {
544
562
  response: {
545
563
  status: number;
546
564
  statusText: string;
547
- headers: Record<string, string>;
565
+ headers: Record<string, string | string[] | undefined>;
548
566
  body: string;
549
567
  contentType: string | null;
550
568
  isBase64?: boolean;
@@ -575,19 +593,15 @@ async function testCdnApp() {
575
593
  try {
576
594
  // Execute the full CDN request/response lifecycle
577
595
  const result: FullFlowResult = await runner.callFullFlow(
578
- 'https://example.com/api/data', // request URL
579
- 'GET', // method
580
- { 'accept': 'application/json' }, // request headers
581
- '', // request body
582
- { 'content-type': 'application/json' }, // upstream response headers
583
- '{"key":"value"}', // upstream response body
584
- 200, // upstream response status
585
- 'OK', // upstream response status text
596
+ 'https://example.com/api/data', // request URL
597
+ 'GET', // method
598
+ { 'accept': 'application/json' }, // request headers
599
+ '', // request body
586
600
  {
587
601
  'request.path': '/api/data',
588
602
  'request.host': 'example.com',
589
- }, // shared properties
590
- true, // enforce production property rules
603
+ }, // shared properties
604
+ true, // enforce production property rules
591
605
  );
592
606
 
593
607
  // Inspect hook results
@@ -647,7 +661,8 @@ async function testCdnAppOffline() {
647
661
  'GET',
648
662
  { 'accept': 'application/json' },
649
663
  '',
650
- {}, '', 200, 'OK', {}, true,
664
+ {}, // properties
665
+ true, // enforceProductionPropertyRules
651
666
  );
652
667
 
653
668
  console.log('Final status:', result.finalResponse.status);