@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
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Proxy Runner</title>
7
- <script type="module" crossorigin src="/assets/index-BpdzhbRl.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CiqeJ9rz.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DdlINQc_.css">
9
9
  </head>
10
10
  <body>
@@ -219,10 +219,36 @@ var MemoryManager = class {
219
219
  // server/runner/HeaderManager.ts
220
220
  var textEncoder2 = new TextEncoder();
221
221
  var HeaderManager = class {
222
+ // Read a header value as a single string. For multi-valued headers (string[]) returns the first.
223
+ // Use when callers know the header is conventionally single-valued (content-type, host, location, etc.)
224
+ // and need to satisfy APIs that take a string.
225
+ static firstValue(v) {
226
+ return Array.isArray(v) ? v[0] : v;
227
+ }
228
+ // Flatten a HeaderRecord to a HeaderMap (single string per key) for consumers
229
+ // that can't handle multi-valued headers (e.g. fetch's HeadersInit).
230
+ // Multi-valued entries are joined with ", " — caller must be sure this is acceptable
231
+ // (NOT valid for Set-Cookie; route those through a separate channel).
232
+ static flattenToMap(headers) {
233
+ const flat = {};
234
+ for (const [k, v] of Object.entries(headers)) {
235
+ if (Array.isArray(v)) {
236
+ flat[k] = v.join(", ");
237
+ } else if (v !== void 0) {
238
+ flat[k] = String(v);
239
+ }
240
+ }
241
+ return flat;
242
+ }
222
243
  static normalize(headers) {
223
244
  const normalized = {};
224
245
  for (const [key, value] of Object.entries(headers)) {
225
- normalized[key.toLowerCase()] = String(value);
246
+ const k = key.toLowerCase();
247
+ if (Array.isArray(value)) {
248
+ normalized[k] = value.map(String);
249
+ } else {
250
+ normalized[k] = String(value);
251
+ }
226
252
  }
227
253
  return normalized;
228
254
  }
@@ -301,13 +327,31 @@ var HeaderManager = class {
301
327
  }
302
328
  // --- Tuple-based methods for multi-valued header support ---
303
329
  static recordToTuples(headers) {
304
- return Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v)]);
330
+ const tuples = [];
331
+ for (const [k, v] of Object.entries(headers)) {
332
+ const key = k.toLowerCase();
333
+ if (Array.isArray(v)) {
334
+ for (const val of v) tuples.push([key, String(val)]);
335
+ } else if (v !== void 0) {
336
+ tuples.push([key, String(v)]);
337
+ }
338
+ }
339
+ return tuples;
305
340
  }
341
+ // Lossless projection of tuples to a Record: single-valued keys are string,
342
+ // multi-valued keys are string[] — matching Node's IncomingHttpHeaders shape.
343
+ // Set-Cookie and other legitimately-repeatable headers are preserved across duplicates.
306
344
  static tuplesToRecord(tuples) {
307
345
  const record = {};
308
346
  for (const [key, value] of tuples) {
309
347
  const existing = record[key];
310
- record[key] = existing !== void 0 ? `${existing},${value}` : value;
348
+ if (existing === void 0) {
349
+ record[key] = value;
350
+ } else if (Array.isArray(existing)) {
351
+ existing.push(value);
352
+ } else {
353
+ record[key] = [existing, value];
354
+ }
311
355
  }
312
356
  return record;
313
357
  }
