@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/dist/lib/index.js CHANGED
@@ -175,10 +175,36 @@ var MemoryManager = class {
175
175
  // server/runner/HeaderManager.ts
176
176
  var textEncoder2 = new TextEncoder();
177
177
  var HeaderManager = class {
178
+ // Read a header value as a single string. For multi-valued headers (string[]) returns the first.
179
+ // Use when callers know the header is conventionally single-valued (content-type, host, location, etc.)
180
+ // and need to satisfy APIs that take a string.
181
+ static firstValue(v) {
182
+ return Array.isArray(v) ? v[0] : v;
183
+ }
184
+ // Flatten a HeaderRecord to a HeaderMap (single string per key) for consumers
185
+ // that can't handle multi-valued headers (e.g. fetch's HeadersInit).
186
+ // Multi-valued entries are joined with ", " — caller must be sure this is acceptable
187
+ // (NOT valid for Set-Cookie; route those through a separate channel).
188
+ static flattenToMap(headers) {
189
+ const flat = {};
190
+ for (const [k, v] of Object.entries(headers)) {
191
+ if (Array.isArray(v)) {
192
+ flat[k] = v.join(", ");
193
+ } else if (v !== void 0) {
194
+ flat[k] = String(v);
195
+ }
196
+ }
197
+ return flat;
198
+ }
178
199
  static normalize(headers) {
179
200
  const normalized = {};
180
201
  for (const [key, value] of Object.entries(headers)) {
181
- normalized[key.toLowerCase()] = String(value);
202
+ const k = key.toLowerCase();
203
+ if (Array.isArray(value)) {
204
+ normalized[k] = value.map(String);
205
+ } else {
206
+ normalized[k] = String(value);
207
+ }
182
208
  }
183
209
  return normalized;
184
210
  }
@@ -257,13 +283,31 @@ var HeaderManager = class {
257
283
  }
258
284
  // --- Tuple-based methods for multi-valued header support ---
259
285
  static recordToTuples(headers) {
260
- return Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v)]);
286
+ const tuples = [];
287
+ for (const [k, v] of Object.entries(headers)) {
288
+ const key = k.toLowerCase();
289
+ if (Array.isArray(v)) {
290
+ for (const val of v) tuples.push([key, String(val)]);
291
+ } else if (v !== void 0) {
292
+ tuples.push([key, String(v)]);
293
+ }
294
+ }
295
+ return tuples;
261
296
  }
297
+ // Lossless projection of tuples to a Record: single-valued keys are string,
298
+ // multi-valued keys are string[] — matching Node's IncomingHttpHeaders shape.
299
+ // Set-Cookie and other legitimately-repeatable headers are preserved across duplicates.
262
300
  static tuplesToRecord(tuples) {
263
301
  const record = {};
264
302
  for (const [key, value] of tuples) {
265
303
  const existing = record[key];
266
- record[key] = existing !== void 0 ? `${existing},${value}` : value;
304
+ if (existing === void 0) {
305
+ record[key] = value;
306
+ } else if (Array.isArray(existing)) {
307
+ existing.push(value);
308
+ } else {
309
+ record[key] = [existing, value];
310
+ }
267
311
  }
268
312
  return record;
269
313
  }
