@gcoredev/fastedge-test 0.0.1-beta.4 → 0.1.0-beta.3

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 (41) hide show
  1. package/README.md +6 -6
  2. package/dist/fastedge-cli/METADATA.json +1 -3
  3. package/dist/fastedge-cli/{fastedge-run-linux-x64-unkown → fastedge-run-darwin-arm64} +0 -0
  4. package/dist/fastedge-cli/fastedge-run-linux-x64 +0 -0
  5. package/dist/fastedge-cli/fastedge-run.exe +0 -0
  6. package/dist/frontend/assets/index-CEFjsU8e.js +35 -0
  7. package/dist/frontend/assets/index-DdlINQc_.css +1 -0
  8. package/dist/frontend/index.html +2 -2
  9. package/dist/lib/index.cjs +299 -107
  10. package/dist/lib/index.js +301 -110
  11. package/dist/lib/runner/HostFunctions.d.ts +8 -0
  12. package/dist/lib/runner/HttpWasmRunner.d.ts +34 -14
  13. package/dist/lib/runner/IStateManager.d.ts +3 -2
  14. package/dist/lib/runner/IWasmRunner.d.ts +16 -1
  15. package/dist/lib/runner/NullStateManager.d.ts +1 -0
  16. package/dist/lib/runner/PortManager.d.ts +17 -19
  17. package/dist/lib/runner/ProxyWasmRunner.d.ts +7 -0
  18. package/dist/lib/schemas/api.d.ts +8 -2
  19. package/dist/lib/schemas/config.d.ts +4 -1
  20. package/dist/lib/test-framework/index.cjs +301 -108
  21. package/dist/lib/test-framework/index.js +303 -111
  22. package/dist/lib/test-framework/suite-runner.d.ts +1 -1
  23. package/dist/server.js +30 -29
  24. package/docs/API.md +758 -360
  25. package/docs/DEBUGGER.md +151 -0
  26. package/docs/INDEX.md +111 -0
  27. package/docs/RUNNER.md +582 -0
  28. package/docs/TEST_CONFIG.md +242 -0
  29. package/docs/TEST_FRAMEWORK.md +384 -284
  30. package/docs/WEBSOCKET.md +499 -0
  31. package/docs/quickstart.md +171 -0
  32. package/llms.txt +72 -14
  33. package/package.json +15 -5
  34. package/schemas/api-config.schema.json +12 -5
  35. package/schemas/api-load.schema.json +11 -6
  36. package/schemas/{test-config.schema.json → fastedge-config.test.schema.json} +12 -5
  37. package/dist/fastedge-cli/.gitkeep +0 -0
  38. package/dist/frontend/assets/index-CnXStFTd.css +0 -1
  39. package/dist/frontend/assets/index-FR9Oqsow.js +0 -37
  40. package/docs/HYBRID_LOADING.md +0 -546
  41. package/docs/LOCAL_SERVER.md +0 -153
