@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
@@ -1,3 +1,4 @@
1
+ var __importMetaUrl = require('url').pathToFileURL(__filename).href;
1
2
  "use strict";
2
3
  var __create = Object.create;
3
4
  var __defProp = Object.defineProperty;
@@ -789,6 +790,8 @@ var HostFunctions = class {
789
790
  this.pendingHttpCall = null;
790
791
  this.httpCallResponse = null;
791
792
  this.streamClosed = false;
793
+ // Local response state (from proxy_send_local_response / send_http_response)
794
+ this.localResponse = null;
792
795
  this.memory = memory;
793
796
  this.propertyResolver = propertyResolver;
794
797
  this.propertyAccessControl = propertyAccessControl;
@@ -843,6 +846,16 @@ var HostFunctions = class {
843
846
  resetStreamClosed() {
844
847
  this.streamClosed = false;
845
848
  }
849
+ // Local response accessors (called by ProxyWasmRunner after callHook)
850
+ hasLocalResponse() {
851
+ return this.localResponse !== null;
852
+ }
853
+ getLocalResponse() {
854
+ return this.localResponse;
855
+ }
856
+ resetLocalResponse() {
857
+ this.localResponse = null;
858
+ }
846
859
  getRequestHeaders() {
847
860
  return this.requestHeaders;
848
861
  }
@@ -1103,15 +1116,21 @@ var HostFunctions = class {
1103
1116
  this.currentContextId = contextId || this.currentContextId;
1104
1117
  return 0 /* Ok */;
1105
1118
  },
1106
- proxy_send_local_response: (statusCode, statusCodePtr, statusCodeLen, bodyPtr, bodyLen, grpcStatus) => {
1119
+ proxy_send_local_response: (statusCode, statusCodePtr, statusCodeLen, bodyPtr, bodyLen, headerPairsPtr, headerPairsLen, grpcStatus) => {
1107
1120
  this.setLastHostCall(
1108
1121
  `proxy_send_local_response status=${statusCode} bodyLen=${bodyLen}`
1109
1122
  );
1110
1123
  const statusText = this.memory.readString(statusCodePtr, statusCodeLen);
1111
- const body = this.memory.readString(bodyPtr, bodyLen);
1124
+ const body = this.memory.readBytes(bodyPtr, bodyLen);
1125
+ if (headerPairsLen > 0) {
1126
+ const headerBytes = this.memory.readBytes(headerPairsPtr, headerPairsLen);
1127
+ const headers = HeaderManager.deserializeBinary(headerBytes);
1128
+ this.logDebug(`send_local_response headers (not merged): ${JSON.stringify(headers)}`);
1129
+ }
1130
+ this.localResponse = { statusCode, statusText, body };
1112
1131
  this.logs.push({
1113
1132
  level: 1,
1114
- message: `local_response status=${statusCode} ${statusText} body=${body} grpc=${grpcStatus}`
1133
+ message: `local_response status=${statusCode} ${statusText} bodyLen=${body.byteLength} grpc=${grpcStatus}`
1115
1134
  });
1116
1135
  return 0 /* Ok */;
1117
1136
  },
@@ -1691,7 +1710,6 @@ async function loadDotenvFiles(dotenvPath = ".") {
1691
1710
  // server/runner/ProxyWasmRunner.ts
1692
1711
  var textEncoder4 = new TextEncoder();
1693
1712
  var ProxyWasmRunner = class {
1694
- // Default to enabled
1695
1713
  constructor(fastEdgeConfig, dotenvEnabled = true) {
1696
1714
  this.module = null;
1697
1715
  // Compiled module (reused)
@@ -1705,6 +1723,8 @@ var ProxyWasmRunner = class {
1705
1723
  this.debug = process.env.PROXY_RUNNER_DEBUG === "1";
1706
1724
  this.stateManager = null;
1707
1725
  this.dotenvEnabled = true;
1726
+ // Default to enabled
1727
+ this.dotenvPath = ".";
1708
1728
  this.memory = new MemoryManager();
1709
1729
  this.propertyResolver = new PropertyResolver();
1710
1730
  this.propertyAccessControl = new PropertyAccessControl();
@@ -1739,7 +1759,7 @@ var ProxyWasmRunner = class {
1739
1759
  return;
1740
1760
  }
1741
1761
  try {
1742
- const dotenvConfig = await loadDotenvFiles(".");
1762
+ const dotenvConfig = await loadDotenvFiles(this.dotenvPath);
1743
1763
  if (dotenvConfig.secrets && Object.keys(dotenvConfig.secrets).length > 0) {
1744
1764
  const existingSecrets = this.secretStore.getAll();
1745
1765
  this.secretStore = new SecretStore({
@@ -1771,9 +1791,37 @@ var ProxyWasmRunner = class {
1771
1791
  console.error("Failed to load dotenv files:", error);
1772
1792
  }
1773
1793
  }
1794
+ /**
1795
+ * Apply dotenv settings to the running module without recompiling.
1796
+ * Resets SecretStore/Dictionary to empty, then re-loads dotenv files
1797
+ * using the new settings. The compiled WASM module is untouched.
1798
+ */
1799
+ async applyDotenv(enabled, dotenvPath) {
1800
+ this.dotenvEnabled = enabled;
1801
+ if (dotenvPath !== void 0) {
1802
+ this.dotenvPath = dotenvPath;
1803
+ }
1804
+ this.secretStore = new SecretStore();
1805
+ this.dictionary = new Dictionary();
1806
+ await this.loadDotenvIfEnabled();
1807
+ if (!enabled) {
1808
+ this.hostFunctions = new HostFunctions(
1809
+ this.memory,
1810
+ this.propertyResolver,
1811
+ this.propertyAccessControl,
1812
+ () => this.currentHook,
1813
+ this.debug,
1814
+ this.secretStore,
1815
+ this.dictionary
1816
+ );
1817
+ }
1818
+ }
1774
1819
  async load(bufferOrPath, config) {
1775
- if (config?.dotenvEnabled !== void 0) {
1776
- this.dotenvEnabled = config.dotenvEnabled;
1820
+ if (config?.dotenv?.enabled !== void 0) {
1821
+ this.dotenvEnabled = config.dotenv.enabled;
1822
+ }
1823
+ if (config?.dotenv?.path !== void 0) {
1824
+ this.dotenvPath = config.dotenv.path;
1777
1825
  }
1778
1826
  this.resetState();
1779
1827
  let buffer;
@@ -1844,6 +1892,25 @@ var ProxyWasmRunner = class {
1844
1892
  "system"
1845
1893
  );
1846
1894
  }
1895
+ if (results.onRequestHeaders.returnCode === 1 && this.hostFunctions.hasLocalResponse()) {
1896
+ const local = this.hostFunctions.getLocalResponse();
1897
+ const responseHeaders = results.onRequestHeaders.output.response.headers;
1898
+ this.hostFunctions.resetLocalResponse();
1899
+ const contentType = responseHeaders["content-type"] || "text/plain";
1900
+ const { body, isBase64 } = encodeLocalResponseBody(local.body, contentType);
1901
+ return {
1902
+ hookResults: results,
1903
+ finalResponse: {
1904
+ status: local.statusCode,
1905
+ statusText: local.statusText,
1906
+ headers: responseHeaders,
1907
+ body,
1908
+ contentType,
1909
+ isBase64
1910
+ },
1911
+ calculatedProperties: this.propertyResolver.getCalculatedProperties()
1912
+ };
1913
+ }
1847
1914
  const headersAfterRequestHeaders = results.onRequestHeaders.output.request.headers;
1848
1915
  const propertiesAfterRequestHeaders = results.onRequestHeaders.properties;
1849
1916
  this.logDebug(
@@ -1868,6 +1935,25 @@ var ProxyWasmRunner = class {
1868
1935
  "system"
1869
1936
  );
1870
1937
  }
1938
+ if (results.onRequestBody.returnCode === 1 && this.hostFunctions.hasLocalResponse()) {
1939
+ const local = this.hostFunctions.getLocalResponse();
1940
+ const responseHeaders = results.onRequestBody.output.response.headers;
1941
+ this.hostFunctions.resetLocalResponse();
1942
+ const contentType = responseHeaders["content-type"] || "text/plain";
1943
+ const { body, isBase64 } = encodeLocalResponseBody(local.body, contentType);
1944
+ return {
1945
+ hookResults: results,
1946
+ finalResponse: {
1947
+ status: local.statusCode,
1948
+ statusText: local.statusText,
1949
+ headers: responseHeaders,
1950
+ body,
1951
+ contentType,
1952
+ isBase64
1953
+ },
1954
+ calculatedProperties: this.propertyResolver.getCalculatedProperties()
1955
+ };
1956
+ }
1871
1957
  const modifiedRequestHeaders = results.onRequestBody.output.request.headers;
1872
1958
  const modifiedRequestBody = results.onRequestBody.output.request.body;
1873
1959
  const propertiesAfterRequestBody = results.onRequestBody.properties;
@@ -2056,6 +2142,7 @@ var ProxyWasmRunner = class {
2056
2142
  throw new Error("WASM module not loaded");
2057
2143
  }
2058
2144
  this.currentHook = this.getHookContext(call.hook);
2145
+ this.hostFunctions.resetLocalResponse();
2059
2146
  const imports = this.createImports();
2060
2147
  this.instance = await WebAssembly.instantiate(this.module, imports);
2061
2148
  const memory = this.instance.exports.memory;
@@ -2454,7 +2541,7 @@ var ProxyWasmRunner = class {
2454
2541
  /**
2455
2542
  * Not supported for Proxy-WASM (HTTP WASM only)
2456
2543
  */
2457
- async execute(request2) {
2544
+ async execute(request) {
2458
2545
  throw new Error(
2459
2546
  "execute() is not supported for Proxy-WASM. Use callHook() or callFullFlow() instead."
2460
2547
  );
@@ -2489,16 +2576,24 @@ function ensureNullTerminated(value) {
2489
2576
  }
2490
2577
  return value.endsWith("\0") ? value : `${value}\0`;
2491
2578
  }
2579
+ function encodeLocalResponseBody(body, contentType) {
2580
+ const isBinary = contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/") || contentType.includes("application/octet-stream") || contentType.includes("application/pdf") || contentType.includes("application/zip");
2581
+ if (isBinary) {
2582
+ return { body: Buffer.from(body).toString("base64"), isBase64: true };
2583
+ }
2584
+ return { body: new TextDecoder().decode(body), isBase64: false };
2585
+ }
2492
2586
 
2493
2587
  // server/runner/HttpWasmRunner.ts
2494
2588
  var import_child_process2 = require("child_process");
2495
- var http = __toESM(require("http"));
2496
2589
 
2497
2590
  // server/utils/fastedge-cli.ts
2498
2591
  var import_child_process = require("child_process");
2499
2592
  var import_fs = require("fs");
2500
2593
  var import_path2 = require("path");
2594
+ var import_url = require("url");
2501
2595
  var import_os = __toESM(require("os"));
2596
+ var _currentDir = (0, import_path2.dirname)((0, import_url.fileURLToPath)(__importMetaUrl));
2502
2597
  function getCliBinaryName() {
2503
2598
  switch (import_os.default.platform()) {
2504
2599
  case "win32":
@@ -2514,19 +2609,30 @@ function getCliBinaryName() {
2514
2609
  function getBundledCliPaths() {
2515
2610
  const binaryName = getCliBinaryName();
2516
2611
  return [
2517
- // Production: bundled server at dist/server.js
2518
- (0, import_path2.join)(__dirname, "fastedge-cli", binaryName),
2612
+ // Installed npm package: dist/lib/index.js → dist/fastedge-cli/
2613
+ (0, import_path2.join)(_currentDir, "..", "fastedge-cli", binaryName),
2614
+ // Production: bundled server at dist/server.js → dist/fastedge-cli/
2615
+ (0, import_path2.join)(_currentDir, "fastedge-cli", binaryName),
2519
2616
  // Development/Tests: running from source
2520
- // __dirname might be server/utils/, so go up to project root
2521
- (0, import_path2.join)(__dirname, "..", "..", "fastedge-run", binaryName),
2522
- // Alternative: if __dirname is already at project root
2523
- (0, import_path2.join)(__dirname, "fastedge-run", binaryName)
2617
+ // _currentDir might be server/utils/, so go up to project root
2618
+ (0, import_path2.join)(_currentDir, "..", "..", "fastedge-run", binaryName),
2619
+ // Alternative: if _currentDir is already at project root
2620
+ (0, import_path2.join)(_currentDir, "fastedge-run", binaryName)
2524
2621
  ];
2525
2622
  }
2623
+ function ensureExecutable(binaryPath) {
2624
+ if (process.platform !== "win32") {
2625
+ try {
2626
+ (0, import_fs.chmodSync)(binaryPath, 493);
2627
+ } catch {
2628
+ }
2629
+ }
2630
+ }
2526
2631
  async function findFastEdgeRunCli() {
2527
2632
  const envPath = process.env.FASTEDGE_RUN_PATH;
2528
2633
  if (envPath) {
2529
2634
  if ((0, import_fs.existsSync)(envPath)) {
2635
+ ensureExecutable(envPath);
2530
2636
  return envPath;
2531
2637
  } else {
2532
2638
  throw new Error(
@@ -2536,6 +2642,7 @@ async function findFastEdgeRunCli() {
2536
2642
  }
2537
2643
  for (const bundledPath of getBundledCliPaths()) {
2538
2644
  if ((0, import_fs.existsSync)(bundledPath)) {
2645
+ ensureExecutable(bundledPath);
2539
2646
  return bundledPath;
2540
2647
  }
2541
2648
  }
@@ -2583,9 +2690,12 @@ var HttpWasmRunner = class {
2583
2690
  this.port = null;
2584
2691
  this.cliPath = null;
2585
2692
  this.tempWasmPath = null;
2693
+ this.currentWasmPath = null;
2694
+ // resolved path used when spawning
2586
2695
  this.logs = [];
2587
2696
  this.stateManager = null;
2588
2697
  this.dotenvEnabled = true;
2698
+ this.dotenvPath = null;
2589
2699
  this.portManager = portManager;
2590
2700
  this.dotenvEnabled = dotenvEnabled;
2591
2701
  }
@@ -2593,8 +2703,11 @@ var HttpWasmRunner = class {
2593
2703
  * Load WASM binary and spawn fastedge-run process
2594
2704
  */
2595
2705
  async load(bufferOrPath, config) {
2596
- if (config?.dotenvEnabled !== void 0) {
2597
- this.dotenvEnabled = config.dotenvEnabled;
2706
+ if (config?.dotenv?.enabled !== void 0) {
2707
+ this.dotenvEnabled = config.dotenv.enabled;
2708
+ }
2709
+ if (config?.dotenv?.path !== void 0) {
2710
+ this.dotenvPath = config.dotenv.path;
2598
2711
  }
2599
2712
  await this.cleanup();
2600
2713
  this.cliPath = await findFastEdgeRunCli();
@@ -2606,7 +2719,7 @@ var HttpWasmRunner = class {
2606
2719
  wasmPath = await writeTempWasmFile(bufferOrPath);
2607
2720
  this.tempWasmPath = wasmPath;
2608
2721
  }
2609
- this.port = this.portManager.allocate();
2722
+ this.port = await this.portManager.allocate();
2610
2723
  const args = [
2611
2724
  "http",
2612
2725
  "-p",
@@ -2617,8 +2730,13 @@ var HttpWasmRunner = class {
2617
2730
  "true"
2618
2731
  ];
2619
2732
  if (this.dotenvEnabled) {
2620
- args.push("--dotenv");
2733
+ if (this.dotenvPath) {
2734
+ args.push("--dotenv", this.dotenvPath);
2735
+ } else {
2736
+ args.push("--dotenv");
2737
+ }
2621
2738
  }
2739
+ this.currentWasmPath = wasmPath;
2622
2740
  this.process = (0, import_child_process2.spawn)(this.cliPath, args, {
2623
2741
  stdio: ["ignore", "pipe", "pipe"],
2624
2742
  env: {
@@ -2634,17 +2752,17 @@ var HttpWasmRunner = class {
2634
2752
  /**
2635
2753
  * Execute an HTTP request through the WASM module
2636
2754
  */
2637
- async execute(request2) {
2755
+ async execute(request) {
2638
2756
  if (!this.port || !this.process) {
2639
2757
  throw new Error("HttpWasmRunner not loaded. Call load() first.");
2640
2758
  }
2641
2759
  this.logs = [];
2642
2760
  try {
2643
- const url = `http://localhost:${this.port}${request2.path}`;
2761
+ const url = `http://localhost:${this.port}${request.path}`;
2644
2762
  const response = await fetch(url, {
2645
- method: request2.method,
2646
- headers: request2.headers,
2647
- body: request2.body || void 0,
2763
+ method: request.method,
2764
+ headers: request.headers,
2765
+ body: request.body || void 0,
2648
2766
  signal: AbortSignal.timeout(3e4)
2649
2767
  // 30 second timeout
2650
2768
  });
@@ -2672,7 +2790,7 @@ var HttpWasmRunner = class {
2672
2790
  /**
2673
2791
  * Not supported for HTTP WASM (proxy-wasm only)
2674
2792
  */
2675
- async callHook(hookCall) {
2793
+ async callHook(_hookCall) {
2676
2794
  throw new Error(
2677
2795
  "callHook() is not supported for HTTP WASM. Use execute() instead."
2678
2796
  );
@@ -2680,11 +2798,53 @@ var HttpWasmRunner = class {
2680
2798
  /**
2681
2799
  * Not supported for HTTP WASM (proxy-wasm only)
2682
2800
  */
2683
- async callFullFlow(url, method, headers, body, responseHeaders, responseBody, responseStatus, responseStatusText, properties, enforceProductionPropertyRules) {
2801
+ async callFullFlow(_url, _method, _headers, _body, _responseHeaders, _responseBody, _responseStatus, _responseStatusText, _properties, _enforceProductionPropertyRules) {
2684
2802
  throw new Error(
2685
2803
  "callFullFlow() is not supported for HTTP WASM. Use execute() instead."
2686
2804
  );
2687
2805
  }
2806
+ /**
2807
+ * Apply dotenv settings by restarting the fastedge-run process.
2808
+ * The WASM file is not re-read; only the --dotenv flag changes.
2809
+ */
2810
+ async applyDotenv(enabled, dotenvPath) {
2811
+ this.dotenvEnabled = enabled;
2812
+ if (dotenvPath !== void 0) {
2813
+ this.dotenvPath = dotenvPath;
2814
+ }
2815
+ if (!this.process || !this.currentWasmPath || !this.cliPath || this.port === null) {
2816
+ return;
2817
+ }
2818
+ await this.killProcess();
2819
+ this.process = null;
2820
+ const args = [
2821
+ "http",
2822
+ "-p",
2823
+ this.port.toString(),
2824
+ "-w",
2825
+ this.currentWasmPath,
2826
+ "--wasi-http",
2827
+ "true"
2828
+ ];
2829
+ if (this.dotenvEnabled) {
2830
+ if (this.dotenvPath) {
2831
+ args.push("--dotenv", this.dotenvPath);
2832
+ } else {
2833
+ args.push("--dotenv");
2834
+ }
2835
+ }
2836
+ this.process = (0, import_child_process2.spawn)(this.cliPath, args, {
2837
+ stdio: ["ignore", "pipe", "pipe"],
2838
+ env: {
2839
+ RUST_LOG: "info",
2840
+ ...process.env
2841
+ }
2842
+ });
2843
+ this.setupLogCapture();
2844
+ this.setupErrorHandlers();
2845
+ const timeout = process.env.NODE_ENV === "test" || process.env.VITEST ? 2e4 : 1e4;
2846
+ await this.waitForServerReady(this.port, timeout);
2847
+ }
2688
2848
  /**
2689
2849
  * Clean up resources
2690
2850
  */
@@ -2709,12 +2869,41 @@ var HttpWasmRunner = class {
2709
2869
  getType() {
2710
2870
  return "http-wasm";
2711
2871
  }
2872
+ /**
2873
+ * Get the port the fastedge-run HTTP server is listening on
2874
+ */
2875
+ getPort() {
2876
+ return this.port;
2877
+ }
2712
2878
  /**
2713
2879
  * Set state manager
2714
2880
  */
2715
2881
  setStateManager(stateManager) {
2716
2882
  this.stateManager = stateManager;
2717
2883
  }
2884
+ /**
2885
+ * Parse log level from a process output line.
2886
+ * Matches bare prefixes (e.g. "INFO target > msg") and bracketed prefixes (e.g. "[INFO] msg").
2887
+ * Falls back to the provided default if no known level prefix is found.
2888
+ */
2889
+ parseLogLevel(message, fallback) {
2890
+ const match = message.trimStart().match(/^\[?(\w+)\]?/);
2891
+ const prefix = match?.[1]?.toUpperCase();
2892
+ switch (prefix) {
2893
+ case "TRACE":
2894
+ return 0;
2895
+ case "DEBUG":
2896
+ return 1;
2897
+ case "INFO":
2898
+ return 2;
2899
+ case "WARN":
2900
+ return 3;
2901
+ case "ERROR":
2902
+ return 4;
2903
+ default:
2904
+ return fallback;
2905
+ }
2906
+ }
2718
2907
  /**
2719
2908
  * Setup log capture from process stdout/stderr
2720
2909
  */
@@ -2723,13 +2912,17 @@ var HttpWasmRunner = class {
2723
2912
  this.process.stdout?.on("data", (data) => {
2724
2913
  const message = data.toString().trim();
2725
2914
  if (message) {
2726
- this.logs.push({ level: 2, message });
2915
+ const log = { level: this.parseLogLevel(message, 2), message };
2916
+ this.logs.push(log);
2917
+ this.stateManager?.emitHttpWasmLog(log);
2727
2918
  }
2728
2919
  });
2729
2920
  this.process.stderr?.on("data", (data) => {
2730
2921
  const message = data.toString().trim();
2731
2922
  if (message) {
2732
- this.logs.push({ level: 4, message });
2923
+ const log = { level: this.parseLogLevel(message, 4), message };
2924
+ this.logs.push(log);
2925
+ this.stateManager?.emitHttpWasmLog(log);
2733
2926
  }
2734
2927
  });
2735
2928
  }
@@ -2759,66 +2952,54 @@ var HttpWasmRunner = class {
2759
2952
  });
2760
2953
  }
2761
2954
  /**
2762
- * Probe port with a single HTTP GET, returning true if any response is received.
2763
- * Uses Node.js http module with explicit content-length: 0 so that wasi-http
2764
- * runtimes (fastedge-run) immediately signal EOF to the WASM request body
2765
- * stream. Without this, newer fastedge-run builds hold the body stream open
2766
- * on keep-alive connections, causing the WASM's event.request.text() to hang
2767
- * indefinitely and never send a response.
2768
- */
2769
- probePort(port, timeoutMs) {
2770
- return new Promise((resolve) => {
2771
- const req = http.request(
2772
- {
2773
- hostname: "localhost",
2774
- port,
2775
- path: "/",
2776
- method: "GET",
2777
- headers: {
2778
- "content-length": "0",
2779
- connection: "close"
2780
- }
2781
- },
2782
- (res) => {
2783
- res.resume();
2784
- resolve(true);
2785
- }
2786
- );
2787
- req.setTimeout(timeoutMs, () => {
2788
- req.destroy();
2789
- resolve(false);
2790
- });
2791
- req.on("error", () => resolve(false));
2792
- req.end();
2793
- });
2794
- }
2795
- /**
2796
- * Wait for server to be ready by polling
2955
+ * Wait for the fastedge-run HTTP server to be ready by watching process logs
2956
+ * for the "Listening on" message.
2957
+ *
2958
+ * We intentionally avoid HTTP probing here. HTTP probes trigger WASM execution
2959
+ * (the app calls event.request.text() to read the body), which can hang for
2960
+ * many seconds in CI due to WASM JIT compilation on the first request, or
2961
+ * because newer fastedge-run builds hold the body stream open regardless of
2962
+ * content-length. Watching logs avoids all of this: fastedge-run emits
2963
+ * "Listening on http://127.0.0.1:<port>" as soon as the HTTP listener is bound,
2964
+ * before any WASM execution occurs.
2797
2965
  */
2798
- async waitForServerReady(port, timeoutMs) {
2966
+ waitForServerReady(port, timeoutMs) {
2799
2967
  const startTime = Date.now();
2800
- while (Date.now() - startTime < timeoutMs) {
2801
- const ready = await this.probePort(port, 5e3);
2802
- if (ready) return;
2803
- if (this.process && this.process.exitCode !== null) {
2804
- throw new Error(
2805
- `FastEdge-run process exited with code ${this.process.exitCode} before server started`
2806
- );
2807
- }
2808
- await new Promise((resolve) => setTimeout(resolve, 100));
2809
- }
2810
- const processInfo = this.process ? `Process state: exitCode=${this.process.exitCode}, killed=${this.process.killed}, pid=${this.process.pid}` : "Process is null";
2811
- const recentLogs = this.logs.slice(-5).map((l) => `[${l.level}] ${l.message}`).join("\n");
2812
- throw new Error(
2813
- `FastEdge-run server did not start within ${timeoutMs}ms on port ${port}
2968
+ return new Promise((resolve, reject) => {
2969
+ const check = () => {
2970
+ if (this.process && this.process.exitCode !== null) {
2971
+ return reject(
2972
+ new Error(
2973
+ `FastEdge-run process exited with code ${this.process.exitCode} before server started`
2974
+ )
2975
+ );
2976
+ }
2977
+ if (this.logs.some((l) => l.message.includes("Listening on"))) {
2978
+ return resolve();
2979
+ }
2980
+ if (Date.now() - startTime >= timeoutMs) {
2981
+ const processInfo = this.process ? `Process state: exitCode=${this.process.exitCode}, killed=${this.process.killed}, pid=${this.process.pid}` : "Process is null";
2982
+ const recentLogs = this.logs.slice(-5).map((l) => `[${l.level}] ${l.message}`).join("\n");
2983
+ return reject(
2984
+ new Error(
2985
+ `FastEdge-run server did not start within ${timeoutMs}ms on port ${port}
2814
2986
  ${processInfo}
2815
2987
  Recent logs:
2816
2988
  ${recentLogs || "(no logs)"}`
2817
- );
2989
+ )
2990
+ );
2991
+ }
2992
+ setTimeout(check, 50);
2993
+ };
2994
+ check();
2995
+ });
2818
2996
  }
2819
2997
  /**
2820
- * Kill the process gracefully (SIGINT) with fallback to SIGKILL
2821
- * FastEdge-run responds to SIGINT for graceful shutdown
2998
+ * Kill the process gracefully (SIGINT) with platform-specific force-kill fallback.
2999
+ * SIGINT is sent first on all platforms — Node.js translates it for Windows.
3000
+ * If the process does not exit within 2 seconds:
3001
+ * - Windows: taskkill /F /T to terminate the process tree
3002
+ * - Unix: SIGKILL
2822
3003
  */
2823
3004
  async killProcess() {
2824
3005
  if (!this.process) return;
@@ -2829,8 +3010,18 @@ ${recentLogs || "(no logs)"}`
2829
3010
  }
2830
3011
  this.process.kill("SIGINT");
2831
3012
  const timeout = setTimeout(() => {
2832
- if (this.process && !this.process.killed) {
2833
- this.process.kill("SIGKILL");
3013
+ if (this.process && this.process.exitCode === null && this.process.signalCode === null) {
3014
+ if (process.platform === "win32") {
3015
+ const pid = this.process.pid;
3016
+ if (pid) {
3017
+ try {
3018
+ (0, import_child_process2.execSync)(`taskkill /F /T /PID ${pid}`);
3019
+ } catch {
3020
+ }
3021
+ }
3022
+ } else {
3023
+ this.process.kill("SIGKILL");
3024
+ }
2834
3025
  }
2835
3026
  resolve();
2836
3027
  }, 2e3);
@@ -2853,9 +3044,7 @@ ${recentLogs || "(no logs)"}`
2853
3044
  "application/zip",
2854
3045
  "application/gzip"
2855
3046
  ];
2856
- return binaryTypes.some(
2857
- (type) => contentType.toLowerCase().includes(type)
2858
- );
3047
+ return binaryTypes.some((type) => contentType.toLowerCase().includes(type));
2859
3048
  }
2860
3049
  /**
2861
3050
  * Parse headers from fetch Headers object
@@ -2870,6 +3059,7 @@ ${recentLogs || "(no logs)"}`
2870
3059
  };
2871
3060
 
2872
3061
  // server/runner/PortManager.ts
3062
+ var import_net = require("net");
2873
3063
  var PortManager = class {
2874
3064
  constructor() {
2875
3065
  this.minPort = 8100;
@@ -2877,18 +3067,31 @@ var PortManager = class {
2877
3067
  this.allocatedPorts = /* @__PURE__ */ new Set();
2878
3068
  this.lastAllocatedPort = this.minPort - 1;
2879
3069
  }
2880
- // Track last allocated port for sequential allocation
2881
3070
  /**
2882
- * Allocate an available port from the pool
2883
- * Sequential allocation to avoid reusing recently released ports (TCP TIME_WAIT)
2884
- * Synchronous to prevent race conditions when allocating in parallel
3071
+ * Check whether a port is actually free at the OS level.
3072
+ * This is necessary when multiple server processes run simultaneously
3073
+ * each has its own PortManager with independent in-memory state, so
3074
+ * in-memory tracking alone is not enough to prevent cross-process conflicts.
3075
+ */
3076
+ isPortFree(port) {
3077
+ return new Promise((resolve) => {
3078
+ const server = (0, import_net.createServer)();
3079
+ server.once("error", () => resolve(false));
3080
+ server.once("listening", () => server.close(() => resolve(true)));
3081
+ server.listen(port, "127.0.0.1");
3082
+ });
3083
+ }
3084
+ /**
3085
+ * Allocate an available port from the pool.
3086
+ * Combines in-memory tracking (avoids TCP TIME_WAIT reuse within this process)
3087
+ * with an OS-level check (avoids cross-process collisions).
2885
3088
  * @returns The allocated port number
2886
3089
  * @throws Error if no ports are available
2887
3090
  */
2888
- allocate() {
3091
+ async allocate() {
2889
3092
  for (let offset = 1; offset <= this.maxPort - this.minPort + 1; offset++) {
2890
3093
  const port = this.minPort + (this.lastAllocatedPort - this.minPort + offset) % (this.maxPort - this.minPort + 1);
2891
- if (!this.allocatedPorts.has(port)) {
3094
+ if (!this.allocatedPorts.has(port) && await this.isPortFree(port)) {
2892
3095
  this.allocatedPorts.add(port);
2893
3096
  this.lastAllocatedPort = port;
2894
3097
  return port;
@@ -2900,32 +3103,19 @@ var PortManager = class {
2900
3103
  }
2901
3104
  /**
2902
3105
  * Release a previously allocated port back to the pool
2903
- * @param port The port number to release
2904
3106
  */
2905
3107
  release(port) {
2906
3108
  this.allocatedPorts.delete(port);
2907
3109
  }
2908
- /**
2909
- * Get the number of currently allocated ports
2910
- */
2911
3110
  getAllocatedCount() {
2912
3111
  return this.allocatedPorts.size;
2913
3112
  }
2914
- /**
2915
- * Get the number of available ports
2916
- */
2917
3113
  getAvailableCount() {
2918
3114
  return this.maxPort - this.minPort + 1 - this.allocatedPorts.size;
2919
3115
  }
2920
- /**
2921
- * Check if a specific port is allocated
2922
- */
2923
3116
  isAllocated(port) {
2924
3117
  return this.allocatedPorts.has(port);
2925
3118
  }
2926
- /**
2927
- * Reset all allocations (useful for testing)
2928
- */
2929
3119
  reset() {
2930
3120
  this.allocatedPorts.clear();
2931
3121
  }
@@ -2970,9 +3160,9 @@ async function createRunnerFromBuffer(buffer, config) {
2970
3160
  const wasmType = config?.runnerType ?? await detectWasmType(buffer);
2971
3161
  let runner;
2972
3162
  if (wasmType === "http-wasm") {
2973
- runner = new HttpWasmRunner(new PortManager(), config?.dotenvEnabled ?? false);
3163
+ runner = new HttpWasmRunner(new PortManager(), config?.dotenv?.enabled ?? false);
2974
3164
  } else {
2975
- runner = new ProxyWasmRunner(void 0, config?.dotenvEnabled ?? false);
3165
+ runner = new ProxyWasmRunner(void 0, config?.dotenv?.enabled ?? false);
2976
3166
  }
2977
3167
  await runner.load(buffer, config);
2978
3168
  return runner;
@@ -3001,7 +3191,10 @@ var TestConfigSchema = import_zod.z.object({
3001
3191
  request: RequestConfigSchema,
3002
3192
  response: ResponseConfigSchema.optional(),
3003
3193
  properties: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional().default({}),
3004
- dotenvEnabled: import_zod.z.boolean().optional().default(true)
3194
+ dotenv: import_zod.z.object({
3195
+ enabled: import_zod.z.boolean().optional(),
3196
+ path: import_zod.z.string().optional()
3197
+ }).optional()
3005
3198
  });
3006
3199
 
3007
3200
  // server/test-framework/suite-runner.ts