@@ -453,10 +497,11 @@ var PropertyResolver = class {
453
497
  const url = new URL(targetUrl);
454
498
  this.requestUrl = targetUrl;
455
499
  this.requestHost = url.hostname + (url.port ? `:${url.port}` : "");
456
- this.requestPath = url.pathname || "/";
500
+ this.requestPath = (url.pathname || "/") + url.search;
457
501
  this.requestQuery = url.search.startsWith("?") ? url.search.substring(1) : url.search;
458
502
  this.requestScheme = url.protocol.replace(":", "");
459
- const pathParts = this.requestPath.split("/");
503
+ const pathOnly = url.pathname || "/";
504
+ const pathParts = pathOnly.split("/");
460
505
  const lastPart = pathParts[pathParts.length - 1];
461
506
  const dotIndex = lastPart.lastIndexOf(".");
462
507
  if (dotIndex > 0 && dotIndex < lastPart.length - 1) {
@@ -526,28 +571,28 @@ var PropertyResolver = class {
526
571
  if (path2 === "request.url")
527
572
  return this.requestUrl || `${this.requestScheme}://${this.requestHost}${this.requestPath}`;
528
573
  if (path2 === "request.host")
529
- return this.requestHost || this.requestHeaders["host"] || "localhost";
574
+ return this.requestHost || HeaderManager.firstValue(this.requestHeaders["host"]) || "localhost";
530
575
  if (path2 === "request.scheme") return this.requestScheme;
531
576
  if (path2 === "request.protocol") return this.requestScheme;
532
577
  if (path2 === "request.query") return this.requestQuery;
533
578
  if (path2 === "request.extension") return this.requestExtension;
534
579
  if (path2 === "request.content_type") {
535
- return this.requestHeaders["content-type"] || "";
580
+ return HeaderManager.firstValue(this.requestHeaders["content-type"]) || "";
536
581
  }
537
582
  if (path2.startsWith("request.headers.")) {
538
583
  const headerName = path2.substring("request.headers.".length).toLowerCase();
539
- return this.requestHeaders[headerName] || "";
584
+ return HeaderManager.firstValue(this.requestHeaders[headerName]) || "";
540
585
  }
541
586
  if (path2 === "response.code") return this.responseStatus;
542
587
  if (path2 === "response.status") return this.responseStatus;
543
588
  if (path2 === "response.status_code") return this.responseStatus;
544
589
  if (path2 === "response.code_details") return this.responseStatusText;
545
590
  if (path2 === "response.content_type") {
546
- return this.responseHeaders["content-type"] || "";
591
+ return HeaderManager.firstValue(this.responseHeaders["content-type"]) || "";
547
592
  }
548
593
  if (path2.startsWith("response.headers.")) {
549
594
  const headerName = path2.substring("response.headers.".length).toLowerCase();
550
- return this.responseHeaders[headerName] || "";
595
+ return HeaderManager.firstValue(this.responseHeaders[headerName]) || "";
551
596
  }
552
597
  return void 0;
553
598
  }
@@ -902,7 +947,8 @@ var HostFunctions = class {
902
947
  return call;
903
948
  }
904
949
  setHttpCallResponse(tokenId, headers, body) {
905
- this.httpCallResponse = { tokenId, headers, body };
950
+ const tuples = Array.isArray(headers) ? headers : HeaderManager.recordToTuples(headers);
951
+ this.httpCallResponse = { tokenId, headers: tuples, body };
906
952
  }
907
953
  clearHttpCallResponse() {
908
954
  this.httpCallResponse = null;
@@ -1332,7 +1378,7 @@ var HostFunctions = class {
1332
1378
  return this.responseHeaders;
1333
1379
  }
1334
1380
  if (mapType === 6 /* HttpCallResponseHeaders */ || mapType === 7 /* HttpCallResponseTrailers */) {
1335
- return HeaderManager.recordToTuples(this.httpCallResponse?.headers ?? {});
1381
+ return this.httpCallResponse?.headers ?? [];
1336
1382
  }
1337
1383
  return this.requestHeaders;
1338
1384
  }
@@ -1992,7 +2038,7 @@ var ProxyWasmRunner = class {
1992
2038
  const local = this.hostFunctions.getLocalResponse();
1993
2039
  const responseHeaders2 = results.onRequestHeaders.output.response.headers;
1994
2040
  this.hostFunctions.resetLocalResponse();
1995
- const contentType2 = responseHeaders2["content-type"] || "text/plain";
2041
+ const contentType2 = HeaderManager.firstValue(responseHeaders2["content-type"]) || "text/plain";
1996
2042
  const { body, isBase64: isBase642 } = encodeLocalResponseBody(local.body, contentType2);
1997
2043
  return {
1998
2044
  hookResults: results,
@@ -2035,7 +2081,7 @@ var ProxyWasmRunner = class {
2035
2081
  const local = this.hostFunctions.getLocalResponse();
2036
2082
  const responseHeaders2 = results.onRequestBody.output.response.headers;
2037
2083
  this.hostFunctions.resetLocalResponse();
2038
- const contentType2 = responseHeaders2["content-type"] || "text/plain";
2084
+ const contentType2 = HeaderManager.firstValue(responseHeaders2["content-type"]) || "text/plain";
2039
2085
  const { body, isBase64: isBase642 } = encodeLocalResponseBody(local.body, contentType2);
2040
2086
  return {
2041
2087
  hookResults: results,
@@ -2066,7 +2112,7 @@ var ProxyWasmRunner = class {
2066
2112
  try {
2067
2113
  if (isBuiltIn) {
2068
2114
  this.logDebug("Using built-in responder");
2069
- const rawStatus = (modifiedRequestHeaders["x-debugger-status"] || "").trim();
2115
+ const rawStatus = (HeaderManager.firstValue(modifiedRequestHeaders["x-debugger-status"]) || "").trim();
2070
2116
  if (rawStatus === "") {
2071
2117
  responseStatus = 200;
2072
2118
  } else {
@@ -2077,7 +2123,7 @@ var ProxyWasmRunner = class {
2077
2123
  }
2078
2124
  }
2079
2125
  responseStatusText = responseStatus === 200 ? "OK" : String(responseStatus);
2080
- const responseContentMode = modifiedRequestHeaders["x-debugger-content"] || "";
2126
+ const responseContentMode = HeaderManager.firstValue(modifiedRequestHeaders["x-debugger-content"]) || "";
2081
2127
  delete modifiedRequestHeaders["x-debugger-status"];
2082
2128
  delete modifiedRequestHeaders["x-debugger-content"];
2083
2129
  if (responseContentMode === "status-only") {
@@ -2085,14 +2131,14 @@ var ProxyWasmRunner = class {
2085
2131
  contentType = "text/plain";
2086
2132
  } else if (responseContentMode === "body-only") {
2087
2133
  responseBody = modifiedRequestBody || "";
2088
- contentType = modifiedRequestHeaders["content-type"] || "text/plain";
2134
+ contentType = HeaderManager.firstValue(modifiedRequestHeaders["content-type"]) || "text/plain";
2089
2135
  } else {
2090
2136
  contentType = "application/json";
2091
2137
  responseBody = JSON.stringify({
2092
2138
  method: requestMethod,
2093
2139
  reqHeaders: modifiedRequestHeaders,
2094
2140
  reqBody: modifiedRequestBody || "",
2095
- requestUrl: BUILTIN_URL
2141
+ requestUrl: propertiesAfterRequestBody["request.url"] || BUILTIN_URL
2096
2142
  });
2097
2143
  }
2098
2144
  responseHeaders = {
@@ -2103,27 +2149,30 @@ var ProxyWasmRunner = class {
2103
2149
  `Built-in responder: ${responseStatus} ${responseStatusText}, mode=${responseContentMode || "full"}`
2104
2150
  );
2105
2151
  } else {
2106
- const modifiedScheme = propertiesAfterRequestBody["request.scheme"] || "https";
2107
- const modifiedHost = propertiesAfterRequestBody["request.host"] || "localhost";
2108
- const modifiedPath = propertiesAfterRequestBody["request.path"] || "/";
2109
- const modifiedQuery = propertiesAfterRequestBody["request.query"] || "";
2110
- const actualTargetUrl = `${modifiedScheme}://${modifiedHost}${modifiedPath}${modifiedQuery ? "?" + modifiedQuery : ""}`;
2152
+ const actualTargetUrl = propertiesAfterRequestBody["request.url"] || targetUrl;
2153
+ const actualScheme = new URL(actualTargetUrl).protocol.replace(":", "");
2111
2154
  this.logDebug(`Original URL: ${targetUrl}`);
2112
- this.logDebug(`Modified URL: ${actualTargetUrl}`);
2155
+ this.logDebug(`Effective URL: ${actualTargetUrl}`);
2113
2156
  this.logDebug(`Fetching ${requestMethod} ${actualTargetUrl}`);
2114
- const fetchHeaders = {
2115
- ...modifiedRequestHeaders
2116
- };
2157
+ const fetchHeaders = HeaderManager.flattenToMap(
2158
+ modifiedRequestHeaders
2159
+ );
2160
+ for (const key of Object.keys(fetchHeaders)) {
2161
+ if (key.startsWith(":")) {
2162
+ delete fetchHeaders[key];
2163
+ }
2164
+ }
2117
2165
  const hostHeader = Object.entries(modifiedRequestHeaders).find(
2118
2166
  ([key]) => key.toLowerCase() === "host"
2119
2167
  );
2120
2168
  if (hostHeader) {
2121
- fetchHeaders["x-forwarded-host"] = hostHeader[1];
2122
- this.logDebug(`Adding x-forwarded-host: ${hostHeader[1]}`);
2169
+ const hostValue = HeaderManager.firstValue(hostHeader[1]) ?? "";
2170
+ fetchHeaders["x-forwarded-host"] = hostValue;
2171
+ this.logDebug(`Adding x-forwarded-host: ${hostValue}`);
2123
2172
  }
2124
- fetchHeaders["x-forwarded-proto"] = modifiedScheme;
2125
- this.logDebug(`Adding x-forwarded-proto: ${modifiedScheme}`);
2126
- fetchHeaders["x-forwarded-port"] = modifiedScheme === "https" ? "443" : "80";
2173
+ fetchHeaders["x-forwarded-proto"] = actualScheme;
2174
+ this.logDebug(`Adding x-forwarded-proto: ${actualScheme}`);
2175
+ fetchHeaders["x-forwarded-port"] = actualScheme === "https" ? "443" : "80";
2127
2176
  this.logDebug(
2128
2177
  `Adding x-forwarded-port: ${fetchHeaders["x-forwarded-port"]}`
2129
2178
  );
@@ -2143,8 +2192,14 @@ var ProxyWasmRunner = class {
2143
2192
  const response = await fetch(actualTargetUrl, fetchOptions);
2144
2193
  responseHeaders = {};
2145
2194
  response.headers.forEach((value, key) => {
2146
- responseHeaders[key] = value;
2195
+ if (key.toLowerCase() !== "set-cookie") {
2196
+ responseHeaders[key] = value;
2197
+ }
2147
2198
  });
2199
+ const setCookies = response.headers.getSetCookie();
2200
+ if (setCookies.length > 0) {
2201
+ responseHeaders["set-cookie"] = setCookies;
2202
+ }
2148
2203
  contentType = response.headers.get("content-type") || "text/plain";
2149
2204
  responseStatus = response.status;
2150
2205
  responseStatusText = response.statusText;
@@ -2221,7 +2276,7 @@ var ProxyWasmRunner = class {
2221
2276
  const finalBody = results.onResponseBody.output.response.body;
2222
2277
  this.logDebug(`Final response body length: ${finalBody.length}`);
2223
2278
  const calculatedProperties = this.propertyResolver.getCalculatedProperties();
2224
- const finalContentType = finalHeaders["content-type"] || contentType;
2279
+ const finalContentType = HeaderManager.firstValue(finalHeaders["content-type"]) || contentType;
2225
2280
  return {
2226
2281
  hookResults: results,
2227
2282
  finalResponse: {
@@ -2314,13 +2369,13 @@ var ProxyWasmRunner = class {
2314
2369
  this.hostFunctions.setLogLevel(0);
2315
2370
  const requestHeaders = HeaderManager.normalize(call.request.headers ?? {});
2316
2371
  const responseHeaders = HeaderManager.normalize(
2317
- call.response.headers ?? {}
2372
+ call.response?.headers ?? {}
2318
2373
  );
2319
2374
  const requestBody = call.request.body ?? "";
2320
- const responseBody = call.response.body ?? "";
2375
+ const responseBody = call.response?.body ?? "";
2321
2376
  const requestMethod = call.request.method ?? "GET";
2322
- const responseStatus = call.response.status ?? 200;
2323
- const responseStatusText = call.response.statusText ?? "OK";
2377
+ const responseStatus = call.response?.status ?? 200;
2378
+ const responseStatusText = call.response?.statusText ?? "OK";
2324
2379
  this.propertyResolver.setProperties({ ...call.properties ?? {} });
2325
2380
  this.propertyResolver.setRequestMetadata(
2326
2381
  requestHeaders,
@@ -2407,7 +2462,7 @@ var ProxyWasmRunner = class {
2407
2462
  for (const [k, v] of Object.entries(pending.headers)) {
2408
2463
  if (!k.startsWith(":")) fetchHeaders[k] = v;
2409
2464
  }
2410
- let responseHeaders2 = {};
2465
+ let responseHeaders2 = [];
2411
2466
  let responseBody2 = new Uint8Array(0);
2412
2467
  try {
2413
2468
  const resp = await fetch(url, {
@@ -2417,20 +2472,23 @@ var ProxyWasmRunner = class {
2417
2472
  signal: AbortSignal.timeout(pending.timeoutMs)
2418
2473
  });
2419
2474
  resp.headers.forEach((v, k) => {
2420
- responseHeaders2[k] = v;
2475
+ if (k.toLowerCase() !== "set-cookie") responseHeaders2.push([k, v]);
2421
2476
  });
2477
+ for (const cookie of resp.headers.getSetCookie()) {
2478
+ responseHeaders2.push(["set-cookie", cookie]);
2479
+ }
2422
2480
  responseBody2 = new Uint8Array(await resp.arrayBuffer());
2423
2481
  this.logDebug(
2424
- `http_call response: ${resp.status} ${resp.statusText} numHeaders=${Object.keys(responseHeaders2).length} bodySize=${responseBody2.byteLength}`
2482
+ `http_call response: ${resp.status} ${resp.statusText} numHeaders=${responseHeaders2.length} bodySize=${responseBody2.byteLength}`
2425
2483
  );
2426
2484
  } catch (err) {
2427
2485
  const errMsg = `http_call failed for ${url}: ${String(err)}`;
2428
2486
  this.logDebug(errMsg);
2429
2487
  this.logs.push({ level: 3, message: `[host] ${errMsg}` });
2430
- responseHeaders2 = {};
2488
+ responseHeaders2 = [];
2431
2489
  responseBody2 = new Uint8Array(0);
2432
2490
  }
2433
- const numHeaders = Object.keys(responseHeaders2).length;
2491
+ const numHeaders = responseHeaders2.length;
2434
2492
  const bodySize = responseBody2.byteLength;
2435
2493
  this.hostFunctions.setHttpCallResponse(pending.tokenId, responseHeaders2, responseBody2);
2436
2494
  this.hostFunctions.resetStreamClosed();
@@ -2532,11 +2590,18 @@ var ProxyWasmRunner = class {
2532
2590
  this.isInitializing = false;
2533
2591
  }
2534
2592
  buildHookInvocation(hook, requestHeaders, responseHeaders, requestBody, responseBody) {
2593
+ const countEntries = (h) => {
2594
+ let n = 0;
2595
+ for (const v of Object.values(h)) {
2596
+ n += Array.isArray(v) ? v.length : 1;
2597
+ }
2598
+ return n;
2599
+ };
2535
2600
  switch (hook) {
2536
2601
  case "onRequestHeaders":
2537
2602
  return {
2538
2603
  exportName: "proxy_on_request_headers",
2539
- args: [this.currentContextId, Object.keys(requestHeaders).length, 0]
2604
+ args: [this.currentContextId, countEntries(requestHeaders), 0]
2540
2605
  };
2541
2606
  case "onRequestBody":
2542
2607
  return {
@@ -2546,7 +2611,7 @@ var ProxyWasmRunner = class {
2546
2611
  case "onResponseHeaders":
2547
2612
  return {
2548
2613
  exportName: "proxy_on_response_headers",
2549
- args: [this.currentContextId, Object.keys(responseHeaders).length, 0]
2614
+ args: [this.currentContextId, countEntries(responseHeaders), 0]
2550
2615
  };
2551
2616
  case "onResponseBody":
2552
2617
  return {
@@ -2661,9 +2726,12 @@ var ProxyWasmRunner = class {
2661
2726
  console.warn(entry.message);
2662
2727
  }
2663
2728
  /**
2664
- * Interface-compliant callFullFlow method
2729
+ * Interface-compliant callFullFlow method.
2730
+ *
2731
+ * The upstream response is generated at runtime by a real HTTP fetch
2732
+ * against `url` or by the built-in responder when `url === "built-in"`.
2665
2733
  */
2666
- async callFullFlow(url, method, headers, body, responseHeaders, responseBody, responseStatus, responseStatusText, properties, enforceProductionPropertyRules) {
2734
+ async callFullFlow(url, method, headers, body, properties, enforceProductionPropertyRules) {
2667
2735
  const call = {
2668
2736
  hook: "",
2669
2737
  // Not used in fullFlow
@@ -2672,12 +2740,6 @@ var ProxyWasmRunner = class {
2672
2740
  body,
2673
2741
  method
2674
2742
  },
2675
- response: {
2676
- headers: responseHeaders,
2677
- body: responseBody,
2678
- status: responseStatus,
2679
- statusText: responseStatusText
2680
- },
2681
2743
  properties,
2682
2744
  enforceProductionPropertyRules
2683
2745
  };
@@ -2861,6 +2923,8 @@ var HttpWasmRunner = class {
2861
2923
  this.stateManager = null;
2862
2924
  this.dotenvEnabled = true;
2863
2925
  this.dotenvPath = null;
2926
+ /** Pinned ports bypass PortManager allocation and must not be released back to it. */
2927
+ this.isPinnedPort = false;
2864
2928
  /** @deprecated Legacy sync support — remove when #[fastedge::http] is retired */
2865
2929
  this.isLegacySync = false;
2866
2930
  this.portManager = portManager;
@@ -2887,7 +2951,19 @@ var HttpWasmRunner = class {
2887
2951
  this.tempWasmPath = wasmPath;
2888
2952
  }
2889
2953
  this.isLegacySync = await isLegacySyncWasm(bufferOrPath);
2890
- this.port = await this.portManager.allocate();
2954
+ if (config?.httpPort !== void 0) {
2955
+ const pinned = config.httpPort;
2956
+ if (!await this.portManager.isPortFree(pinned)) {
2957
+ throw new Error(
2958
+ `fastedge-run port ${pinned} is not available \u2014 release it or choose a different httpPort in fastedge-config.test.json`
2959
+ );
2960
+ }
2961
+ this.port = pinned;
2962
+ this.isPinnedPort = true;
2963
+ } else {
2964
+ this.port = await this.portManager.allocate();
2965
+ this.isPinnedPort = false;
2966
+ }
2891
2967
  const wasi_http = !this.isLegacySync;
2892
2968
  const args = [
2893
2969
  "http",
@@ -2919,7 +2995,13 @@ var HttpWasmRunner = class {
2919
2995
  await this.waitForServerReady(this.port, timeout);
2920
2996
  }
2921
2997
  /**
2922
- * Execute an HTTP request through the WASM module
2998
+ * Execute an HTTP request through the WASM module.
2999
+ *
3000
+ * Redirects are surfaced verbatim — `fetch` is called with
3001
+ * `redirect: "manual"` so 3xx responses (status + `Location`) reach the
3002
+ * caller intact. This matches FastEdge edge behaviour, which returns
3003
+ * redirects to the client rather than following them server-side. See
3004
+ * `IWasmRunner.execute` for the public contract.
2923
3005
  */
2924
3006
  async execute(request) {
2925
3007
  if (!this.port || !this.process) {
@@ -2932,8 +3014,12 @@ var HttpWasmRunner = class {
2932
3014
  method: request.method,
2933
3015
  headers: request.headers,
2934
3016
  body: request.body || void 0,
2935
- signal: AbortSignal.timeout(3e4)
3017
+ signal: AbortSignal.timeout(3e4),
2936
3018
  // 30 second timeout
3019
+ // Surface 3xx responses verbatim so tests can assert on status/Location.
3020
+ // A FastEdge edge returns redirects to the client rather than following
3021
+ // them server-side; production parity requires the same here.
3022
+ redirect: "manual"
2937
3023
  });
2938
3024
  const arrayBuffer = await response.arrayBuffer();
2939
3025
  const bodyBuffer = Buffer.from(arrayBuffer);
@@ -2967,7 +3053,7 @@ var HttpWasmRunner = class {
2967
3053
  /**
2968
3054
  * Not supported for HTTP WASM (proxy-wasm only)
2969
3055
  */
2970
- async callFullFlow(_url, _method, _headers, _body, _responseHeaders, _responseBody, _responseStatus, _responseStatusText, _properties, _enforceProductionPropertyRules) {
3056
+ async callFullFlow(_url, _method, _headers, _body, _properties, _enforceProductionPropertyRules) {
2971
3057
  throw new Error(
2972
3058
  "callFullFlow() is not supported for HTTP WASM. Use execute() instead."
2973
3059
  );
@@ -3024,8 +3110,11 @@ var HttpWasmRunner = class {
3024
3110
  this.process = null;
3025
3111
  }
3026
3112
  if (this.port !== null) {
3027
- this.portManager.release(this.port);
3113
+ if (!this.isPinnedPort) {
3114
+ this.portManager.release(this.port);
3115
+ }
3028
3116
  this.port = null;
3117
+ this.isPinnedPort = false;
3029
3118
  }
3030
3119
  if (this.tempWasmPath) {
3031
3120
  await removeTempWasmFile(this.tempWasmPath);
@@ -3216,17 +3305,23 @@ ${recentLogs || "(no logs)"}`
3216
3305
  ];
3217
3306
  return binaryTypes.some((type) => contentType.toLowerCase().includes(type));
3218
3307
  }
3219
- /**
3220
- * Parse headers from fetch Headers object
3221
- */
3222
3308
  parseHeaders(headers) {
3223
- const result = {};
3224
- headers.forEach((value, key) => {
3225
- result[key] = value;
3226
- });
3227
- return result;
3309
+ return parseFetchHeaders(headers);
3228
3310
  }
3229
3311
  };
3312
+ function parseFetchHeaders(headers) {
3313
+ const result = {};
3314
+ headers.forEach((value, key) => {
3315
+ if (key.toLowerCase() !== "set-cookie") {
3316
+ result[key] = value;
3317
+ }
3318
+ });
3319
+ const setCookies = headers.getSetCookie();
3320
+ if (setCookies.length > 0) {
3321
+ result["set-cookie"] = setCookies;
3322
+ }
3323
+ return result;
3324
+ }
3230
3325
 
3231
3326
  // server/runner/PortManager.ts
3232
3327
  var import_net = require("net");
@@ -3242,6 +3337,9 @@ var PortManager = class {
3242
3337
  * This is necessary when multiple server processes run simultaneously —
3243
3338
  * each has its own PortManager with independent in-memory state, so
3244
3339
  * in-memory tracking alone is not enough to prevent cross-process conflicts.
3340
+ *
3341
+ * Public so pinned-port callers (HttpWasmRunner with RunnerConfig.httpPort)
3342
+ * can reuse the same OS-level check without going through allocate().
3245
3343
  */
3246
3344
  isPortFree(port) {
3247
3345
  return new Promise((resolve) => {
@@ -0,0 +1 @@
1
+ export * from "./runner/index.js";