@gcoredev/fastedge-test 0.1.6 → 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 (43) hide show
  1. package/dist/frontend/assets/{index-BpdzhbRl.js → index-CiqeJ9rz.js} +24 -24
  2. package/dist/frontend/index.html +1 -1
  3. package/dist/lib/index.cjs +164 -66
  4. package/dist/lib/index.d.ts +1 -0
  5. package/dist/lib/index.js +164 -66
  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 +22 -5
  9. package/dist/lib/runner/IStateManager.d.ts +7 -7
  10. package/dist/lib/runner/IWasmRunner.d.ts +42 -10
  11. package/dist/lib/runner/PortManager.d.ts +4 -1
  12. package/dist/lib/runner/PropertyResolver.d.ts +3 -3
  13. package/dist/lib/runner/ProxyWasmRunner.d.ts +5 -2
  14. package/dist/lib/runner/standalone.d.ts +1 -1
  15. package/dist/lib/runner/types.d.ts +17 -8
  16. package/dist/lib/schemas/api.d.ts +2 -8
  17. package/dist/lib/schemas/config.d.ts +2 -13
  18. package/dist/lib/schemas/index.d.ts +2 -2
  19. package/dist/lib/test-framework/assertions.d.ts +18 -4
  20. package/dist/lib/test-framework/index.cjs +18634 -115
  21. package/dist/lib/test-framework/index.d.ts +2 -0
  22. package/dist/lib/test-framework/index.js +18651 -104
  23. package/dist/lib/test-framework/mock-origins.d.ts +56 -0
  24. package/dist/lib/test-framework/suite-runner.d.ts +16 -0
  25. package/dist/lib/test-framework/types.d.ts +1 -5
  26. package/dist/server.js +33 -33
  27. package/docs/API.md +48 -54
  28. package/docs/DEBUGGER.md +7 -8
  29. package/docs/INDEX.md +4 -1
  30. package/docs/RUNNER.md +126 -74
  31. package/docs/TEST_CONFIG.md +79 -40
  32. package/docs/TEST_FRAMEWORK.md +235 -36
  33. package/docs/WEBSOCKET.md +25 -21
  34. package/docs/quickstart.md +1 -13
  35. package/package.json +4 -1
  36. package/schemas/api-config.schema.json +5 -24
  37. package/schemas/api-load.schema.json +5 -0
  38. package/schemas/api-send.schema.json +0 -20
  39. package/schemas/fastedge-config.test.schema.json +5 -24
  40. package/schemas/full-flow-result.schema.json +17 -7
  41. package/schemas/hook-call.schema.json +16 -6
  42. package/schemas/hook-result.schema.json +16 -6
  43. 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
  };