@@ -736,6 +736,8 @@ var HostFunctions = class {
736
736
  this.pendingHttpCall = null;
737
737
  this.httpCallResponse = null;
738
738
  this.streamClosed = false;
739
+ // Local response state (from proxy_send_local_response / send_http_response)
740
+ this.localResponse = null;
739
741
  this.memory = memory;
740
742
  this.propertyResolver = propertyResolver;
741
743
  this.propertyAccessControl = propertyAccessControl;
@@ -790,6 +792,16 @@ var HostFunctions = class {
790
792
  resetStreamClosed() {
791
793
  this.streamClosed = false;
792
794
  }
795
+ // Local response accessors (called by ProxyWasmRunner after callHook)
796
+ hasLocalResponse() {
797
+ return this.localResponse !== null;
798
+ }
799
+ getLocalResponse() {
800
+ return this.localResponse;
801
+ }
802
+ resetLocalResponse() {
803
+ this.localResponse = null;
804
+ }
793
805
  getRequestHeaders() {
794
806
  return this.requestHeaders;
795
807
  }
@@ -1050,15 +1062,21 @@ var HostFunctions = class {
1050
1062
  this.currentContextId = contextId || this.currentContextId;
1051
1063
  return 0 /* Ok */;
1052
1064
  },
1053
- proxy_send_local_response: (statusCode, statusCodePtr, statusCodeLen, bodyPtr, bodyLen, grpcStatus) => {
1065
+ proxy_send_local_response: (statusCode, statusCodePtr, statusCodeLen, bodyPtr, bodyLen, headerPairsPtr, headerPairsLen, grpcStatus) => {
1054
1066
  this.setLastHostCall(
1055
1067
  `proxy_send_local_response status=${statusCode} bodyLen=${bodyLen}`
1056
1068
  );
1057
1069
  const statusText = this.memory.readString(statusCodePtr, statusCodeLen);
1058
- const body = this.memory.readString(bodyPtr, bodyLen);
1070
+ const body = this.memory.readBytes(bodyPtr, bodyLen);
1071
+ if (headerPairsLen > 0) {
1072
+ const headerBytes = this.memory.readBytes(headerPairsPtr, headerPairsLen);
1073
+ const headers = HeaderManager.deserializeBinary(headerBytes);
1074
+ this.logDebug(`send_local_response headers (not merged): ${JSON.stringify(headers)}`);
1075
+ }
1076
+ this.localResponse = { statusCode, statusText, body };
1059
1077
  this.logs.push({
1060
1078
  level: 1,
1061
- message: `local_response status=${statusCode} ${statusText} body=${body} grpc=${grpcStatus}`
1079
+ message: `local_response status=${statusCode} ${statusText} bodyLen=${body.byteLength} grpc=${grpcStatus}`
1062
1080
  });
1063
1081
  return 0 /* Ok */;
1064
1082
  },
@@ -1638,7 +1656,6 @@ async function loadDotenvFiles(dotenvPath = ".") {
1638
1656
  // server/runner/ProxyWasmRunner.ts
1639
1657
  var textEncoder4 = new TextEncoder();
1640
1658
  var ProxyWasmRunner = class {
1641
- // Default to enabled
1642
1659
  constructor(fastEdgeConfig, dotenvEnabled = true) {
1643
1660
  this.module = null;
1644
1661
  // Compiled module (reused)
@@ -1652,6 +1669,8 @@ var ProxyWasmRunner = class {
1652
1669
  this.debug = process.env.PROXY_RUNNER_DEBUG === "1";
1653
1670
  this.stateManager = null;
1654
1671
  this.dotenvEnabled = true;
1672
+ // Default to enabled
1673
+ this.dotenvPath = ".";
1655
1674
  this.memory = new MemoryManager();
1656
1675
  this.propertyResolver = new PropertyResolver();
1657
1676
  this.propertyAccessControl = new PropertyAccessControl();
@@ -1686,7 +1705,7 @@ var ProxyWasmRunner = class {
1686
1705
  return;
1687
1706
  }
1688
1707
  try {
1689
- const dotenvConfig = await loadDotenvFiles(".");
1708
+ const dotenvConfig = await loadDotenvFiles(this.dotenvPath);
1690
1709
  if (dotenvConfig.secrets && Object.keys(dotenvConfig.secrets).length > 0) {
1691
1710
  const existingSecrets = this.secretStore.getAll();
1692
1711
  this.secretStore = new SecretStore({
@@ -1718,9 +1737,37 @@ var ProxyWasmRunner = class {
1718
1737
  console.error("Failed to load dotenv files:", error);
1719
1738
  }
1720
1739
  }
1740
+ /**
1741
+ * Apply dotenv settings to the running module without recompiling.
1742
+ * Resets SecretStore/Dictionary to empty, then re-loads dotenv files
1743
+ * using the new settings. The compiled WASM module is untouched.
1744
+ */
1745
+ async applyDotenv(enabled, dotenvPath) {
1746
+ this.dotenvEnabled = enabled;
1747
+ if (dotenvPath !== void 0) {
1748
+ this.dotenvPath = dotenvPath;
1749
+ }
1750
+ this.secretStore = new SecretStore();
1751
+ this.dictionary = new Dictionary();
1752
+ await this.loadDotenvIfEnabled();
1753
+ if (!enabled) {
1754
+ this.hostFunctions = new HostFunctions(
1755
+ this.memory,
1756
+ this.propertyResolver,
1757
+ this.propertyAccessControl,
1758
+ () => this.currentHook,
1759
+ this.debug,
1760
+ this.secretStore,
1761
+ this.dictionary
1762
+ );
1763
+ }
1764
+ }
1721
1765
  async load(bufferOrPath, config) {
1722
- if (config?.dotenvEnabled !== void 0) {
1723
- this.dotenvEnabled = config.dotenvEnabled;
1766
+ if (config?.dotenv?.enabled !== void 0) {
1767
+ this.dotenvEnabled = config.dotenv.enabled;
1768
+ }
1769
+ if (config?.dotenv?.path !== void 0) {
1770
+ this.dotenvPath = config.dotenv.path;
1724
1771
  }
1725
1772
  this.resetState();
1726
1773
  let buffer;
@@ -1791,6 +1838,25 @@ var ProxyWasmRunner = class {
1791
1838
  "system"
1792
1839
  );
1793
1840
  }
1841
+ if (results.onRequestHeaders.returnCode === 1 && this.hostFunctions.hasLocalResponse()) {
1842
+ const local = this.hostFunctions.getLocalResponse();
1843
+ const responseHeaders = results.onRequestHeaders.output.response.headers;
1844
+ this.hostFunctions.resetLocalResponse();
1845
+ const contentType = responseHeaders["content-type"] || "text/plain";
1846
+ const { body, isBase64 } = encodeLocalResponseBody(local.body, contentType);
1847
+ return {
1848
+ hookResults: results,
1849
+ finalResponse: {
1850
+ status: local.statusCode,
1851
+ statusText: local.statusText,
1852
+ headers: responseHeaders,
1853
+ body,
1854
+ contentType,
1855
+ isBase64
1856
+ },
1857
+ calculatedProperties: this.propertyResolver.getCalculatedProperties()
1858
+ };
1859
+ }
1794
1860
  const headersAfterRequestHeaders = results.onRequestHeaders.output.request.headers;
1795
1861
  const propertiesAfterRequestHeaders = results.onRequestHeaders.properties;
1796
1862
  this.logDebug(
@@ -1815,6 +1881,25 @@ var ProxyWasmRunner = class {
1815
1881
  "system"
1816
1882
  );
1817
1883
  }
1884
+ if (results.onRequestBody.returnCode === 1 && this.hostFunctions.hasLocalResponse()) {
1885
+ const local = this.hostFunctions.getLocalResponse();
1886
+ const responseHeaders = results.onRequestBody.output.response.headers;
1887
+ this.hostFunctions.resetLocalResponse();
1888
+ const contentType = responseHeaders["content-type"] || "text/plain";
1889
+ const { body, isBase64 } = encodeLocalResponseBody(local.body, contentType);
1890
+ return {
1891
+ hookResults: results,
1892
+ finalResponse: {
1893
+ status: local.statusCode,
1894
+ statusText: local.statusText,
1895
+ headers: responseHeaders,
1896
+ body,
1897
+ contentType,
1898
+ isBase64
1899
+ },
1900
+ calculatedProperties: this.propertyResolver.getCalculatedProperties()
1901
+ };
1902
+ }
1818
1903
  const modifiedRequestHeaders = results.onRequestBody.output.request.headers;
1819
1904
  const modifiedRequestBody = results.onRequestBody.output.request.body;
1820
1905
  const propertiesAfterRequestBody = results.onRequestBody.properties;
@@ -2003,6 +2088,7 @@ var ProxyWasmRunner = class {
2003
2088
  throw new Error("WASM module not loaded");
2004
2089
  }
2005
2090
  this.currentHook = this.getHookContext(call.hook);
2091
+ this.hostFunctions.resetLocalResponse();
2006
2092
  const imports = this.createImports();
2007
2093
  this.instance = await WebAssembly.instantiate(this.module, imports);
2008
2094
  const memory = this.instance.exports.memory;
@@ -2401,7 +2487,7 @@ var ProxyWasmRunner = class {
2401
2487
  /**
2402
2488
  * Not supported for Proxy-WASM (HTTP WASM only)
2403
2489
  */
2404
- async execute(request2) {
2490
+ async execute(request) {
2405
2491
  throw new Error(
2406
2492
  "execute() is not supported for Proxy-WASM. Use callHook() or callFullFlow() instead."
2407
2493
  );
@@ -2436,16 +2522,24 @@ function ensureNullTerminated(value) {
2436
2522
  }
2437
2523
  return value.endsWith("\0") ? value : `${value}\0`;
2438
2524
  }
2525
+ function encodeLocalResponseBody(body, contentType) {
2526
+ const isBinary = contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/") || contentType.includes("application/octet-stream") || contentType.includes("application/pdf") || contentType.includes("application/zip");
2527
+ if (isBinary) {
2528
+ return { body: Buffer.from(body).toString("base64"), isBase64: true };
2529
+ }
2530
+ return { body: new TextDecoder().decode(body), isBase64: false };
2531
+ }
2439
2532
 
2440
2533
  // server/runner/HttpWasmRunner.ts
2441
- import { spawn } from "child_process";
2442
- import * as http from "http";
2534
+ import { spawn, execSync as execSync2 } from "child_process";
2443
2535
 
2444
2536
  // server/utils/fastedge-cli.ts
2445
2537
  import { execSync } from "child_process";
2446
- import { existsSync } from "fs";
2447
- import { join } from "path";
2538
+ import { existsSync, chmodSync } from "fs";
2539
+ import { join, dirname } from "path";
2540
+ import { fileURLToPath } from "url";
2448
2541
  import os from "os";
2542
+ var _currentDir = dirname(fileURLToPath(import.meta.url));
2449
2543
  function getCliBinaryName() {
2450
2544
  switch (os.platform()) {
2451
2545
  case "win32":
@@ -2461,19 +2555,30 @@ function getCliBinaryName() {
2461
2555
  function getBundledCliPaths() {
2462
2556
  const binaryName = getCliBinaryName();
2463
2557
  return [
2464
- // Production: bundled server at dist/server.js
2465
- join(__dirname, "fastedge-cli", binaryName),
2558
+ // Installed npm package: dist/lib/index.js → dist/fastedge-cli/
2559
+ join(_currentDir, "..", "fastedge-cli", binaryName),
2560
+ // Production: bundled server at dist/server.js → dist/fastedge-cli/
2561
+ join(_currentDir, "fastedge-cli", binaryName),
2466
2562
  // Development/Tests: running from source
2467
- // __dirname might be server/utils/, so go up to project root
2468
- join(__dirname, "..", "..", "fastedge-run", binaryName),
2469
- // Alternative: if __dirname is already at project root
2470
- join(__dirname, "fastedge-run", binaryName)
2563
+ // _currentDir might be server/utils/, so go up to project root
2564
+ join(_currentDir, "..", "..", "fastedge-run", binaryName),
2565
+ // Alternative: if _currentDir is already at project root
2566
+ join(_currentDir, "fastedge-run", binaryName)
2471
2567
  ];
2472
2568
  }
2569
+ function ensureExecutable(binaryPath) {
2570
+ if (process.platform !== "win32") {
2571
+ try {
2572
+ chmodSync(binaryPath, 493);
2573
+ } catch {
2574
+ }
2575
+ }
2576
+ }
2473
2577
  async function findFastEdgeRunCli() {
2474
2578
  const envPath = process.env.FASTEDGE_RUN_PATH;
2475
2579
  if (envPath) {
2476
2580
  if (existsSync(envPath)) {
2581
+ ensureExecutable(envPath);
2477
2582
  return envPath;
2478
2583
  } else {
2479
2584
  throw new Error(
@@ -2483,6 +2588,7 @@ async function findFastEdgeRunCli() {
2483
2588
  }
2484
2589
  for (const bundledPath of getBundledCliPaths()) {
2485
2590
  if (existsSync(bundledPath)) {
2591
+ ensureExecutable(bundledPath);
2486
2592
  return bundledPath;
2487
2593
  }
2488
2594
  }
@@ -2530,9 +2636,12 @@ var HttpWasmRunner = class {
2530
2636
  this.port = null;
2531
2637
  this.cliPath = null;
2532
2638
  this.tempWasmPath = null;
2639
+ this.currentWasmPath = null;
2640
+ // resolved path used when spawning
2533
2641
  this.logs = [];
2534
2642
  this.stateManager = null;
2535
2643
  this.dotenvEnabled = true;
2644
+ this.dotenvPath = null;
2536
2645
  this.portManager = portManager;
2537
2646
  this.dotenvEnabled = dotenvEnabled;
2538
2647
  }
@@ -2540,8 +2649,11 @@ var HttpWasmRunner = class {
2540
2649
  * Load WASM binary and spawn fastedge-run process
2541
2650
  */
2542
2651
  async load(bufferOrPath, config) {
2543
- if (config?.dotenvEnabled !== void 0) {
2544
- this.dotenvEnabled = config.dotenvEnabled;
2652
+ if (config?.dotenv?.enabled !== void 0) {
2653
+ this.dotenvEnabled = config.dotenv.enabled;
2654
+ }
2655
+ if (config?.dotenv?.path !== void 0) {
2656
+ this.dotenvPath = config.dotenv.path;
2545
2657
  }
2546
2658
  await this.cleanup();
2547
2659
  this.cliPath = await findFastEdgeRunCli();
@@ -2553,7 +2665,7 @@ var HttpWasmRunner = class {
2553
2665
  wasmPath = await writeTempWasmFile(bufferOrPath);
2554
2666
  this.tempWasmPath = wasmPath;
2555
2667
  }
2556
- this.port = this.portManager.allocate();
2668
+ this.port = await this.portManager.allocate();
2557
2669
  const args = [
2558
2670
  "http",
2559
2671
  "-p",
@@ -2564,8 +2676,13 @@ var HttpWasmRunner = class {
2564
2676
  "true"
2565
2677
  ];
2566
2678
  if (this.dotenvEnabled) {
2567
- args.push("--dotenv");
2679
+ if (this.dotenvPath) {
2680
+ args.push("--dotenv", this.dotenvPath);
2681
+ } else {
2682
+ args.push("--dotenv");
2683
+ }
2568
2684
  }
2685
+ this.currentWasmPath = wasmPath;
2569
2686
  this.process = spawn(this.cliPath, args, {
2570
2687
  stdio: ["ignore", "pipe", "pipe"],
2571
2688
  env: {
@@ -2581,17 +2698,17 @@ var HttpWasmRunner = class {
2581
2698
  /**
2582
2699
  * Execute an HTTP request through the WASM module
2583
2700
  */
2584
- async execute(request2) {
2701
+ async execute(request) {
2585
2702
  if (!this.port || !this.process) {
2586
2703
  throw new Error("HttpWasmRunner not loaded. Call load() first.");
2587
2704
  }
2588
2705
  this.logs = [];
2589
2706
  try {
2590
- const url = `http://localhost:${this.port}${request2.path}`;
2707
+ const url = `http://localhost:${this.port}${request.path}`;
2591
2708
  const response = await fetch(url, {
2592
- method: request2.method,
2593
- headers: request2.headers,
2594
- body: request2.body || void 0,
2709
+ method: request.method,
2710
+ headers: request.headers,
2711
+ body: request.body || void 0,
2595
2712
  signal: AbortSignal.timeout(3e4)
2596
2713
  // 30 second timeout
2597
2714
  });
@@ -2619,7 +2736,7 @@ var HttpWasmRunner = class {
2619
2736
  /**
2620
2737
  * Not supported for HTTP WASM (proxy-wasm only)
2621
2738
  */
2622
- async callHook(hookCall) {
2739
+ async callHook(_hookCall) {
2623
2740
  throw new Error(
2624
2741
  "callHook() is not supported for HTTP WASM. Use execute() instead."
2625
2742
  );
@@ -2627,11 +2744,53 @@ var HttpWasmRunner = class {
2627
2744
  /**
2628
2745
  * Not supported for HTTP WASM (proxy-wasm only)
2629
2746
  */
2630
- async callFullFlow(url, method, headers, body, responseHeaders, responseBody, responseStatus, responseStatusText, properties, enforceProductionPropertyRules) {
2747
+ async callFullFlow(_url, _method, _headers, _body, _responseHeaders, _responseBody, _responseStatus, _responseStatusText, _properties, _enforceProductionPropertyRules) {
2631
2748
  throw new Error(
2632
2749
  "callFullFlow() is not supported for HTTP WASM. Use execute() instead."
2633
2750
  );
2634
2751
  }
2752
+ /**
2753
+ * Apply dotenv settings by restarting the fastedge-run process.
2754
+ * The WASM file is not re-read; only the --dotenv flag changes.
2755
+ */
2756
+ async applyDotenv(enabled, dotenvPath) {
2757
+ this.dotenvEnabled = enabled;
2758
+ if (dotenvPath !== void 0) {
2759
+ this.dotenvPath = dotenvPath;
2760
+ }
2761
+ if (!this.process || !this.currentWasmPath || !this.cliPath || this.port === null) {
2762
+ return;
2763
+ }
2764
+ await this.killProcess();
2765
+ this.process = null;
2766
+ const args = [
2767
+ "http",
2768
+ "-p",
2769
+ this.port.toString(),
2770
+ "-w",
2771
+ this.currentWasmPath,
2772
+ "--wasi-http",
2773
+ "true"
2774
+ ];
2775
+ if (this.dotenvEnabled) {
2776
+ if (this.dotenvPath) {
2777
+ args.push("--dotenv", this.dotenvPath);
2778
+ } else {
2779
+ args.push("--dotenv");
2780
+ }
2781
+ }
2782
+ this.process = spawn(this.cliPath, args, {
2783
+ stdio: ["ignore", "pipe", "pipe"],
2784
+ env: {
2785
+ RUST_LOG: "info",
2786
+ ...process.env
2787
+ }
2788
+ });
2789
+ this.setupLogCapture();
2790
+ this.setupErrorHandlers();
2791
+ const timeout = process.env.NODE_ENV === "test" || process.env.VITEST ? 2e4 : 1e4;
2792
+ await this.waitForServerReady(this.port, timeout);
2793
+ }
2635
2794
  /**
2636
2795
  * Clean up resources
2637
2796
  */
@@ -2656,12 +2815,41 @@ var HttpWasmRunner = class {
2656
2815
  getType() {
2657
2816
  return "http-wasm";
2658
2817
  }
2818
+ /**
2819
+ * Get the port the fastedge-run HTTP server is listening on
2820
+ */
2821
+ getPort() {
2822
+ return this.port;
2823
+ }
2659
2824
  /**
2660
2825
  * Set state manager
2661
2826
  */
2662
2827
  setStateManager(stateManager) {
2663
2828
  this.stateManager = stateManager;
2664
2829
  }
2830
+ /**
2831
+ * Parse log level from a process output line.
2832
+ * Matches bare prefixes (e.g. "INFO target > msg") and bracketed prefixes (e.g. "[INFO] msg").
2833
+ * Falls back to the provided default if no known level prefix is found.
2834
+ */
2835
+ parseLogLevel(message, fallback) {
2836
+ const match = message.trimStart().match(/^\[?(\w+)\]?/);
2837
+ const prefix = match?.[1]?.toUpperCase();
2838
+ switch (prefix) {
2839
+ case "TRACE":
2840
+ return 0;
2841
+ case "DEBUG":
2842
+ return 1;
2843
+ case "INFO":
2844
+ return 2;
2845
+ case "WARN":
2846
+ return 3;
2847
+ case "ERROR":
2848
+ return 4;
2849
+ default:
2850
+ return fallback;
2851
+ }
2852
+ }
2665
2853
  /**
2666
2854
  * Setup log capture from process stdout/stderr
2667
2855
  */
@@ -2670,13 +2858,17 @@ var HttpWasmRunner = class {
2670
2858
  this.process.stdout?.on("data", (data) => {
2671
2859
  const message = data.toString().trim();
2672
2860
  if (message) {
2673
- this.logs.push({ level: 2, message });
2861
+ const log = { level: this.parseLogLevel(message, 2), message };
2862
+ this.logs.push(log);
2863
+ this.stateManager?.emitHttpWasmLog(log);
2674
2864
  }
2675
2865
  });
2676
2866
  this.process.stderr?.on("data", (data) => {
2677
2867
  const message = data.toString().trim();
2678
2868
  if (message) {
2679
- this.logs.push({ level: 4, message });
2869
+ const log = { level: this.parseLogLevel(message, 4), message };
2870
+ this.logs.push(log);
2871
+ this.stateManager?.emitHttpWasmLog(log);
2680
2872
  }
2681
2873
  });
2682
2874
  }
@@ -2706,66 +2898,54 @@ var HttpWasmRunner = class {
2706
2898
  });
2707
2899
  }
2708
2900
  /**
2709
- * Probe port with a single HTTP GET, returning true if any response is received.
2710
- * Uses Node.js http module with explicit content-length: 0 so that wasi-http
2711
- * runtimes (fastedge-run) immediately signal EOF to the WASM request body
2712
- * stream. Without this, newer fastedge-run builds hold the body stream open
2713
- * on keep-alive connections, causing the WASM's event.request.text() to hang
2714
- * indefinitely and never send a response.
2715
- */
2716
- probePort(port, timeoutMs) {
2717
- return new Promise((resolve) => {
2718
- const req = http.request(
2719
- {
2720
- hostname: "localhost",
2721
- port,
2722
- path: "/",
2723
- method: "GET",
2724
- headers: {
2725
- "content-length": "0",
2726
- connection: "close"
2727
- }
2728
- },
2729
- (res) => {
2730
- res.resume();
2731
- resolve(true);
2732
- }
2733
- );
2734
- req.setTimeout(timeoutMs, () => {
2735
- req.destroy();
2736
- resolve(false);
2737
- });
2738
- req.on("error", () => resolve(false));
2739
- req.end();
2740
- });
2741
- }
2742
- /**
2743
- * Wait for server to be ready by polling
2901
+ * Wait for the fastedge-run HTTP server to be ready by watching process logs
2902
+ * for the "Listening on" message.
2903
+ *
2904
+ * We intentionally avoid HTTP probing here. HTTP probes trigger WASM execution
2905
+ * (the app calls event.request.text() to read the body), which can hang for
2906
+ * many seconds in CI due to WASM JIT compilation on the first request, or
2907
+ * because newer fastedge-run builds hold the body stream open regardless of
2908
+ * content-length. Watching logs avoids all of this: fastedge-run emits
2909
+ * "Listening on http://127.0.0.1:<port>" as soon as the HTTP listener is bound,
2910
+ * before any WASM execution occurs.
2744
2911
  */
2745
- async waitForServerReady(port, timeoutMs) {
2912
+ waitForServerReady(port, timeoutMs) {
2746
2913
  const startTime = Date.now();
2747
- while (Date.now() - startTime < timeoutMs) {
2748
- const ready = await this.probePort(port, 5e3);
2749
- if (ready) return;
2750
- if (this.process && this.process.exitCode !== null) {
2751
- throw new Error(
2752
- `FastEdge-run process exited with code ${this.process.exitCode} before server started`
2753
- );
2754
- }
2755
- await new Promise((resolve) => setTimeout(resolve, 100));
2756
- }
2757
- const processInfo = this.process ? `Process state: exitCode=${this.process.exitCode}, killed=${this.process.killed}, pid=${this.process.pid}` : "Process is null";
2758
- const recentLogs = this.logs.slice(-5).map((l) => `[${l.level}] ${l.message}`).join("\n");
2759
- throw new Error(
2760
- `FastEdge-run server did not start within ${timeoutMs}ms on port ${port}
2914
+ return new Promise((resolve, reject) => {
2915
+ const check = () => {
2916
+ if (this.process && this.process.exitCode !== null) {
2917
+ return reject(
2918
+ new Error(
2919
+ `FastEdge-run process exited with code ${this.process.exitCode} before server started`
2920
+ )
2921
+ );
2922
+ }
2923
+ if (this.logs.some((l) => l.message.includes("Listening on"))) {
2924
+ return resolve();
2925
+ }
2926
+ if (Date.now() - startTime >= timeoutMs) {
2927
+ const processInfo = this.process ? `Process state: exitCode=${this.process.exitCode}, killed=${this.process.killed}, pid=${this.process.pid}` : "Process is null";
2928
+ const recentLogs = this.logs.slice(-5).map((l) => `[${l.level}] ${l.message}`).join("\n");
2929
+ return reject(
2930
+ new Error(
2931
+ `FastEdge-run server did not start within ${timeoutMs}ms on port ${port}
2761
2932
  ${processInfo}
2762
2933
  Recent logs:
2763
2934
  ${recentLogs || "(no logs)"}`
2764
- );
2935
+ )
2936
+ );
2937
+ }
2938
+ setTimeout(check, 50);
2939
+ };
2940
+ check();
2941
+ });
2765
2942
  }
2766
2943
  /**
2767
- * Kill the process gracefully (SIGINT) with fallback to SIGKILL
2768
- * FastEdge-run responds to SIGINT for graceful shutdown
2944
+ * Kill the process gracefully (SIGINT) with platform-specific force-kill fallback.
2945
+ * SIGINT is sent first on all platforms — Node.js translates it for Windows.
2946
+ * If the process does not exit within 2 seconds:
2947
+ * - Windows: taskkill /F /T to terminate the process tree
2948
+ * - Unix: SIGKILL
2769
2949
  */
2770
2950
  async killProcess() {
2771
2951
  if (!this.process) return;
@@ -2776,8 +2956,18 @@ ${recentLogs || "(no logs)"}`
2776
2956
  }
2777
2957
  this.process.kill("SIGINT");
2778
2958
  const timeout = setTimeout(() => {
2779
- if (this.process && !this.process.killed) {
2780
- this.process.kill("SIGKILL");
2959
+ if (this.process && this.process.exitCode === null && this.process.signalCode === null) {
2960
+ if (process.platform === "win32") {
2961
+ const pid = this.process.pid;
2962
+ if (pid) {
2963
+ try {
2964
+ execSync2(`taskkill /F /T /PID ${pid}`);
2965
+ } catch {
2966
+ }
2967
+ }
2968
+ } else {
2969
+ this.process.kill("SIGKILL");
2970
+ }
2781
2971
  }
2782
2972
  resolve();
2783
2973
  }, 2e3);
@@ -2800,9 +2990,7 @@ ${recentLogs || "(no logs)"}`
2800
2990
  "application/zip",
2801
2991
  "application/gzip"
2802
2992
  ];
2803
- return binaryTypes.some(
2804
- (type) => contentType.toLowerCase().includes(type)
2805
- );
2993
+ return binaryTypes.some((type) => contentType.toLowerCase().includes(type));
2806
2994
  }
2807
2995
  /**
2808
2996
  * Parse headers from fetch Headers object
@@ -2817,6 +3005,7 @@ ${recentLogs || "(no logs)"}`
2817
3005
  };
2818
3006
 
2819
3007
  // server/runner/PortManager.ts
3008
+ import { createServer } from "net";
2820
3009
  var PortManager = class {
2821
3010
  constructor() {
2822
3011
  this.minPort = 8100;
@@ -2824,18 +3013,31 @@ var PortManager = class {
2824
3013
  this.allocatedPorts = /* @__PURE__ */ new Set();
2825
3014
  this.lastAllocatedPort = this.minPort - 1;
2826
3015
  }
2827
- // Track last allocated port for sequential allocation
2828
3016
  /**
2829
- * Allocate an available port from the pool
2830
- * Sequential allocation to avoid reusing recently released ports (TCP TIME_WAIT)
2831
- * Synchronous to prevent race conditions when allocating in parallel
3017
+ * Check whether a port is actually free at the OS level.
3018
+ * This is necessary when multiple server processes run simultaneously
3019
+ * each has its own PortManager with independent in-memory state, so
3020
+ * in-memory tracking alone is not enough to prevent cross-process conflicts.
3021
+ */
3022
+ isPortFree(port) {
3023
+ return new Promise((resolve) => {
3024
+ const server = createServer();
3025
+ server.once("error", () => resolve(false));
3026
+ server.once("listening", () => server.close(() => resolve(true)));
3027
+ server.listen(port, "127.0.0.1");
3028
+ });
3029
+ }
3030
+ /**
3031
+ * Allocate an available port from the pool.
3032
+ * Combines in-memory tracking (avoids TCP TIME_WAIT reuse within this process)
3033
+ * with an OS-level check (avoids cross-process collisions).
2832
3034
  * @returns The allocated port number
2833
3035
  * @throws Error if no ports are available
2834
3036
  */
2835
- allocate() {
3037
+ async allocate() {
2836
3038
  for (let offset = 1; offset <= this.maxPort - this.minPort + 1; offset++) {
2837
3039
  const port = this.minPort + (this.lastAllocatedPort - this.minPort + offset) % (this.maxPort - this.minPort + 1);
2838
- if (!this.allocatedPorts.has(port)) {
3040
+ if (!this.allocatedPorts.has(port) && await this.isPortFree(port)) {
2839
3041
  this.allocatedPorts.add(port);
2840
3042
  this.lastAllocatedPort = port;
2841
3043
  return port;
@@ -2847,32 +3049,19 @@ var PortManager = class {
2847
3049
  }
2848
3050
  /**
2849
3051
  * Release a previously allocated port back to the pool
2850
- * @param port The port number to release
2851
3052
  */
2852
3053
  release(port) {
2853
3054
  this.allocatedPorts.delete(port);
2854
3055
  }
2855
- /**
2856
- * Get the number of currently allocated ports
2857
- */
2858
3056
  getAllocatedCount() {
2859
3057
  return this.allocatedPorts.size;
2860
3058
  }
2861
- /**
2862
- * Get the number of available ports
2863
- */
2864
3059
  getAvailableCount() {
2865
3060
  return this.maxPort - this.minPort + 1 - this.allocatedPorts.size;
2866
3061
  }
2867
- /**
2868
- * Check if a specific port is allocated
2869
- */
2870
3062
  isAllocated(port) {
2871
3063
  return this.allocatedPorts.has(port);
2872
3064
  }
2873
- /**
2874
- * Reset all allocations (useful for testing)
2875
- */
2876
3065
  reset() {
2877
3066
  this.allocatedPorts.clear();
2878
3067
  }
@@ -2917,9 +3106,9 @@ async function createRunnerFromBuffer(buffer, config) {
2917
3106
  const wasmType = config?.runnerType ?? await detectWasmType(buffer);
2918
3107
  let runner;
2919
3108
  if (wasmType === "http-wasm") {
2920
- runner = new HttpWasmRunner(new PortManager(), config?.dotenvEnabled ?? false);
3109
+ runner = new HttpWasmRunner(new PortManager(), config?.dotenv?.enabled ?? false);
2921
3110
  } else {
2922
- runner = new ProxyWasmRunner(void 0, config?.dotenvEnabled ?? false);
3111
+ runner = new ProxyWasmRunner(void 0, config?.dotenv?.enabled ?? false);
2923
3112
  }
2924
3113
  await runner.load(buffer, config);
2925
3114
  return runner;
@@ -2948,7 +3137,10 @@ var TestConfigSchema = z.object({
2948
3137
  request: RequestConfigSchema,
2949
3138
  response: ResponseConfigSchema.optional(),
2950
3139
  properties: z.record(z.string(), z.unknown()).optional().default({}),
2951
- dotenvEnabled: z.boolean().optional().default(true)
3140
+ dotenv: z.object({
3141
+ enabled: z.boolean().optional(),
3142
+ path: z.string().optional()
3143
+ }).optional()
2952
3144
  });
2953
3145
 
2954
3146
  // server/test-framework/suite-runner.ts