@@ -409,10 +453,11 @@ var PropertyResolver = class {
409
453
  const url = new URL(targetUrl);
410
454
  this.requestUrl = targetUrl;
411
455
  this.requestHost = url.hostname + (url.port ? `:${url.port}` : "");
412
- this.requestPath = url.pathname || "/";
456
+ this.requestPath = (url.pathname || "/") + url.search;
413
457
  this.requestQuery = url.search.startsWith("?") ? url.search.substring(1) : url.search;
414
458
  this.requestScheme = url.protocol.replace(":", "");
415
- const pathParts = this.requestPath.split("/");
459
+ const pathOnly = url.pathname || "/";
460
+ const pathParts = pathOnly.split("/");
416
461
  const lastPart = pathParts[pathParts.length - 1];
417
462
  const dotIndex = lastPart.lastIndexOf(".");
418
463
  if (dotIndex > 0 && dotIndex < lastPart.length - 1) {
@@ -482,28 +527,28 @@ var PropertyResolver = class {
482
527
  if (path2 === "request.url")
483
528
  return this.requestUrl || `${this.requestScheme}://${this.requestHost}${this.requestPath}`;
484
529
  if (path2 === "request.host")
485
- return this.requestHost || this.requestHeaders["host"] || "localhost";
530
+ return this.requestHost || HeaderManager.firstValue(this.requestHeaders["host"]) || "localhost";
486
531
  if (path2 === "request.scheme") return this.requestScheme;
487
532
  if (path2 === "request.protocol") return this.requestScheme;
488
533
  if (path2 === "request.query") return this.requestQuery;
489
534
  if (path2 === "request.extension") return this.requestExtension;
490
535
  if (path2 === "request.content_type") {
491
- return this.requestHeaders["content-type"] || "";
536
+ return HeaderManager.firstValue(this.requestHeaders["content-type"]) || "";
492
537
  }
493
538
  if (path2.startsWith("request.headers.")) {
494
539
  const headerName = path2.substring("request.headers.".length).toLowerCase();
495
- return this.requestHeaders[headerName] || "";
540
+ return HeaderManager.firstValue(this.requestHeaders[headerName]) || "";
496
541
  }
497
542
  if (path2 === "response.code") return this.responseStatus;
498
543
  if (path2 === "response.status") return this.responseStatus;
499
544
  if (path2 === "response.status_code") return this.responseStatus;
500
545
  if (path2 === "response.code_details") return this.responseStatusText;
501
546
  if (path2 === "response.content_type") {
502
- return this.responseHeaders["content-type"] || "";
547
+ return HeaderManager.firstValue(this.responseHeaders["content-type"]) || "";
503
548
  }
504
549
  if (path2.startsWith("response.headers.")) {
505
550
  const headerName = path2.substring("response.headers.".length).toLowerCase();
506
- return this.responseHeaders[headerName] || "";
551
+ return HeaderManager.firstValue(this.responseHeaders[headerName]) || "";
507
552
  }
508
553
  return void 0;
509
554
  }
@@ -858,7 +903,8 @@ var HostFunctions = class {
858
903
  return call;
859
904
  }
860
905
  setHttpCallResponse(tokenId, headers, body) {
861
- this.httpCallResponse = { tokenId, headers, body };
906
+ const tuples = Array.isArray(headers) ? headers : HeaderManager.recordToTuples(headers);
907
+ this.httpCallResponse = { tokenId, headers: tuples, body };
862
908
  }
863
909
  clearHttpCallResponse() {
864
910
  this.httpCallResponse = null;
@@ -1288,7 +1334,7 @@ var HostFunctions = class {
1288
1334
  return this.responseHeaders;
1289
1335
  }
1290
1336
  if (mapType === 6 /* HttpCallResponseHeaders */ || mapType === 7 /* HttpCallResponseTrailers */) {
1291
- return HeaderManager.recordToTuples(this.httpCallResponse?.headers ?? {});
1337
+ return this.httpCallResponse?.headers ?? [];
1292
1338
  }
1293
1339
  return this.requestHeaders;
1294
1340
  }
@@ -1948,7 +1994,7 @@ var ProxyWasmRunner = class {
1948
1994
  const local = this.hostFunctions.getLocalResponse();
1949
1995
  const responseHeaders2 = results.onRequestHeaders.output.response.headers;
1950
1996
  this.hostFunctions.resetLocalResponse();
1951
- const contentType2 = responseHeaders2["content-type"] || "text/plain";
1997
+ const contentType2 = HeaderManager.firstValue(responseHeaders2["content-type"]) || "text/plain";
1952
1998
  const { body, isBase64: isBase642 } = encodeLocalResponseBody(local.body, contentType2);
1953
1999
  return {
1954
2000
  hookResults: results,
@@ -1991,7 +2037,7 @@ var ProxyWasmRunner = class {
1991
2037
  const local = this.hostFunctions.getLocalResponse();
1992
2038
  const responseHeaders2 = results.onRequestBody.output.response.headers;
1993
2039
  this.hostFunctions.resetLocalResponse();
1994
- const contentType2 = responseHeaders2["content-type"] || "text/plain";
2040
+ const contentType2 = HeaderManager.firstValue(responseHeaders2["content-type"]) || "text/plain";
1995
2041
  const { body, isBase64: isBase642 } = encodeLocalResponseBody(local.body, contentType2);
1996
2042
  return {
1997
2043
  hookResults: results,
@@ -2022,7 +2068,7 @@ var ProxyWasmRunner = class {
2022
2068
  try {
2023
2069
  if (isBuiltIn) {
2024
2070
  this.logDebug("Using built-in responder");
2025
- const rawStatus = (modifiedRequestHeaders["x-debugger-status"] || "").trim();
2071
+ const rawStatus = (HeaderManager.firstValue(modifiedRequestHeaders["x-debugger-status"]) || "").trim();
2026
2072
  if (rawStatus === "") {
2027
2073
  responseStatus = 200;
2028
2074
  } else {
@@ -2033,7 +2079,7 @@ var ProxyWasmRunner = class {
2033
2079
  }
2034
2080
  }
2035
2081
  responseStatusText = responseStatus === 200 ? "OK" : String(responseStatus);
2036
- const responseContentMode = modifiedRequestHeaders["x-debugger-content"] || "";
2082
+ const responseContentMode = HeaderManager.firstValue(modifiedRequestHeaders["x-debugger-content"]) || "";
2037
2083
  delete modifiedRequestHeaders["x-debugger-status"];
2038
2084
  delete modifiedRequestHeaders["x-debugger-content"];
2039
2085
  if (responseContentMode === "status-only") {
@@ -2041,14 +2087,14 @@ var ProxyWasmRunner = class {
2041
2087
  contentType = "text/plain";
2042
2088
  } else if (responseContentMode === "body-only") {
2043
2089
  responseBody = modifiedRequestBody || "";
2044
- contentType = modifiedRequestHeaders["content-type"] || "text/plain";
2090
+ contentType = HeaderManager.firstValue(modifiedRequestHeaders["content-type"]) || "text/plain";
2045
2091
  } else {
2046
2092
  contentType = "application/json";
2047
2093
  responseBody = JSON.stringify({
2048
2094
  method: requestMethod,
2049
2095
  reqHeaders: modifiedRequestHeaders,
2050
2096
  reqBody: modifiedRequestBody || "",
2051
- requestUrl: BUILTIN_URL
2097
+ requestUrl: propertiesAfterRequestBody["request.url"] || BUILTIN_URL
2052
2098
  });
2053
2099
  }
2054
2100
  responseHeaders = {
@@ -2059,27 +2105,30 @@ var ProxyWasmRunner = class {
2059
2105
  `Built-in responder: ${responseStatus} ${responseStatusText}, mode=${responseContentMode || "full"}`
2060
2106
  );
2061
2107
  } else {
2062
- const modifiedScheme = propertiesAfterRequestBody["request.scheme"] || "https";
2063
- const modifiedHost = propertiesAfterRequestBody["request.host"] || "localhost";
2064
- const modifiedPath = propertiesAfterRequestBody["request.path"] || "/";
2065
- const modifiedQuery = propertiesAfterRequestBody["request.query"] || "";
2066
- const actualTargetUrl = `${modifiedScheme}://${modifiedHost}${modifiedPath}${modifiedQuery ? "?" + modifiedQuery : ""}`;
2108
+ const actualTargetUrl = propertiesAfterRequestBody["request.url"] || targetUrl;
2109
+ const actualScheme = new URL(actualTargetUrl).protocol.replace(":", "");
2067
2110
  this.logDebug(`Original URL: ${targetUrl}`);
2068
- this.logDebug(`Modified URL: ${actualTargetUrl}`);
2111
+ this.logDebug(`Effective URL: ${actualTargetUrl}`);
2069
2112
  this.logDebug(`Fetching ${requestMethod} ${actualTargetUrl}`);
2070
- const fetchHeaders = {
2071
- ...modifiedRequestHeaders
2072
- };
2113
+ const fetchHeaders = HeaderManager.flattenToMap(
2114
+ modifiedRequestHeaders
2115
+ );
2116
+ for (const key of Object.keys(fetchHeaders)) {
2117
+ if (key.startsWith(":")) {
2118
+ delete fetchHeaders[key];
2119
+ }
2120
+ }
2073
2121
  const hostHeader = Object.entries(modifiedRequestHeaders).find(
2074
2122
  ([key]) => key.toLowerCase() === "host"
2075
2123
  );
2076
2124
  if (hostHeader) {
2077
- fetchHeaders["x-forwarded-host"] = hostHeader[1];
2078
- this.logDebug(`Adding x-forwarded-host: ${hostHeader[1]}`);
2125
+ const hostValue = HeaderManager.firstValue(hostHeader[1]) ?? "";
2126
+ fetchHeaders["x-forwarded-host"] = hostValue;
2127
+ this.logDebug(`Adding x-forwarded-host: ${hostValue}`);
2079
2128
  }
2080
- fetchHeaders["x-forwarded-proto"] = modifiedScheme;
2081
- this.logDebug(`Adding x-forwarded-proto: ${modifiedScheme}`);
2082
- fetchHeaders["x-forwarded-port"] = modifiedScheme === "https" ? "443" : "80";
2129
+ fetchHeaders["x-forwarded-proto"] = actualScheme;
2130
+ this.logDebug(`Adding x-forwarded-proto: ${actualScheme}`);
2131
+ fetchHeaders["x-forwarded-port"] = actualScheme === "https" ? "443" : "80";
2083
2132
  this.logDebug(
2084
2133
  `Adding x-forwarded-port: ${fetchHeaders["x-forwarded-port"]}`
2085
2134
  );
@@ -2099,8 +2148,14 @@ var ProxyWasmRunner = class {
2099
2148
  const response = await fetch(actualTargetUrl, fetchOptions);
2100
2149
  responseHeaders = {};
2101
2150
  response.headers.forEach((value, key) => {
2102
- responseHeaders[key] = value;
2151
+ if (key.toLowerCase() !== "set-cookie") {
2152
+ responseHeaders[key] = value;
2153
+ }
2103
2154
  });
2155
+ const setCookies = response.headers.getSetCookie();
2156
+ if (setCookies.length > 0) {
2157
+ responseHeaders["set-cookie"] = setCookies;
2158
+ }
2104
2159
  contentType = response.headers.get("content-type") || "text/plain";
2105
2160
  responseStatus = response.status;
2106
2161
  responseStatusText = response.statusText;
@@ -2177,7 +2232,7 @@ var ProxyWasmRunner = class {
2177
2232
  const finalBody = results.onResponseBody.output.response.body;
2178
2233
  this.logDebug(`Final response body length: ${finalBody.length}`);
2179
2234
  const calculatedProperties = this.propertyResolver.getCalculatedProperties();
2180
- const finalContentType = finalHeaders["content-type"] || contentType;
2235
+ const finalContentType = HeaderManager.firstValue(finalHeaders["content-type"]) || contentType;
2181
2236
  return {
2182
2237
  hookResults: results,
2183
2238
  finalResponse: {
@@ -2270,13 +2325,13 @@ var ProxyWasmRunner = class {
2270
2325
  this.hostFunctions.setLogLevel(0);
2271
2326
  const requestHeaders = HeaderManager.normalize(call.request.headers ?? {});
2272
2327
  const responseHeaders = HeaderManager.normalize(
2273
- call.response.headers ?? {}
2328
+ call.response?.headers ?? {}
2274
2329
  );
2275
2330
  const requestBody = call.request.body ?? "";
2276
- const responseBody = call.response.body ?? "";
2331
+ const responseBody = call.response?.body ?? "";
2277
2332
  const requestMethod = call.request.method ?? "GET";
2278
- const responseStatus = call.response.status ?? 200;
2279
- const responseStatusText = call.response.statusText ?? "OK";
2333
+ const responseStatus = call.response?.status ?? 200;
2334
+ const responseStatusText = call.response?.statusText ?? "OK";
2280
2335
  this.propertyResolver.setProperties({ ...call.properties ?? {} });
2281
2336
  this.propertyResolver.setRequestMetadata(
2282
2337
  requestHeaders,
@@ -2363,7 +2418,7 @@ var ProxyWasmRunner = class {
2363
2418
  for (const [k, v] of Object.entries(pending.headers)) {
2364
2419
  if (!k.startsWith(":")) fetchHeaders[k] = v;
2365
2420
  }
2366
- let responseHeaders2 = {};
2421
+ let responseHeaders2 = [];
2367
2422
  let responseBody2 = new Uint8Array(0);
2368
2423
  try {
2369
2424
  const resp = await fetch(url, {
@@ -2373,20 +2428,23 @@ var ProxyWasmRunner = class {
2373
2428
  signal: AbortSignal.timeout(pending.timeoutMs)
2374
2429
  });
2375
2430
  resp.headers.forEach((v, k) => {
2376
- responseHeaders2[k] = v;
2431
+ if (k.toLowerCase() !== "set-cookie") responseHeaders2.push([k, v]);
2377
2432
  });
2433
+ for (const cookie of resp.headers.getSetCookie()) {
2434
+ responseHeaders2.push(["set-cookie", cookie]);
2435
+ }
2378
2436
  responseBody2 = new Uint8Array(await resp.arrayBuffer());
2379
2437
  this.logDebug(
2380
- `http_call response: ${resp.status} ${resp.statusText} numHeaders=${Object.keys(responseHeaders2).length} bodySize=${responseBody2.byteLength}`
2438
+ `http_call response: ${resp.status} ${resp.statusText} numHeaders=${responseHeaders2.length} bodySize=${responseBody2.byteLength}`
2381
2439
  );
2382
2440
  } catch (err) {
2383
2441
  const errMsg = `http_call failed for ${url}: ${String(err)}`;
2384
2442
  this.logDebug(errMsg);
2385
2443
  this.logs.push({ level: 3, message: `[host] ${errMsg}` });
2386
- responseHeaders2 = {};
2444
+ responseHeaders2 = [];
2387
2445
  responseBody2 = new Uint8Array(0);
2388
2446
  }
2389
- const numHeaders = Object.keys(responseHeaders2).length;
2447
+ const numHeaders = responseHeaders2.length;
2390
2448
  const bodySize = responseBody2.byteLength;
2391
2449
  this.hostFunctions.setHttpCallResponse(pending.tokenId, responseHeaders2, responseBody2);
2392
2450
  this.hostFunctions.resetStreamClosed();
@@ -2488,11 +2546,18 @@ var ProxyWasmRunner = class {
2488
2546
  this.isInitializing = false;
2489
2547
  }
2490
2548
  buildHookInvocation(hook, requestHeaders, responseHeaders, requestBody, responseBody) {
2549
+ const countEntries = (h) => {
2550
+ let n = 0;
2551
+ for (const v of Object.values(h)) {
2552
+ n += Array.isArray(v) ? v.length : 1;
2553
+ }
2554
+ return n;
2555
+ };
2491
2556
  switch (hook) {
2492
2557
  case "onRequestHeaders":
2493
2558
  return {
2494
2559
  exportName: "proxy_on_request_headers",
2495
- args: [this.currentContextId, Object.keys(requestHeaders).length, 0]
2560
+ args: [this.currentContextId, countEntries(requestHeaders), 0]
2496
2561
  };
2497
2562
  case "onRequestBody":
2498
2563
  return {
@@ -2502,7 +2567,7 @@ var ProxyWasmRunner = class {
2502
2567
  case "onResponseHeaders":
2503
2568
  return {
2504
2569
  exportName: "proxy_on_response_headers",
2505
- args: [this.currentContextId, Object.keys(responseHeaders).length, 0]
2570
+ args: [this.currentContextId, countEntries(responseHeaders), 0]
2506
2571
  };
2507
2572
  case "onResponseBody":
2508
2573
  return {
@@ -2617,9 +2682,12 @@ var ProxyWasmRunner = class {
2617
2682
  console.warn(entry.message);
2618
2683
  }
2619
2684
  /**
2620
- * Interface-compliant callFullFlow method
2685
+ * Interface-compliant callFullFlow method.
2686
+ *
2687
+ * The upstream response is generated at runtime by a real HTTP fetch
2688
+ * against `url` or by the built-in responder when `url === "built-in"`.
2621
2689
  */
2622
- async callFullFlow(url, method, headers, body, responseHeaders, responseBody, responseStatus, responseStatusText, properties, enforceProductionPropertyRules) {
2690
+ async callFullFlow(url, method, headers, body, properties, enforceProductionPropertyRules) {
2623
2691
  const call = {
2624
2692
  hook: "",
2625
2693
  // Not used in fullFlow
@@ -2628,12 +2696,6 @@ var ProxyWasmRunner = class {
2628
2696
  body,
2629
2697
  method
2630
2698
  },
2631
- response: {
2632
- headers: responseHeaders,
2633
- body: responseBody,
2634
- status: responseStatus,
2635
- statusText: responseStatusText
2636
- },
2637
2699
  properties,
2638
2700
  enforceProductionPropertyRules
2639
2701
  };
@@ -2947,7 +3009,7 @@ var HttpWasmRunner = class {
2947
3009
  /**
2948
3010
  * Not supported for HTTP WASM (proxy-wasm only)
2949
3011
  */
2950
- async callFullFlow(_url, _method, _headers, _body, _responseHeaders, _responseBody, _responseStatus, _responseStatusText, _properties, _enforceProductionPropertyRules) {
3012
+ async callFullFlow(_url, _method, _headers, _body, _properties, _enforceProductionPropertyRules) {
2951
3013
  throw new Error(
2952
3014
  "callFullFlow() is not supported for HTTP WASM. Use execute() instead."
2953
3015
  );
@@ -3199,17 +3261,23 @@ ${recentLogs || "(no logs)"}`
3199
3261
  ];
3200
3262
  return binaryTypes.some((type) => contentType.toLowerCase().includes(type));
3201
3263
  }
3202
- /**
3203
- * Parse headers from fetch Headers object
3204
- */
3205
3264
  parseHeaders(headers) {
3206
- const result = {};
3207
- headers.forEach((value, key) => {
3208
- result[key] = value;
3209
- });
3210
- return result;
3265
+ return parseFetchHeaders(headers);
3211
3266
  }
3212
3267
  };
3268
+ function parseFetchHeaders(headers) {
3269
+ const result = {};
3270
+ headers.forEach((value, key) => {
3271
+ if (key.toLowerCase() !== "set-cookie") {
3272
+ result[key] = value;
3273
+ }
3274
+ });
3275
+ const setCookies = headers.getSetCookie();
3276
+ if (setCookies.length > 0) {
3277
+ result["set-cookie"] = setCookies;
3278
+ }
3279
+ return result;
3280
+ }
3213
3281
 
3214
3282
  // server/runner/PortManager.ts
3215
3283
  import { createServer } from "net";
@@ -1,6 +1,8 @@
1
- import type { HeaderMap, HeaderTuples } from "./types";
1
+ import type { HeaderMap, HeaderRecord, HeaderTuples } from "./types";
2
2
  export declare class HeaderManager {
3
- static normalize(headers: HeaderMap): HeaderMap;
3
+ static firstValue(v: string | string[] | undefined): string | undefined;
4
+ static flattenToMap(headers: HeaderMap | HeaderRecord): HeaderMap;
5
+ static normalize(headers: HeaderMap | HeaderRecord): HeaderRecord;
4
6
  static serialize(headers: HeaderMap): Uint8Array;
5
7
  /**
6
8
  * Deserializes a proxy-wasm binary header map format:
@@ -8,8 +10,8 @@ export declare class HeaderManager {
8
10
  */
9
11
  static deserializeBinary(bytes: Uint8Array): HeaderMap;
10
12
  static deserialize(payload: string): HeaderMap;
11
- static recordToTuples(headers: HeaderMap): HeaderTuples;
12
- static tuplesToRecord(tuples: HeaderTuples): HeaderMap;
13
+ static recordToTuples(headers: HeaderMap | HeaderRecord): HeaderTuples;
14
+ static tuplesToRecord(tuples: HeaderTuples): HeaderRecord;
13
15
  static normalizeTuples(tuples: HeaderTuples): HeaderTuples;
14
16
  static serializeTuples(tuples: HeaderTuples): Uint8Array;
15
17
  static deserializeBinaryToTuples(bytes: Uint8Array): HeaderTuples;
@@ -1,4 +1,4 @@
1
- import type { HeaderMap, LogEntry } from "./types";
1
+ import type { HeaderMap, HeaderRecord, HeaderTuples, LogEntry } from "./types";
2
2
  import { MemoryManager } from "./MemoryManager";
3
3
  import { PropertyResolver } from "./PropertyResolver";
4
4
  import { PropertyAccessControl, HookContext } from "./PropertyAccessControl";
@@ -36,7 +36,7 @@ export declare class HostFunctions {
36
36
  private dictionary;
37
37
  constructor(memory: MemoryManager, propertyResolver: PropertyResolver, propertyAccessControl: PropertyAccessControl, getCurrentHook: () => HookContext | null, debug?: boolean, secretStore?: SecretStore, dictionary?: Dictionary);
38
38
  setLogs(logs: LogEntry[]): void;
39
- setHeadersAndBodies(reqHeaders: HeaderMap, resHeaders: HeaderMap, reqBody: string, resBody: string): void;
39
+ setHeadersAndBodies(reqHeaders: HeaderMap | HeaderRecord, resHeaders: HeaderMap | HeaderRecord, reqBody: string, resBody: string): void;
40
40
  setConfigs(vmConfig: string, pluginConfig: string): void;
41
41
  setCurrentContext(contextId: number): void;
42
42
  getCurrentLogLevel(): number;
@@ -50,7 +50,7 @@ export declare class HostFunctions {
50
50
  body: Uint8Array | null;
51
51
  timeoutMs: number;
52
52
  } | null;
53
- setHttpCallResponse(tokenId: number, headers: HeaderMap, body: Uint8Array): void;
53
+ setHttpCallResponse(tokenId: number, headers: HeaderMap | HeaderRecord | HeaderTuples, body: Uint8Array): void;
54
54
  clearHttpCallResponse(): void;
55
55
  isStreamClosed(): boolean;
56
56
  resetStreamClosed(): void;
@@ -61,8 +61,8 @@ export declare class HostFunctions {
61
61
  body: Uint8Array;
62
62
  } | null;
63
63
  resetLocalResponse(): void;
64
- getRequestHeaders(): HeaderMap;
65
- getResponseHeaders(): HeaderMap;
64
+ getRequestHeaders(): HeaderRecord;
65
+ getResponseHeaders(): HeaderRecord;
66
66
  getRequestBody(): string;
67
67
  getResponseBody(): string;
68
68
  getSecretStore(): SecretStore;
@@ -4,6 +4,7 @@
4
4
  * Executes HTTP WASM binaries (component model with wasi-http interface)
5
5
  * using the FastEdge-run CLI as a process-based runner.
6
6
  */
7
+ import type { IncomingHttpHeaders } from "node:http";
7
8
  import type { IWasmRunner, WasmType, RunnerConfig, HttpRequest, HttpResponse } from "./IWasmRunner.js";
8
9
  import type { HookCall, HookResult, FullFlowResult } from "./types.js";
9
10
  import type { IStateManager } from "./IStateManager.js";
@@ -50,7 +51,7 @@ export declare class HttpWasmRunner implements IWasmRunner {
50
51
  /**
51
52
  * Not supported for HTTP WASM (proxy-wasm only)
52
53
  */
53
- callFullFlow(_url: string, _method: string, _headers: Record<string, string>, _body: string, _responseHeaders: Record<string, string>, _responseBody: string, _responseStatus: number, _responseStatusText: string, _properties: Record<string, unknown>, _enforceProductionPropertyRules: boolean): Promise<FullFlowResult>;
54
+ callFullFlow(_url: string, _method: string, _headers: Record<string, string>, _body: string, _properties: Record<string, unknown>, _enforceProductionPropertyRules: boolean): Promise<FullFlowResult>;
54
55
  /**
55
56
  * Apply dotenv settings by restarting the fastedge-run process.
56
57
  * The WASM file is not re-read; only the --dotenv flag changes.
@@ -111,8 +112,16 @@ export declare class HttpWasmRunner implements IWasmRunner {
111
112
  * Check if content type is binary
112
113
  */
113
114
  private isBinaryContentType;
114
- /**
115
- * Parse headers from fetch Headers object
116
- */
117
115
  private parseHeaders;
118
116
  }
117
+ /**
118
+ * Parse a fetch Headers object into an IncomingHttpHeaders-shaped record.
119
+ *
120
+ * Uses `Headers.getSetCookie()` (Node 19.7+, always available on Node ≥22.12)
121
+ * to preserve multiple Set-Cookie entries as a string[] — RFC 6265 §3 exempts
122
+ * Set-Cookie from the "combine duplicates with commas" rule, and real browsers
123
+ * process each Set-Cookie independently.
124
+ *
125
+ * Exported for unit testing; in production use it via HttpWasmRunner.
126
+ */
127
+ export declare function parseFetchHeaders(headers: Headers): IncomingHttpHeaders;
@@ -7,30 +7,30 @@
7
7
  */
8
8
  export type EventSource = "ui" | "ai_agent" | "api" | "system";
9
9
  export interface IStateManager {
10
- emitRequestStarted(url: string, method: string, headers: Record<string, string>, source?: EventSource): void;
10
+ emitRequestStarted(url: string, method: string, headers: Record<string, string | string[]>, source?: EventSource): void;
11
11
  emitHookExecuted(hook: string, returnCode: number | null, logCount: number, input: {
12
12
  request: {
13
- headers: Record<string, string>;
13
+ headers: Record<string, string | string[]>;
14
14
  body: string;
15
15
  };
16
16
  response: {
17
- headers: Record<string, string>;
17
+ headers: Record<string, string | string[]>;
18
18
  body: string;
19
19
  };
20
20
  }, output: {
21
21
  request: {
22
- headers: Record<string, string>;
22
+ headers: Record<string, string | string[]>;
23
23
  body: string;
24
24
  };
25
25
  response: {
26
- headers: Record<string, string>;
26
+ headers: Record<string, string | string[]>;
27
27
  body: string;
28
28
  };
29
29
  }, source?: EventSource): void;
30
30
  emitRequestCompleted(hookResults: Record<string, unknown>, finalResponse: {
31
31
  status: number;
32
32
  statusText: string;
33
- headers: Record<string, string>;
33
+ headers: Record<string, string | string[]>;
34
34
  body: string;
35
35
  contentType: string;
36
36
  isBase64?: boolean;
@@ -41,7 +41,7 @@ export interface IStateManager {
41
41
  emitHttpWasmRequestCompleted(response: {
42
42
  status: number;
43
43
  statusText: string;
44
- headers: Record<string, string>;
44
+ headers: Record<string, string | string[] | undefined>;
45
45
  body: string;
46
46
  contentType: string | null;
47
47
  isBase64?: boolean;
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Defines the common contract for both ProxyWasmRunner and HttpWasmRunner
5
5
  */
6
+ import type { IncomingHttpHeaders } from "node:http";
6
7
  import type { IStateManager } from "./IStateManager.js";
7
8
  import type { HookCall, HookResult, FullFlowResult } from "./types.js";
8
9
  export type WasmType = "http-wasm" | "proxy-wasm";
@@ -38,12 +39,18 @@ export interface HttpRequest {
38
39
  body?: string;
39
40
  }
40
41
  /**
41
- * HTTP Response type for HTTP WASM runner
42
+ * HTTP Response type for HTTP WASM runner.
43
+ *
44
+ * `headers` uses Node's `IncomingHttpHeaders` shape — common single-valued
45
+ * headers (`content-type`, `location`, `etag`, …) are typed as `string`;
46
+ * `set-cookie` is `string[]`; unknown keys are `string | string[] | undefined`.
47
+ * This preserves RFC 6265 Set-Cookie semantics (each cookie kept separate)
48
+ * and matches consumer expectations for Node's fetch/http ecosystem.
42
49
  */
43
50
  export interface HttpResponse {
44
51
  status: number;
45
52
  statusText: string;
46
- headers: Record<string, string>;
53
+ headers: IncomingHttpHeaders;
47
54
  body: string;
48
55
  contentType: string | null;
49
56
  isBase64?: boolean;
@@ -90,20 +97,21 @@ export interface IWasmRunner {
90
97
  */
91
98
  callHook(hookCall: HookCall): Promise<HookResult>;
92
99
  /**
93
- * Execute full request/response flow (Proxy-WASM only)
94
- * @param url Request URL
100
+ * Execute full request/response flow (Proxy-WASM only).
101
+ *
102
+ * The upstream response is generated at runtime — either by a real HTTP
103
+ * fetch against `url`, or by the built-in responder when
104
+ * `url === "built-in"`. There is no fixture-level mock response.
105
+ *
106
+ * @param url Request URL, or `"built-in"` to use the built-in responder
95
107
  * @param method HTTP method
96
108
  * @param headers Request headers
97
109
  * @param body Request body
98
- * @param responseHeaders Response headers
99
- * @param responseBody Response body
100
- * @param responseStatus Response status code
101
- * @param responseStatusText Response status text
102
110
  * @param properties Shared properties
103
111
  * @param enforceProductionPropertyRules Whether to enforce property access rules
104
112
  * @returns Full flow execution result
105
113
  */
106
- callFullFlow(url: string, method: string, headers: Record<string, string>, body: string, responseHeaders: Record<string, string>, responseBody: string, responseStatus: number, responseStatusText: string, properties: Record<string, unknown>, enforceProductionPropertyRules: boolean): Promise<FullFlowResult>;
114
+ callFullFlow(url: string, method: string, headers: Record<string, string>, body: string, properties: Record<string, unknown>, enforceProductionPropertyRules: boolean): Promise<FullFlowResult>;
107
115
  /**
108
116
  * Apply dotenv settings to the current runner without reloading the WASM.
109
117
  * For ProxyWasmRunner: resets stores and re-loads dotenv files in-place.
@@ -1,4 +1,4 @@
1
- import type { HeaderMap } from "./types";
1
+ import type { HeaderMap, HeaderRecord } from "./types";
2
2
  export declare class PropertyResolver {
3
3
  private properties;
4
4
  private requestHeaders;
@@ -28,13 +28,13 @@ export declare class PropertyResolver {
28
28
  * User properties take precedence over calculated ones
29
29
  */
30
30
  getAllProperties(): Record<string, unknown>;
31
- setRequestMetadata(headers: HeaderMap, method: string, path?: string, scheme?: string): void;
31
+ setRequestMetadata(headers: HeaderMap | HeaderRecord, method: string, path?: string, scheme?: string): void;
32
32
  /**
33
33
  * Extract runtime properties from target URL
34
34
  * This parses the URL to populate request.url, request.host, request.path, etc.
35
35
  */
36
36
  extractRuntimePropertiesFromUrl(targetUrl: string): void;
37
- setResponseMetadata(headers: HeaderMap, status: number, statusText: string): void;
37
+ setResponseMetadata(headers: HeaderMap | HeaderRecord, status: number, statusText: string): void;
38
38
  resolve(path: string): unknown;
39
39
  private resolveStandard;
40
40
  private resolvePathSegments;
@@ -59,9 +59,12 @@ export declare class ProxyWasmRunner implements IWasmRunner {
59
59
  private getHookContext;
60
60
  private logDebug;
61
61
  /**
62
- * Interface-compliant callFullFlow method
62
+ * Interface-compliant callFullFlow method.
63
+ *
64
+ * The upstream response is generated at runtime by a real HTTP fetch
65
+ * against `url` or by the built-in responder when `url === "built-in"`.
63
66
  */
64
- callFullFlow(url: string, method: string, headers: Record<string, string>, body: string, responseHeaders: Record<string, string>, responseBody: string, responseStatus: number, responseStatusText: string, properties: Record<string, unknown>, enforceProductionPropertyRules: boolean): Promise<FullFlowResult>;
67
+ callFullFlow(url: string, method: string, headers: Record<string, string>, body: string, properties: Record<string, unknown>, enforceProductionPropertyRules: boolean): Promise<FullFlowResult>;
65
68
  /**
66
69
  * Not supported for Proxy-WASM (HTTP WASM only)
67
70
  */
@@ -7,7 +7,7 @@
7
7
  * Usage:
8
8
  * import { createRunner } from './server/runner/standalone.js';
9
9
  * const runner = await createRunner('./path/to/wasm.wasm');
10
- * const result = await runner.callFullFlow('https://example.com', 'GET', {}, '', {}, '', 200, 'OK', {}, true);
10
+ * const result = await runner.callFullFlow('https://example.com', 'GET', {}, '', {}, true);
11
11
  */
12
12
  import type { IWasmRunner, RunnerConfig } from "./IWasmRunner.js";
13
13
  /**