@@ -2817,6 +2879,8 @@ var HttpWasmRunner = class {
2817
2879
  this.stateManager = null;
2818
2880
  this.dotenvEnabled = true;
2819
2881
  this.dotenvPath = null;
2882
+ /** Pinned ports bypass PortManager allocation and must not be released back to it. */
2883
+ this.isPinnedPort = false;
2820
2884
  /** @deprecated Legacy sync support — remove when #[fastedge::http] is retired */
2821
2885
  this.isLegacySync = false;
2822
2886
  this.portManager = portManager;
@@ -2843,7 +2907,19 @@ var HttpWasmRunner = class {
2843
2907
  this.tempWasmPath = wasmPath;
2844
2908
  }
2845
2909
  this.isLegacySync = await isLegacySyncWasm(bufferOrPath);
2846
- this.port = await this.portManager.allocate();
2910
+ if (config?.httpPort !== void 0) {
2911
+ const pinned = config.httpPort;
2912
+ if (!await this.portManager.isPortFree(pinned)) {
2913
+ throw new Error(
2914
+ `fastedge-run port ${pinned} is not available \u2014 release it or choose a different httpPort in fastedge-config.test.json`
2915
+ );
2916
+ }
2917
+ this.port = pinned;
2918
+ this.isPinnedPort = true;
2919
+ } else {
2920
+ this.port = await this.portManager.allocate();
2921
+ this.isPinnedPort = false;
2922
+ }
2847
2923
  const wasi_http = !this.isLegacySync;
2848
2924
  const args = [
2849
2925
  "http",
@@ -2875,7 +2951,13 @@ var HttpWasmRunner = class {
2875
2951
  await this.waitForServerReady(this.port, timeout);
2876
2952
  }
2877
2953
  /**
2878
- * Execute an HTTP request through the WASM module
2954
+ * Execute an HTTP request through the WASM module.
2955
+ *
2956
+ * Redirects are surfaced verbatim — `fetch` is called with
2957
+ * `redirect: "manual"` so 3xx responses (status + `Location`) reach the
2958
+ * caller intact. This matches FastEdge edge behaviour, which returns
2959
+ * redirects to the client rather than following them server-side. See
2960
+ * `IWasmRunner.execute` for the public contract.
2879
2961
  */
2880
2962
  async execute(request) {
2881
2963
  if (!this.port || !this.process) {
@@ -2888,8 +2970,12 @@ var HttpWasmRunner = class {
2888
2970
  method: request.method,
2889
2971
  headers: request.headers,
2890
2972
  body: request.body || void 0,
2891
- signal: AbortSignal.timeout(3e4)
2973
+ signal: AbortSignal.timeout(3e4),
2892
2974
  // 30 second timeout
2975
+ // Surface 3xx responses verbatim so tests can assert on status/Location.
2976
+ // A FastEdge edge returns redirects to the client rather than following
2977
+ // them server-side; production parity requires the same here.
2978
+ redirect: "manual"
2893
2979
  });
2894
2980
  const arrayBuffer = await response.arrayBuffer();
2895
2981
  const bodyBuffer = Buffer.from(arrayBuffer);
@@ -2923,7 +3009,7 @@ var HttpWasmRunner = class {
2923
3009
  /**
2924
3010
  * Not supported for HTTP WASM (proxy-wasm only)
2925
3011
  */
2926
- async callFullFlow(_url, _method, _headers, _body, _responseHeaders, _responseBody, _responseStatus, _responseStatusText, _properties, _enforceProductionPropertyRules) {
3012
+ async callFullFlow(_url, _method, _headers, _body, _properties, _enforceProductionPropertyRules) {
2927
3013
  throw new Error(
2928
3014
  "callFullFlow() is not supported for HTTP WASM. Use execute() instead."
2929
3015
  );
@@ -2980,8 +3066,11 @@ var HttpWasmRunner = class {
2980
3066
  this.process = null;
2981
3067
  }
2982
3068
  if (this.port !== null) {
2983
- this.portManager.release(this.port);
3069
+ if (!this.isPinnedPort) {
3070
+ this.portManager.release(this.port);
3071
+ }
2984
3072
  this.port = null;
3073
+ this.isPinnedPort = false;
2985
3074
  }
2986
3075
  if (this.tempWasmPath) {
2987
3076
  await removeTempWasmFile(this.tempWasmPath);
@@ -3172,17 +3261,23 @@ ${recentLogs || "(no logs)"}`
3172
3261
  ];
3173
3262
  return binaryTypes.some((type) => contentType.toLowerCase().includes(type));
3174
3263
  }
3175
- /**
3176
- * Parse headers from fetch Headers object
3177
- */
3178
3264
  parseHeaders(headers) {
3179
- const result = {};
3180
- headers.forEach((value, key) => {
3181
- result[key] = value;
3182
- });
3183
- return result;
3265
+ return parseFetchHeaders(headers);
3184
3266
  }
3185
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
+ }
3186
3281
 
3187
3282
  // server/runner/PortManager.ts
3188
3283
  import { createServer } from "net";
@@ -3198,6 +3293,9 @@ var PortManager = class {
3198
3293
  * This is necessary when multiple server processes run simultaneously —
3199
3294
  * each has its own PortManager with independent in-memory state, so
3200
3295
  * in-memory tracking alone is not enough to prevent cross-process conflicts.
3296
+ *
3297
+ * Public so pinned-port callers (HttpWasmRunner with RunnerConfig.httpPort)
3298
+ * can reuse the same OS-level check without going through allocate().
3201
3299
  */
3202
3300
  isPortFree(port) {
3203
3301
  return new Promise((resolve) => {
@@ -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";
@@ -24,6 +25,8 @@ export declare class HttpWasmRunner implements IWasmRunner {
24
25
  private portManager;
25
26
  private dotenvEnabled;
26
27
  private dotenvPath;
28
+ /** Pinned ports bypass PortManager allocation and must not be released back to it. */
29
+ private isPinnedPort;
27
30
  /** @deprecated Legacy sync support — remove when #[fastedge::http] is retired */
28
31
  private isLegacySync;
29
32
  constructor(portManager: PortManager, dotenvEnabled?: boolean);
@@ -32,7 +35,13 @@ export declare class HttpWasmRunner implements IWasmRunner {
32
35
  */
33
36
  load(bufferOrPath: Buffer | string, config?: RunnerConfig): Promise<void>;
34
37
  /**
35
- * Execute an HTTP request through the WASM module
38
+ * Execute an HTTP request through the WASM module.
39
+ *
40
+ * Redirects are surfaced verbatim — `fetch` is called with
41
+ * `redirect: "manual"` so 3xx responses (status + `Location`) reach the
42
+ * caller intact. This matches FastEdge edge behaviour, which returns
43
+ * redirects to the client rather than following them server-side. See
44
+ * `IWasmRunner.execute` for the public contract.
36
45
  */
37
46
  execute(request: HttpRequest): Promise<HttpResponse>;
38
47
  /**
@@ -42,7 +51,7 @@ export declare class HttpWasmRunner implements IWasmRunner {
42
51
  /**
43
52
  * Not supported for HTTP WASM (proxy-wasm only)
44
53
  */
45
- 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>;
46
55
  /**
47
56
  * Apply dotenv settings by restarting the fastedge-run process.
48
57
  * The WASM file is not re-read; only the --dotenv flag changes.
@@ -103,8 +112,16 @@ export declare class HttpWasmRunner implements IWasmRunner {
103
112
  * Check if content type is binary
104
113
  */
105
114
  private isBinaryContentType;
106
- /**
107
- * Parse headers from fetch Headers object
108
- */
109
115
  private parseHeaders;
110
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;