@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;
@@ -771,6 +772,8 @@ var HostFunctions = class {
771
772
  this.pendingHttpCall = null;
772
773
  this.httpCallResponse = null;
773
774
  this.streamClosed = false;
775
+ // Local response state (from proxy_send_local_response / send_http_response)
776
+ this.localResponse = null;
774
777
  this.memory = memory;
775
778
  this.propertyResolver = propertyResolver;
776
779
  this.propertyAccessControl = propertyAccessControl;
@@ -825,6 +828,16 @@ var HostFunctions = class {
825
828
  resetStreamClosed() {
826
829
  this.streamClosed = false;
827
830
  }
831
+ // Local response accessors (called by ProxyWasmRunner after callHook)
832
+ hasLocalResponse() {
833
+ return this.localResponse !== null;
834
+ }
835
+ getLocalResponse() {
836
+ return this.localResponse;
837
+ }
838
+ resetLocalResponse() {
839
+ this.localResponse = null;
840
+ }
828
841
  getRequestHeaders() {
829
842
  return this.requestHeaders;
830
843
  }
@@ -1085,15 +1098,21 @@ var HostFunctions = class {
1085
1098
  this.currentContextId = contextId || this.currentContextId;
1086
1099
  return 0 /* Ok */;
1087
1100
  },
1088
- proxy_send_local_response: (statusCode, statusCodePtr, statusCodeLen, bodyPtr, bodyLen, grpcStatus) => {
1101
+ proxy_send_local_response: (statusCode, statusCodePtr, statusCodeLen, bodyPtr, bodyLen, headerPairsPtr, headerPairsLen, grpcStatus) => {
1089
1102
  this.setLastHostCall(
1090
1103
  `proxy_send_local_response status=${statusCode} bodyLen=${bodyLen}`
1091
1104
  );
1092
1105
  const statusText = this.memory.readString(statusCodePtr, statusCodeLen);
1093
- const body = this.memory.readString(bodyPtr, bodyLen);
1106
+ const body = this.memory.readBytes(bodyPtr, bodyLen);
1107
+ if (headerPairsLen > 0) {
1108
+ const headerBytes = this.memory.readBytes(headerPairsPtr, headerPairsLen);
1109
+ const headers = HeaderManager.deserializeBinary(headerBytes);
1110
+ this.logDebug(`send_local_response headers (not merged): ${JSON.stringify(headers)}`);
1111
+ }
1112
+ this.localResponse = { statusCode, statusText, body };
1094
1113
  this.logs.push({
1095
1114
  level: 1,
1096
- message: `local_response status=${statusCode} ${statusText} body=${body} grpc=${grpcStatus}`
1115
+ message: `local_response status=${statusCode} ${statusText} bodyLen=${body.byteLength} grpc=${grpcStatus}`
1097
1116
  });
1098
1117
  return 0 /* Ok */;
1099
1118
  },
@@ -1673,7 +1692,6 @@ async function loadDotenvFiles(dotenvPath = ".") {
1673
1692
  // server/runner/ProxyWasmRunner.ts
1674
1693
  var textEncoder4 = new TextEncoder();
1675
1694
  var ProxyWasmRunner = class {
1676
- // Default to enabled
1677
1695
  constructor(fastEdgeConfig, dotenvEnabled = true) {
1678
1696
  this.module = null;
1679
1697
  // Compiled module (reused)
@@ -1687,6 +1705,8 @@ var ProxyWasmRunner = class {
1687
1705
  this.debug = process.env.PROXY_RUNNER_DEBUG === "1";
1688
1706
  this.stateManager = null;
1689
1707
  this.dotenvEnabled = true;
1708
+ // Default to enabled
1709
+ this.dotenvPath = ".";
1690
1710
  this.memory = new MemoryManager();
1691
1711
  this.propertyResolver = new PropertyResolver();
1692
1712
  this.propertyAccessControl = new PropertyAccessControl();
@@ -1721,7 +1741,7 @@ var ProxyWasmRunner = class {
1721
1741
  return;
1722
1742
  }
1723
1743
  try {
1724
- const dotenvConfig = await loadDotenvFiles(".");
1744
+ const dotenvConfig = await loadDotenvFiles(this.dotenvPath);
1725
1745
  if (dotenvConfig.secrets && Object.keys(dotenvConfig.secrets).length > 0) {
1726
1746
  const existingSecrets = this.secretStore.getAll();
1727
1747
  this.secretStore = new SecretStore({
@@ -1753,9 +1773,37 @@ var ProxyWasmRunner = class {
1753
1773
  console.error("Failed to load dotenv files:", error);
1754
1774
  }
1755
1775
  }
1776
+ /**
1777
+ * Apply dotenv settings to the running module without recompiling.
1778
+ * Resets SecretStore/Dictionary to empty, then re-loads dotenv files
1779
+ * using the new settings. The compiled WASM module is untouched.
1780
+ */
1781
+ async applyDotenv(enabled, dotenvPath) {
1782
+ this.dotenvEnabled = enabled;
1783
+ if (dotenvPath !== void 0) {
1784
+ this.dotenvPath = dotenvPath;
1785
+ }
1786
+ this.secretStore = new SecretStore();
1787
+ this.dictionary = new Dictionary();
1788
+ await this.loadDotenvIfEnabled();
1789
+ if (!enabled) {
1790
+ this.hostFunctions = new HostFunctions(
1791
+ this.memory,
1792
+ this.propertyResolver,
1793
+ this.propertyAccessControl,
1794
+ () => this.currentHook,
1795
+ this.debug,
1796
+ this.secretStore,
1797
+ this.dictionary
1798
+ );
1799
+ }
1800
+ }
1756
1801
  async load(bufferOrPath, config) {
1757
- if (config?.dotenvEnabled !== void 0) {
1758
- this.dotenvEnabled = config.dotenvEnabled;
1802
+ if (config?.dotenv?.enabled !== void 0) {
1803
+ this.dotenvEnabled = config.dotenv.enabled;
1804
+ }
1805
+ if (config?.dotenv?.path !== void 0) {
1806
+ this.dotenvPath = config.dotenv.path;
1759
1807
  }
1760
1808
  this.resetState();
1761
1809
  let buffer;
@@ -1826,6 +1874,25 @@ var ProxyWasmRunner = class {
1826
1874
  "system"
1827
1875
  );
1828
1876
  }
1877
+ if (results.onRequestHeaders.returnCode === 1 && this.hostFunctions.hasLocalResponse()) {
1878
+ const local = this.hostFunctions.getLocalResponse();
1879
+ const responseHeaders = results.onRequestHeaders.output.response.headers;
1880
+ this.hostFunctions.resetLocalResponse();
1881
+ const contentType = responseHeaders["content-type"] || "text/plain";
1882
+ const { body, isBase64 } = encodeLocalResponseBody(local.body, contentType);
1883
+ return {
1884
+ hookResults: results,
1885
+ finalResponse: {
1886
+ status: local.statusCode,
1887
+ statusText: local.statusText,
1888
+ headers: responseHeaders,
1889
+ body,
1890
+ contentType,
1891
+ isBase64
1892
+ },
1893
+ calculatedProperties: this.propertyResolver.getCalculatedProperties()
1894
+ };
1895
+ }
1829
1896
  const headersAfterRequestHeaders = results.onRequestHeaders.output.request.headers;
1830
1897
  const propertiesAfterRequestHeaders = results.onRequestHeaders.properties;
1831
1898
  this.logDebug(
@@ -1850,6 +1917,25 @@ var ProxyWasmRunner = class {
1850
1917
  "system"
1851
1918
  );
1852
1919
  }
1920
+ if (results.onRequestBody.returnCode === 1 && this.hostFunctions.hasLocalResponse()) {
1921
+ const local = this.hostFunctions.getLocalResponse();
1922
+ const responseHeaders = results.onRequestBody.output.response.headers;
1923
+ this.hostFunctions.resetLocalResponse();
1924
+ const contentType = responseHeaders["content-type"] || "text/plain";
1925
+ const { body, isBase64 } = encodeLocalResponseBody(local.body, contentType);
1926
+ return {
1927
+ hookResults: results,
1928
+ finalResponse: {
1929
+ status: local.statusCode,
1930
+ statusText: local.statusText,
1931
+ headers: responseHeaders,
1932
+ body,
1933
+ contentType,
1934
+ isBase64
1935
+ },
1936
+ calculatedProperties: this.propertyResolver.getCalculatedProperties()
1937
+ };
1938
+ }
1853
1939
  const modifiedRequestHeaders = results.onRequestBody.output.request.headers;
1854
1940
  const modifiedRequestBody = results.onRequestBody.output.request.body;
1855
1941
  const propertiesAfterRequestBody = results.onRequestBody.properties;
@@ -2038,6 +2124,7 @@ var ProxyWasmRunner = class {
2038
2124
  throw new Error("WASM module not loaded");
2039
2125
  }
2040
2126
  this.currentHook = this.getHookContext(call.hook);
2127
+ this.hostFunctions.resetLocalResponse();
2041
2128
  const imports = this.createImports();
2042
2129
  this.instance = await WebAssembly.instantiate(this.module, imports);
2043
2130
  const memory = this.instance.exports.memory;
@@ -2436,7 +2523,7 @@ var ProxyWasmRunner = class {
2436
2523
  /**
2437
2524
  * Not supported for Proxy-WASM (HTTP WASM only)
2438
2525
  */
2439
- async execute(request2) {
2526
+ async execute(request) {
2440
2527
  throw new Error(
2441
2528
  "execute() is not supported for Proxy-WASM. Use callHook() or callFullFlow() instead."
2442
2529
  );
@@ -2471,16 +2558,24 @@ function ensureNullTerminated(value) {
2471
2558
  }
2472
2559
  return value.endsWith("\0") ? value : `${value}\0`;
2473
2560
  }
2561
+ function encodeLocalResponseBody(body, contentType) {
2562
+ const isBinary = contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/") || contentType.includes("application/octet-stream") || contentType.includes("application/pdf") || contentType.includes("application/zip");
2563
+ if (isBinary) {
2564
+ return { body: Buffer.from(body).toString("base64"), isBase64: true };
2565
+ }
2566
+ return { body: new TextDecoder().decode(body), isBase64: false };
2567
+ }
2474
2568
 
2475
2569
  // server/runner/HttpWasmRunner.ts
2476
2570
  var import_child_process2 = require("child_process");
2477
- var http = __toESM(require("http"));
2478
2571
 
2479
2572
  // server/utils/fastedge-cli.ts
2480
2573
  var import_child_process = require("child_process");
2481
2574
  var import_fs = require("fs");
2482
2575
  var import_path2 = require("path");
2576
+ var import_url = require("url");
2483
2577
  var import_os = __toESM(require("os"));
2578
+ var _currentDir = (0, import_path2.dirname)((0, import_url.fileURLToPath)(__importMetaUrl));
2484
2579
  function getCliBinaryName() {
2485
2580
  switch (import_os.default.platform()) {
2486
2581
  case "win32":
@@ -2496,19 +2591,30 @@ function getCliBinaryName() {
2496
2591
  function getBundledCliPaths() {
2497
2592
  const binaryName = getCliBinaryName();
2498
2593
  return [
2499
- // Production: bundled server at dist/server.js
2500
- (0, import_path2.join)(__dirname, "fastedge-cli", binaryName),
2594
+ // Installed npm package: dist/lib/index.js → dist/fastedge-cli/
2595
+ (0, import_path2.join)(_currentDir, "..", "fastedge-cli", binaryName),
2596
+ // Production: bundled server at dist/server.js → dist/fastedge-cli/
2597
+ (0, import_path2.join)(_currentDir, "fastedge-cli", binaryName),
2501
2598
  // Development/Tests: running from source
2502
- // __dirname might be server/utils/, so go up to project root
2503
- (0, import_path2.join)(__dirname, "..", "..", "fastedge-run", binaryName),
2504
- // Alternative: if __dirname is already at project root
2505
- (0, import_path2.join)(__dirname, "fastedge-run", binaryName)
2599
+ // _currentDir might be server/utils/, so go up to project root
2600
+ (0, import_path2.join)(_currentDir, "..", "..", "fastedge-run", binaryName),
2601
+ // Alternative: if _currentDir is already at project root
2602
+ (0, import_path2.join)(_currentDir, "fastedge-run", binaryName)
2506
2603
  ];
2507
2604
  }
2605
+ function ensureExecutable(binaryPath) {
2606
+ if (process.platform !== "win32") {
2607
+ try {
2608
+ (0, import_fs.chmodSync)(binaryPath, 493);
2609
+ } catch {
2610
+ }
2611
+ }
2612
+ }
2508
2613
  async function findFastEdgeRunCli() {
2509
2614
  const envPath = process.env.FASTEDGE_RUN_PATH;
2510
2615
  if (envPath) {
2511
2616
  if ((0, import_fs.existsSync)(envPath)) {
2617
+ ensureExecutable(envPath);
2512
2618
  return envPath;
2513
2619
  } else {
2514
2620
  throw new Error(
@@ -2518,6 +2624,7 @@ async function findFastEdgeRunCli() {
2518
2624
  }
2519
2625
  for (const bundledPath of getBundledCliPaths()) {
2520
2626
  if ((0, import_fs.existsSync)(bundledPath)) {
2627
+ ensureExecutable(bundledPath);
2521
2628
  return bundledPath;
2522
2629
  }
2523
2630
  }
@@ -2565,9 +2672,12 @@ var HttpWasmRunner = class {
2565
2672
  this.port = null;
2566
2673
  this.cliPath = null;
2567
2674
  this.tempWasmPath = null;
2675
+ this.currentWasmPath = null;
2676
+ // resolved path used when spawning
2568
2677
  this.logs = [];
2569
2678
  this.stateManager = null;
2570
2679
  this.dotenvEnabled = true;
2680
+ this.dotenvPath = null;
2571
2681
  this.portManager = portManager;
2572
2682
  this.dotenvEnabled = dotenvEnabled;
2573
2683
  }
@@ -2575,8 +2685,11 @@ var HttpWasmRunner = class {
2575
2685
  * Load WASM binary and spawn fastedge-run process
2576
2686
  */
2577
2687
  async load(bufferOrPath, config) {
2578
- if (config?.dotenvEnabled !== void 0) {
2579
- this.dotenvEnabled = config.dotenvEnabled;
2688
+ if (config?.dotenv?.enabled !== void 0) {
2689
+ this.dotenvEnabled = config.dotenv.enabled;
2690
+ }
2691
+ if (config?.dotenv?.path !== void 0) {
2692
+ this.dotenvPath = config.dotenv.path;
2580
2693
  }
2581
2694
  await this.cleanup();
2582
2695
  this.cliPath = await findFastEdgeRunCli();
@@ -2588,7 +2701,7 @@ var HttpWasmRunner = class {
2588
2701
  wasmPath = await writeTempWasmFile(bufferOrPath);
2589
2702
  this.tempWasmPath = wasmPath;
2590
2703
  }
2591
- this.port = this.portManager.allocate();
2704
+ this.port = await this.portManager.allocate();
2592
2705
  const args = [
2593
2706
  "http",
2594
2707
  "-p",
@@ -2599,8 +2712,13 @@ var HttpWasmRunner = class {
2599
2712
  "true"
2600
2713
  ];
2601
2714
  if (this.dotenvEnabled) {
2602
- args.push("--dotenv");
2715
+ if (this.dotenvPath) {
2716
+ args.push("--dotenv", this.dotenvPath);
2717
+ } else {
2718
+ args.push("--dotenv");
2719
+ }
2603
2720
  }
2721
+ this.currentWasmPath = wasmPath;
2604
2722
  this.process = (0, import_child_process2.spawn)(this.cliPath, args, {
2605
2723
  stdio: ["ignore", "pipe", "pipe"],
2606
2724
  env: {
@@ -2616,17 +2734,17 @@ var HttpWasmRunner = class {
2616
2734
  /**
2617
2735
  * Execute an HTTP request through the WASM module
2618
2736
  */
2619
- async execute(request2) {
2737
+ async execute(request) {
2620
2738
  if (!this.port || !this.process) {
2621
2739
  throw new Error("HttpWasmRunner not loaded. Call load() first.");
2622
2740
  }
2623
2741
  this.logs = [];
2624
2742
  try {
2625
- const url = `http://localhost:${this.port}${request2.path}`;
2743
+ const url = `http://localhost:${this.port}${request.path}`;
2626
2744
  const response = await fetch(url, {
2627
- method: request2.method,
2628
- headers: request2.headers,
2629
- body: request2.body || void 0,
2745
+ method: request.method,
2746
+ headers: request.headers,
2747
+ body: request.body || void 0,
2630
2748
  signal: AbortSignal.timeout(3e4)
2631
2749
  // 30 second timeout
2632
2750
  });
@@ -2654,7 +2772,7 @@ var HttpWasmRunner = class {
2654
2772
  /**
2655
2773
  * Not supported for HTTP WASM (proxy-wasm only)
2656
2774
  */
2657
- async callHook(hookCall) {
2775
+ async callHook(_hookCall) {
2658
2776
  throw new Error(
2659
2777
  "callHook() is not supported for HTTP WASM. Use execute() instead."
2660
2778
  );
@@ -2662,11 +2780,53 @@ var HttpWasmRunner = class {
2662
2780
  /**
2663
2781
  * Not supported for HTTP WASM (proxy-wasm only)
2664
2782
  */
2665
- async callFullFlow(url, method, headers, body, responseHeaders, responseBody, responseStatus, responseStatusText, properties, enforceProductionPropertyRules) {
2783
+ async callFullFlow(_url, _method, _headers, _body, _responseHeaders, _responseBody, _responseStatus, _responseStatusText, _properties, _enforceProductionPropertyRules) {
2666
2784
  throw new Error(
2667
2785
  "callFullFlow() is not supported for HTTP WASM. Use execute() instead."
2668
2786
  );
2669
2787
  }
2788
+ /**
2789
+ * Apply dotenv settings by restarting the fastedge-run process.
2790
+ * The WASM file is not re-read; only the --dotenv flag changes.
2791
+ */
2792
+ async applyDotenv(enabled, dotenvPath) {
2793
+ this.dotenvEnabled = enabled;
2794
+ if (dotenvPath !== void 0) {
2795
+ this.dotenvPath = dotenvPath;
2796
+ }
2797
+ if (!this.process || !this.currentWasmPath || !this.cliPath || this.port === null) {
2798
+ return;
2799
+ }
2800
+ await this.killProcess();
2801
+ this.process = null;
2802
+ const args = [
2803
+ "http",
2804
+ "-p",
2805
+ this.port.toString(),
2806
+ "-w",
2807
+ this.currentWasmPath,
2808
+ "--wasi-http",
2809
+ "true"
2810
+ ];
2811
+ if (this.dotenvEnabled) {
2812
+ if (this.dotenvPath) {
2813
+ args.push("--dotenv", this.dotenvPath);
2814
+ } else {
2815
+ args.push("--dotenv");
2816
+ }
2817
+ }
2818
+ this.process = (0, import_child_process2.spawn)(this.cliPath, args, {
2819
+ stdio: ["ignore", "pipe", "pipe"],
2820
+ env: {
2821
+ RUST_LOG: "info",
2822
+ ...process.env
2823
+ }
2824
+ });
2825
+ this.setupLogCapture();
2826
+ this.setupErrorHandlers();
2827
+ const timeout = process.env.NODE_ENV === "test" || process.env.VITEST ? 2e4 : 1e4;
2828
+ await this.waitForServerReady(this.port, timeout);
2829
+ }
2670
2830
  /**
2671
2831
  * Clean up resources
2672
2832
  */
@@ -2691,12 +2851,41 @@ var HttpWasmRunner = class {
2691
2851
  getType() {
2692
2852
  return "http-wasm";
2693
2853
  }
2854
+ /**
2855
+ * Get the port the fastedge-run HTTP server is listening on
2856
+ */
2857
+ getPort() {
2858
+ return this.port;
2859
+ }
2694
2860
  /**
2695
2861
  * Set state manager
2696
2862
  */
2697
2863
  setStateManager(stateManager) {
2698
2864
  this.stateManager = stateManager;
2699
2865
  }
2866
+ /**
2867
+ * Parse log level from a process output line.
2868
+ * Matches bare prefixes (e.g. "INFO target > msg") and bracketed prefixes (e.g. "[INFO] msg").
2869
+ * Falls back to the provided default if no known level prefix is found.
2870
+ */
2871
+ parseLogLevel(message, fallback) {
2872
+ const match = message.trimStart().match(/^\[?(\w+)\]?/);
2873
+ const prefix = match?.[1]?.toUpperCase();
2874
+ switch (prefix) {
2875
+ case "TRACE":
2876
+ return 0;
2877
+ case "DEBUG":
2878
+ return 1;
2879
+ case "INFO":
2880
+ return 2;
2881
+ case "WARN":
2882
+ return 3;
2883
+ case "ERROR":
2884
+ return 4;
2885
+ default:
2886
+ return fallback;
2887
+ }
2888
+ }
2700
2889
  /**
2701
2890
  * Setup log capture from process stdout/stderr
2702
2891
  */
@@ -2705,13 +2894,17 @@ var HttpWasmRunner = class {
2705
2894
  this.process.stdout?.on("data", (data) => {
2706
2895
  const message = data.toString().trim();
2707
2896
  if (message) {
2708
- this.logs.push({ level: 2, message });
2897
+ const log = { level: this.parseLogLevel(message, 2), message };
2898
+ this.logs.push(log);
2899
+ this.stateManager?.emitHttpWasmLog(log);
2709
2900
  }
2710
2901
  });
2711
2902
  this.process.stderr?.on("data", (data) => {
2712
2903
  const message = data.toString().trim();
2713
2904
  if (message) {
2714
- this.logs.push({ level: 4, message });
2905
+ const log = { level: this.parseLogLevel(message, 4), message };
2906
+ this.logs.push(log);
2907
+ this.stateManager?.emitHttpWasmLog(log);
2715
2908
  }
2716
2909
  });
2717
2910
  }
@@ -2741,66 +2934,54 @@ var HttpWasmRunner = class {
2741
2934
  });
2742
2935
  }
2743
2936
  /**
2744
- * Probe port with a single HTTP GET, returning true if any response is received.
2745
- * Uses Node.js http module with explicit content-length: 0 so that wasi-http
2746
- * runtimes (fastedge-run) immediately signal EOF to the WASM request body
2747
- * stream. Without this, newer fastedge-run builds hold the body stream open
2748
- * on keep-alive connections, causing the WASM's event.request.text() to hang
2749
- * indefinitely and never send a response.
2750
- */
2751
- probePort(port, timeoutMs) {
2752
- return new Promise((resolve) => {
2753
- const req = http.request(
2754
- {
2755
- hostname: "localhost",
2756
- port,
2757
- path: "/",
2758
- method: "GET",
2759
- headers: {
2760
- "content-length": "0",
2761
- connection: "close"
2762
- }
2763
- },
2764
- (res) => {
2765
- res.resume();
2766
- resolve(true);
2767
- }
2768
- );
2769
- req.setTimeout(timeoutMs, () => {
2770
- req.destroy();
2771
- resolve(false);
2772
- });
2773
- req.on("error", () => resolve(false));
2774
- req.end();
2775
- });
2776
- }
2777
- /**
2778
- * Wait for server to be ready by polling
2937
+ * Wait for the fastedge-run HTTP server to be ready by watching process logs
2938
+ * for the "Listening on" message.
2939
+ *
2940
+ * We intentionally avoid HTTP probing here. HTTP probes trigger WASM execution
2941
+ * (the app calls event.request.text() to read the body), which can hang for
2942
+ * many seconds in CI due to WASM JIT compilation on the first request, or
2943
+ * because newer fastedge-run builds hold the body stream open regardless of
2944
+ * content-length. Watching logs avoids all of this: fastedge-run emits
2945
+ * "Listening on http://127.0.0.1:<port>" as soon as the HTTP listener is bound,
2946
+ * before any WASM execution occurs.
2779
2947
  */
2780
- async waitForServerReady(port, timeoutMs) {
2948
+ waitForServerReady(port, timeoutMs) {
2781
2949
  const startTime = Date.now();
2782
- while (Date.now() - startTime < timeoutMs) {
2783
- const ready = await this.probePort(port, 5e3);
2784
- if (ready) return;
2785
- if (this.process && this.process.exitCode !== null) {
2786
- throw new Error(
2787
- `FastEdge-run process exited with code ${this.process.exitCode} before server started`
2788
- );
2789
- }
2790
- await new Promise((resolve) => setTimeout(resolve, 100));
2791
- }
2792
- const processInfo = this.process ? `Process state: exitCode=${this.process.exitCode}, killed=${this.process.killed}, pid=${this.process.pid}` : "Process is null";
2793
- const recentLogs = this.logs.slice(-5).map((l) => `[${l.level}] ${l.message}`).join("\n");
2794
- throw new Error(
2795
- `FastEdge-run server did not start within ${timeoutMs}ms on port ${port}
2950
+ return new Promise((resolve, reject) => {
2951
+ const check = () => {
2952
+ if (this.process && this.process.exitCode !== null) {
2953
+ return reject(
2954
+ new Error(
2955
+ `FastEdge-run process exited with code ${this.process.exitCode} before server started`
2956
+ )
2957
+ );
2958
+ }
2959
+ if (this.logs.some((l) => l.message.includes("Listening on"))) {
2960
+ return resolve();
2961
+ }
2962
+ if (Date.now() - startTime >= timeoutMs) {
2963
+ const processInfo = this.process ? `Process state: exitCode=${this.process.exitCode}, killed=${this.process.killed}, pid=${this.process.pid}` : "Process is null";
2964
+ const recentLogs = this.logs.slice(-5).map((l) => `[${l.level}] ${l.message}`).join("\n");
2965
+ return reject(
2966
+ new Error(
2967
+ `FastEdge-run server did not start within ${timeoutMs}ms on port ${port}
2796
2968
  ${processInfo}
2797
2969
  Recent logs:
2798
2970
  ${recentLogs || "(no logs)"}`
2799
- );
2971
+ )
2972
+ );
2973
+ }
2974
+ setTimeout(check, 50);
2975
+ };
2976
+ check();
2977
+ });
2800
2978
  }
2801
2979
  /**
2802
- * Kill the process gracefully (SIGINT) with fallback to SIGKILL
2803
- * FastEdge-run responds to SIGINT for graceful shutdown
2980
+ * Kill the process gracefully (SIGINT) with platform-specific force-kill fallback.
2981
+ * SIGINT is sent first on all platforms — Node.js translates it for Windows.
2982
+ * If the process does not exit within 2 seconds:
2983
+ * - Windows: taskkill /F /T to terminate the process tree
2984
+ * - Unix: SIGKILL
2804
2985
  */
2805
2986
  async killProcess() {
2806
2987
  if (!this.process) return;
@@ -2811,8 +2992,18 @@ ${recentLogs || "(no logs)"}`
2811
2992
  }
2812
2993
  this.process.kill("SIGINT");
2813
2994
  const timeout = setTimeout(() => {
2814
- if (this.process && !this.process.killed) {
2815
- this.process.kill("SIGKILL");
2995
+ if (this.process && this.process.exitCode === null && this.process.signalCode === null) {
2996
+ if (process.platform === "win32") {
2997
+ const pid = this.process.pid;
2998
+ if (pid) {
2999
+ try {
3000
+ (0, import_child_process2.execSync)(`taskkill /F /T /PID ${pid}`);
3001
+ } catch {
3002
+ }
3003
+ }
3004
+ } else {
3005
+ this.process.kill("SIGKILL");
3006
+ }
2816
3007
  }
2817
3008
  resolve();
2818
3009
  }, 2e3);
@@ -2835,9 +3026,7 @@ ${recentLogs || "(no logs)"}`
2835
3026
  "application/zip",
2836
3027
  "application/gzip"
2837
3028
  ];
2838
- return binaryTypes.some(
2839
- (type) => contentType.toLowerCase().includes(type)
2840
- );
3029
+ return binaryTypes.some((type) => contentType.toLowerCase().includes(type));
2841
3030
  }
2842
3031
  /**
2843
3032
  * Parse headers from fetch Headers object
@@ -2852,6 +3041,7 @@ ${recentLogs || "(no logs)"}`
2852
3041
  };
2853
3042
 
2854
3043
  // server/runner/PortManager.ts
3044
+ var import_net = require("net");
2855
3045
  var PortManager = class {
2856
3046
  constructor() {
2857
3047
  this.minPort = 8100;
@@ -2859,18 +3049,31 @@ var PortManager = class {
2859
3049
  this.allocatedPorts = /* @__PURE__ */ new Set();
2860
3050
  this.lastAllocatedPort = this.minPort - 1;
2861
3051
  }
2862
- // Track last allocated port for sequential allocation
2863
3052
  /**
2864
- * Allocate an available port from the pool
2865
- * Sequential allocation to avoid reusing recently released ports (TCP TIME_WAIT)
2866
- * Synchronous to prevent race conditions when allocating in parallel
3053
+ * Check whether a port is actually free at the OS level.
3054
+ * This is necessary when multiple server processes run simultaneously
3055
+ * each has its own PortManager with independent in-memory state, so
3056
+ * in-memory tracking alone is not enough to prevent cross-process conflicts.
3057
+ */
3058
+ isPortFree(port) {
3059
+ return new Promise((resolve) => {
3060
+ const server = (0, import_net.createServer)();
3061
+ server.once("error", () => resolve(false));
3062
+ server.once("listening", () => server.close(() => resolve(true)));
3063
+ server.listen(port, "127.0.0.1");
3064
+ });
3065
+ }
3066
+ /**
3067
+ * Allocate an available port from the pool.
3068
+ * Combines in-memory tracking (avoids TCP TIME_WAIT reuse within this process)
3069
+ * with an OS-level check (avoids cross-process collisions).
2867
3070
  * @returns The allocated port number
2868
3071
  * @throws Error if no ports are available
2869
3072
  */
2870
- allocate() {
3073
+ async allocate() {
2871
3074
  for (let offset = 1; offset <= this.maxPort - this.minPort + 1; offset++) {
2872
3075
  const port = this.minPort + (this.lastAllocatedPort - this.minPort + offset) % (this.maxPort - this.minPort + 1);
2873
- if (!this.allocatedPorts.has(port)) {
3076
+ if (!this.allocatedPorts.has(port) && await this.isPortFree(port)) {
2874
3077
  this.allocatedPorts.add(port);
2875
3078
  this.lastAllocatedPort = port;
2876
3079
  return port;
@@ -2882,32 +3085,19 @@ var PortManager = class {
2882
3085
  }
2883
3086
  /**
2884
3087
  * Release a previously allocated port back to the pool
2885
- * @param port The port number to release
2886
3088
  */
2887
3089
  release(port) {
2888
3090
  this.allocatedPorts.delete(port);
2889
3091
  }
2890
- /**
2891
- * Get the number of currently allocated ports
2892
- */
2893
3092
  getAllocatedCount() {
2894
3093
  return this.allocatedPorts.size;
2895
3094
  }
2896
- /**
2897
- * Get the number of available ports
2898
- */
2899
3095
  getAvailableCount() {
2900
3096
  return this.maxPort - this.minPort + 1 - this.allocatedPorts.size;
2901
3097
  }
2902
- /**
2903
- * Check if a specific port is allocated
2904
- */
2905
3098
  isAllocated(port) {
2906
3099
  return this.allocatedPorts.has(port);
2907
3100
  }
2908
- /**
2909
- * Reset all allocations (useful for testing)
2910
- */
2911
3101
  reset() {
2912
3102
  this.allocatedPorts.clear();
2913
3103
  }
@@ -2957,6 +3147,8 @@ var NullStateManager = class {
2957
3147
  }
2958
3148
  emitHttpWasmRequestCompleted() {
2959
3149
  }
3150
+ emitHttpWasmLog() {
3151
+ }
2960
3152
  emitReloadWorkspaceWasm() {
2961
3153
  }
2962
3154
  };
@@ -3003,9 +3195,9 @@ async function createRunnerFromBuffer(buffer, config) {
3003
3195
  const wasmType = config?.runnerType ?? await detectWasmType(buffer);
3004
3196
  let runner;
3005
3197
  if (wasmType === "http-wasm") {
3006
- runner = new HttpWasmRunner(new PortManager(), config?.dotenvEnabled ?? false);
3198
+ runner = new HttpWasmRunner(new PortManager(), config?.dotenv?.enabled ?? false);
3007
3199
  } else {
3008
- runner = new ProxyWasmRunner(void 0, config?.dotenvEnabled ?? false);
3200
+ runner = new ProxyWasmRunner(void 0, config?.dotenv?.enabled ?? false);
3009
3201
  }
3010
3202
  await runner.load(buffer, config);
3011
3203
  return runner;