@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
package/dist/lib/index.js CHANGED
@@ -730,6 +730,8 @@ var HostFunctions = class {
730
730
  this.pendingHttpCall = null;
731
731
  this.httpCallResponse = null;
732
732
  this.streamClosed = false;
733
+ // Local response state (from proxy_send_local_response / send_http_response)
734
+ this.localResponse = null;
733
735
  this.memory = memory;
734
736
  this.propertyResolver = propertyResolver;
735
737
  this.propertyAccessControl = propertyAccessControl;
@@ -784,6 +786,16 @@ var HostFunctions = class {
784
786
  resetStreamClosed() {
785
787
  this.streamClosed = false;
786
788
  }
789
+ // Local response accessors (called by ProxyWasmRunner after callHook)
790
+ hasLocalResponse() {
791
+ return this.localResponse !== null;
792
+ }
793
+ getLocalResponse() {
794
+ return this.localResponse;
795
+ }
796
+ resetLocalResponse() {
797
+ this.localResponse = null;
798
+ }
787
799
  getRequestHeaders() {
788
800
  return this.requestHeaders;
789
801
  }
@@ -1044,15 +1056,21 @@ var HostFunctions = class {
1044
1056
  this.currentContextId = contextId || this.currentContextId;
1045
1057
  return 0 /* Ok */;
1046
1058
  },
1047
- proxy_send_local_response: (statusCode, statusCodePtr, statusCodeLen, bodyPtr, bodyLen, grpcStatus) => {
1059
+ proxy_send_local_response: (statusCode, statusCodePtr, statusCodeLen, bodyPtr, bodyLen, headerPairsPtr, headerPairsLen, grpcStatus) => {
1048
1060
  this.setLastHostCall(
1049
1061
  `proxy_send_local_response status=${statusCode} bodyLen=${bodyLen}`
1050
1062
  );
1051
1063
  const statusText = this.memory.readString(statusCodePtr, statusCodeLen);
1052
- const body = this.memory.readString(bodyPtr, bodyLen);
1064
+ const body = this.memory.readBytes(bodyPtr, bodyLen);
1065
+ if (headerPairsLen > 0) {
1066
+ const headerBytes = this.memory.readBytes(headerPairsPtr, headerPairsLen);
1067
+ const headers = HeaderManager.deserializeBinary(headerBytes);
1068
+ this.logDebug(`send_local_response headers (not merged): ${JSON.stringify(headers)}`);
1069
+ }
1070
+ this.localResponse = { statusCode, statusText, body };
1053
1071
  this.logs.push({
1054
1072
  level: 1,
1055
- message: `local_response status=${statusCode} ${statusText} body=${body} grpc=${grpcStatus}`
1073
+ message: `local_response status=${statusCode} ${statusText} bodyLen=${body.byteLength} grpc=${grpcStatus}`
1056
1074
  });
1057
1075
  return 0 /* Ok */;
1058
1076
  },
@@ -1632,7 +1650,6 @@ async function loadDotenvFiles(dotenvPath = ".") {
1632
1650
  // server/runner/ProxyWasmRunner.ts
1633
1651
  var textEncoder4 = new TextEncoder();
1634
1652
  var ProxyWasmRunner = class {
1635
- // Default to enabled
1636
1653
  constructor(fastEdgeConfig, dotenvEnabled = true) {
1637
1654
  this.module = null;
1638
1655
  // Compiled module (reused)
@@ -1646,6 +1663,8 @@ var ProxyWasmRunner = class {
1646
1663
  this.debug = process.env.PROXY_RUNNER_DEBUG === "1";
1647
1664
  this.stateManager = null;
1648
1665
  this.dotenvEnabled = true;
1666
+ // Default to enabled
1667
+ this.dotenvPath = ".";
1649
1668
  this.memory = new MemoryManager();
1650
1669
  this.propertyResolver = new PropertyResolver();
1651
1670
  this.propertyAccessControl = new PropertyAccessControl();
@@ -1680,7 +1699,7 @@ var ProxyWasmRunner = class {
1680
1699
  return;
1681
1700
  }
1682
1701
  try {
1683
- const dotenvConfig = await loadDotenvFiles(".");
1702
+ const dotenvConfig = await loadDotenvFiles(this.dotenvPath);
1684
1703
  if (dotenvConfig.secrets && Object.keys(dotenvConfig.secrets).length > 0) {
1685
1704
  const existingSecrets = this.secretStore.getAll();
1686
1705
  this.secretStore = new SecretStore({
@@ -1712,9 +1731,37 @@ var ProxyWasmRunner = class {
1712
1731
  console.error("Failed to load dotenv files:", error);
1713
1732
  }
1714
1733
  }
1734
+ /**
1735
+ * Apply dotenv settings to the running module without recompiling.
1736
+ * Resets SecretStore/Dictionary to empty, then re-loads dotenv files
1737
+ * using the new settings. The compiled WASM module is untouched.
1738
+ */
1739
+ async applyDotenv(enabled, dotenvPath) {
1740
+ this.dotenvEnabled = enabled;
1741
+ if (dotenvPath !== void 0) {
1742
+ this.dotenvPath = dotenvPath;
1743
+ }
1744
+ this.secretStore = new SecretStore();
1745
+ this.dictionary = new Dictionary();
1746
+ await this.loadDotenvIfEnabled();
1747
+ if (!enabled) {
1748
+ this.hostFunctions = new HostFunctions(
1749
+ this.memory,
1750
+ this.propertyResolver,
1751
+ this.propertyAccessControl,
1752
+ () => this.currentHook,
1753
+ this.debug,
1754
+ this.secretStore,
1755
+ this.dictionary
1756
+ );
1757
+ }
1758
+ }
1715
1759
  async load(bufferOrPath, config) {
1716
- if (config?.dotenvEnabled !== void 0) {
1717
- this.dotenvEnabled = config.dotenvEnabled;
1760
+ if (config?.dotenv?.enabled !== void 0) {
1761
+ this.dotenvEnabled = config.dotenv.enabled;
1762
+ }
1763
+ if (config?.dotenv?.path !== void 0) {
1764
+ this.dotenvPath = config.dotenv.path;
1718
1765
  }
1719
1766
  this.resetState();
1720
1767
  let buffer;
@@ -1785,6 +1832,25 @@ var ProxyWasmRunner = class {
1785
1832
  "system"
1786
1833
  );
1787
1834
  }
1835
+ if (results.onRequestHeaders.returnCode === 1 && this.hostFunctions.hasLocalResponse()) {
1836
+ const local = this.hostFunctions.getLocalResponse();
1837
+ const responseHeaders = results.onRequestHeaders.output.response.headers;
1838
+ this.hostFunctions.resetLocalResponse();
1839
+ const contentType = responseHeaders["content-type"] || "text/plain";
1840
+ const { body, isBase64 } = encodeLocalResponseBody(local.body, contentType);
1841
+ return {
1842
+ hookResults: results,
1843
+ finalResponse: {
1844
+ status: local.statusCode,
1845
+ statusText: local.statusText,
1846
+ headers: responseHeaders,
1847
+ body,
1848
+ contentType,
1849
+ isBase64
1850
+ },
1851
+ calculatedProperties: this.propertyResolver.getCalculatedProperties()
1852
+ };
1853
+ }
1788
1854
  const headersAfterRequestHeaders = results.onRequestHeaders.output.request.headers;
1789
1855
  const propertiesAfterRequestHeaders = results.onRequestHeaders.properties;
1790
1856
  this.logDebug(
@@ -1809,6 +1875,25 @@ var ProxyWasmRunner = class {
1809
1875
  "system"
1810
1876
  );
1811
1877
  }
1878
+ if (results.onRequestBody.returnCode === 1 && this.hostFunctions.hasLocalResponse()) {
1879
+ const local = this.hostFunctions.getLocalResponse();
1880
+ const responseHeaders = results.onRequestBody.output.response.headers;
1881
+ this.hostFunctions.resetLocalResponse();
1882
+ const contentType = responseHeaders["content-type"] || "text/plain";
1883
+ const { body, isBase64 } = encodeLocalResponseBody(local.body, contentType);
1884
+ return {
1885
+ hookResults: results,
1886
+ finalResponse: {
1887
+ status: local.statusCode,
1888
+ statusText: local.statusText,
1889
+ headers: responseHeaders,
1890
+ body,
1891
+ contentType,
1892
+ isBase64
1893
+ },
1894
+ calculatedProperties: this.propertyResolver.getCalculatedProperties()
1895
+ };
1896
+ }
1812
1897
  const modifiedRequestHeaders = results.onRequestBody.output.request.headers;
1813
1898
  const modifiedRequestBody = results.onRequestBody.output.request.body;
1814
1899
  const propertiesAfterRequestBody = results.onRequestBody.properties;
@@ -1997,6 +2082,7 @@ var ProxyWasmRunner = class {
1997
2082
  throw new Error("WASM module not loaded");
1998
2083
  }
1999
2084
  this.currentHook = this.getHookContext(call.hook);
2085
+ this.hostFunctions.resetLocalResponse();
2000
2086
  const imports = this.createImports();
2001
2087
  this.instance = await WebAssembly.instantiate(this.module, imports);
2002
2088
  const memory = this.instance.exports.memory;
@@ -2395,7 +2481,7 @@ var ProxyWasmRunner = class {
2395
2481
  /**
2396
2482
  * Not supported for Proxy-WASM (HTTP WASM only)
2397
2483
  */
2398
- async execute(request2) {
2484
+ async execute(request) {
2399
2485
  throw new Error(
2400
2486
  "execute() is not supported for Proxy-WASM. Use callHook() or callFullFlow() instead."
2401
2487
  );
@@ -2430,16 +2516,24 @@ function ensureNullTerminated(value) {
2430
2516
  }
2431
2517
  return value.endsWith("\0") ? value : `${value}\0`;
2432
2518
  }
2519
+ function encodeLocalResponseBody(body, contentType) {
2520
+ const isBinary = contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/") || contentType.includes("application/octet-stream") || contentType.includes("application/pdf") || contentType.includes("application/zip");
2521
+ if (isBinary) {
2522
+ return { body: Buffer.from(body).toString("base64"), isBase64: true };
2523
+ }
2524
+ return { body: new TextDecoder().decode(body), isBase64: false };
2525
+ }
2433
2526
 
2434
2527
  // server/runner/HttpWasmRunner.ts
2435
- import { spawn } from "child_process";
2436
- import * as http from "http";
2528
+ import { spawn, execSync as execSync2 } from "child_process";
2437
2529
 
2438
2530
  // server/utils/fastedge-cli.ts
2439
2531
  import { execSync } from "child_process";
2440
- import { existsSync } from "fs";
2441
- import { join } from "path";
2532
+ import { existsSync, chmodSync } from "fs";
2533
+ import { join, dirname } from "path";
2534
+ import { fileURLToPath } from "url";
2442
2535
  import os from "os";
2536
+ var _currentDir = dirname(fileURLToPath(import.meta.url));
2443
2537
  function getCliBinaryName() {
2444
2538
  switch (os.platform()) {
2445
2539
  case "win32":
@@ -2455,19 +2549,30 @@ function getCliBinaryName() {
2455
2549
  function getBundledCliPaths() {
2456
2550
  const binaryName = getCliBinaryName();
2457
2551
  return [
2458
- // Production: bundled server at dist/server.js
2459
- join(__dirname, "fastedge-cli", binaryName),
2552
+ // Installed npm package: dist/lib/index.js → dist/fastedge-cli/
2553
+ join(_currentDir, "..", "fastedge-cli", binaryName),
2554
+ // Production: bundled server at dist/server.js → dist/fastedge-cli/
2555
+ join(_currentDir, "fastedge-cli", binaryName),
2460
2556
  // Development/Tests: running from source
2461
- // __dirname might be server/utils/, so go up to project root
2462
- join(__dirname, "..", "..", "fastedge-run", binaryName),
2463
- // Alternative: if __dirname is already at project root
2464
- join(__dirname, "fastedge-run", binaryName)
2557
+ // _currentDir might be server/utils/, so go up to project root
2558
+ join(_currentDir, "..", "..", "fastedge-run", binaryName),
2559
+ // Alternative: if _currentDir is already at project root
2560
+ join(_currentDir, "fastedge-run", binaryName)
2465
2561
  ];
2466
2562
  }
2563
+ function ensureExecutable(binaryPath) {
2564
+ if (process.platform !== "win32") {
2565
+ try {
2566
+ chmodSync(binaryPath, 493);
2567
+ } catch {
2568
+ }
2569
+ }
2570
+ }
2467
2571
  async function findFastEdgeRunCli() {
2468
2572
  const envPath = process.env.FASTEDGE_RUN_PATH;
2469
2573
  if (envPath) {
2470
2574
  if (existsSync(envPath)) {
2575
+ ensureExecutable(envPath);
2471
2576
  return envPath;
2472
2577
  } else {
2473
2578
  throw new Error(
@@ -2477,6 +2582,7 @@ async function findFastEdgeRunCli() {
2477
2582
  }
2478
2583
  for (const bundledPath of getBundledCliPaths()) {
2479
2584
  if (existsSync(bundledPath)) {
2585
+ ensureExecutable(bundledPath);
2480
2586
  return bundledPath;
2481
2587
  }
2482
2588
  }
@@ -2524,9 +2630,12 @@ var HttpWasmRunner = class {
2524
2630
  this.port = null;
2525
2631
  this.cliPath = null;
2526
2632
  this.tempWasmPath = null;
2633
+ this.currentWasmPath = null;
2634
+ // resolved path used when spawning
2527
2635
  this.logs = [];
2528
2636
  this.stateManager = null;
2529
2637
  this.dotenvEnabled = true;
2638
+ this.dotenvPath = null;
2530
2639
  this.portManager = portManager;
2531
2640
  this.dotenvEnabled = dotenvEnabled;
2532
2641
  }
@@ -2534,8 +2643,11 @@ var HttpWasmRunner = class {
2534
2643
  * Load WASM binary and spawn fastedge-run process
2535
2644
  */
2536
2645
  async load(bufferOrPath, config) {
2537
- if (config?.dotenvEnabled !== void 0) {
2538
- this.dotenvEnabled = config.dotenvEnabled;
2646
+ if (config?.dotenv?.enabled !== void 0) {
2647
+ this.dotenvEnabled = config.dotenv.enabled;
2648
+ }
2649
+ if (config?.dotenv?.path !== void 0) {
2650
+ this.dotenvPath = config.dotenv.path;
2539
2651
  }
2540
2652
  await this.cleanup();
2541
2653
  this.cliPath = await findFastEdgeRunCli();
@@ -2547,7 +2659,7 @@ var HttpWasmRunner = class {
2547
2659
  wasmPath = await writeTempWasmFile(bufferOrPath);
2548
2660
  this.tempWasmPath = wasmPath;
2549
2661
  }
2550
- this.port = this.portManager.allocate();
2662
+ this.port = await this.portManager.allocate();
2551
2663
  const args = [
2552
2664
  "http",
2553
2665
  "-p",
@@ -2558,8 +2670,13 @@ var HttpWasmRunner = class {
2558
2670
  "true"
2559
2671
  ];
2560
2672
  if (this.dotenvEnabled) {
2561
- args.push("--dotenv");
2673
+ if (this.dotenvPath) {
2674
+ args.push("--dotenv", this.dotenvPath);
2675
+ } else {
2676
+ args.push("--dotenv");
2677
+ }
2562
2678
  }
2679
+ this.currentWasmPath = wasmPath;
2563
2680
  this.process = spawn(this.cliPath, args, {
2564
2681
  stdio: ["ignore", "pipe", "pipe"],
2565
2682
  env: {
@@ -2575,17 +2692,17 @@ var HttpWasmRunner = class {
2575
2692
  /**
2576
2693
  * Execute an HTTP request through the WASM module
2577
2694
  */
2578
- async execute(request2) {
2695
+ async execute(request) {
2579
2696
  if (!this.port || !this.process) {
2580
2697
  throw new Error("HttpWasmRunner not loaded. Call load() first.");
2581
2698
  }
2582
2699
  this.logs = [];
2583
2700
  try {
2584
- const url = `http://localhost:${this.port}${request2.path}`;
2701
+ const url = `http://localhost:${this.port}${request.path}`;
2585
2702
  const response = await fetch(url, {
2586
- method: request2.method,
2587
- headers: request2.headers,
2588
- body: request2.body || void 0,
2703
+ method: request.method,
2704
+ headers: request.headers,
2705
+ body: request.body || void 0,
2589
2706
  signal: AbortSignal.timeout(3e4)
2590
2707
  // 30 second timeout
2591
2708
  });
@@ -2613,7 +2730,7 @@ var HttpWasmRunner = class {
2613
2730
  /**
2614
2731
  * Not supported for HTTP WASM (proxy-wasm only)
2615
2732
  */
2616
- async callHook(hookCall) {
2733
+ async callHook(_hookCall) {
2617
2734
  throw new Error(
2618
2735
  "callHook() is not supported for HTTP WASM. Use execute() instead."
2619
2736
  );
@@ -2621,11 +2738,53 @@ var HttpWasmRunner = class {
2621
2738
  /**
2622
2739
  * Not supported for HTTP WASM (proxy-wasm only)
2623
2740
  */
2624
- async callFullFlow(url, method, headers, body, responseHeaders, responseBody, responseStatus, responseStatusText, properties, enforceProductionPropertyRules) {
2741
+ async callFullFlow(_url, _method, _headers, _body, _responseHeaders, _responseBody, _responseStatus, _responseStatusText, _properties, _enforceProductionPropertyRules) {
2625
2742
  throw new Error(
2626
2743
  "callFullFlow() is not supported for HTTP WASM. Use execute() instead."
2627
2744
  );
2628
2745
  }
2746
+ /**
2747
+ * Apply dotenv settings by restarting the fastedge-run process.
2748
+ * The WASM file is not re-read; only the --dotenv flag changes.
2749
+ */
2750
+ async applyDotenv(enabled, dotenvPath) {
2751
+ this.dotenvEnabled = enabled;
2752
+ if (dotenvPath !== void 0) {
2753
+ this.dotenvPath = dotenvPath;
2754
+ }
2755
+ if (!this.process || !this.currentWasmPath || !this.cliPath || this.port === null) {
2756
+ return;
2757
+ }
2758
+ await this.killProcess();
2759
+ this.process = null;
2760
+ const args = [
2761
+ "http",
2762
+ "-p",
2763
+ this.port.toString(),
2764
+ "-w",
2765
+ this.currentWasmPath,
2766
+ "--wasi-http",
2767
+ "true"
2768
+ ];
2769
+ if (this.dotenvEnabled) {
2770
+ if (this.dotenvPath) {
2771
+ args.push("--dotenv", this.dotenvPath);
2772
+ } else {
2773
+ args.push("--dotenv");
2774
+ }
2775
+ }
2776
+ this.process = spawn(this.cliPath, args, {
2777
+ stdio: ["ignore", "pipe", "pipe"],
2778
+ env: {
2779
+ RUST_LOG: "info",
2780
+ ...process.env
2781
+ }
2782
+ });
2783
+ this.setupLogCapture();
2784
+ this.setupErrorHandlers();
2785
+ const timeout = process.env.NODE_ENV === "test" || process.env.VITEST ? 2e4 : 1e4;
2786
+ await this.waitForServerReady(this.port, timeout);
2787
+ }
2629
2788
  /**
2630
2789
  * Clean up resources
2631
2790
  */
@@ -2650,12 +2809,41 @@ var HttpWasmRunner = class {
2650
2809
  getType() {
2651
2810
  return "http-wasm";
2652
2811
  }
2812
+ /**
2813
+ * Get the port the fastedge-run HTTP server is listening on
2814
+ */
2815
+ getPort() {
2816
+ return this.port;
2817
+ }
2653
2818
  /**
2654
2819
  * Set state manager
2655
2820
  */
2656
2821
  setStateManager(stateManager) {
2657
2822
  this.stateManager = stateManager;
2658
2823
  }
2824
+ /**
2825
+ * Parse log level from a process output line.
2826
+ * Matches bare prefixes (e.g. "INFO target > msg") and bracketed prefixes (e.g. "[INFO] msg").
2827
+ * Falls back to the provided default if no known level prefix is found.
2828
+ */
2829
+ parseLogLevel(message, fallback) {
2830
+ const match = message.trimStart().match(/^\[?(\w+)\]?/);
2831
+ const prefix = match?.[1]?.toUpperCase();
2832
+ switch (prefix) {
2833
+ case "TRACE":
2834
+ return 0;
2835
+ case "DEBUG":
2836
+ return 1;
2837
+ case "INFO":
2838
+ return 2;
2839
+ case "WARN":
2840
+ return 3;
2841
+ case "ERROR":
2842
+ return 4;
2843
+ default:
2844
+ return fallback;
2845
+ }
2846
+ }
2659
2847
  /**
2660
2848
  * Setup log capture from process stdout/stderr
2661
2849
  */
@@ -2664,13 +2852,17 @@ var HttpWasmRunner = class {
2664
2852
  this.process.stdout?.on("data", (data) => {
2665
2853
  const message = data.toString().trim();
2666
2854
  if (message) {
2667
- this.logs.push({ level: 2, message });
2855
+ const log = { level: this.parseLogLevel(message, 2), message };
2856
+ this.logs.push(log);
2857
+ this.stateManager?.emitHttpWasmLog(log);
2668
2858
  }
2669
2859
  });
2670
2860
  this.process.stderr?.on("data", (data) => {
2671
2861
  const message = data.toString().trim();
2672
2862
  if (message) {
2673
- this.logs.push({ level: 4, message });
2863
+ const log = { level: this.parseLogLevel(message, 4), message };
2864
+ this.logs.push(log);
2865
+ this.stateManager?.emitHttpWasmLog(log);
2674
2866
  }
2675
2867
  });
2676
2868
  }
@@ -2700,66 +2892,54 @@ var HttpWasmRunner = class {
2700
2892
  });
2701
2893
  }
2702
2894
  /**
2703
- * Probe port with a single HTTP GET, returning true if any response is received.
2704
- * Uses Node.js http module with explicit content-length: 0 so that wasi-http
2705
- * runtimes (fastedge-run) immediately signal EOF to the WASM request body
2706
- * stream. Without this, newer fastedge-run builds hold the body stream open
2707
- * on keep-alive connections, causing the WASM's event.request.text() to hang
2708
- * indefinitely and never send a response.
2709
- */
2710
- probePort(port, timeoutMs) {
2711
- return new Promise((resolve) => {
2712
- const req = http.request(
2713
- {
2714
- hostname: "localhost",
2715
- port,
2716
- path: "/",
2717
- method: "GET",
2718
- headers: {
2719
- "content-length": "0",
2720
- connection: "close"
2721
- }
2722
- },
2723
- (res) => {
2724
- res.resume();
2725
- resolve(true);
2726
- }
2727
- );
2728
- req.setTimeout(timeoutMs, () => {
2729
- req.destroy();
2730
- resolve(false);
2731
- });
2732
- req.on("error", () => resolve(false));
2733
- req.end();
2734
- });
2735
- }
2736
- /**
2737
- * Wait for server to be ready by polling
2895
+ * Wait for the fastedge-run HTTP server to be ready by watching process logs
2896
+ * for the "Listening on" message.
2897
+ *
2898
+ * We intentionally avoid HTTP probing here. HTTP probes trigger WASM execution
2899
+ * (the app calls event.request.text() to read the body), which can hang for
2900
+ * many seconds in CI due to WASM JIT compilation on the first request, or
2901
+ * because newer fastedge-run builds hold the body stream open regardless of
2902
+ * content-length. Watching logs avoids all of this: fastedge-run emits
2903
+ * "Listening on http://127.0.0.1:<port>" as soon as the HTTP listener is bound,
2904
+ * before any WASM execution occurs.
2738
2905
  */
2739
- async waitForServerReady(port, timeoutMs) {
2906
+ waitForServerReady(port, timeoutMs) {
2740
2907
  const startTime = Date.now();
2741
- while (Date.now() - startTime < timeoutMs) {
2742
- const ready = await this.probePort(port, 5e3);
2743
- if (ready) return;
2744
- if (this.process && this.process.exitCode !== null) {
2745
- throw new Error(
2746
- `FastEdge-run process exited with code ${this.process.exitCode} before server started`
2747
- );
2748
- }
2749
- await new Promise((resolve) => setTimeout(resolve, 100));
2750
- }
2751
- const processInfo = this.process ? `Process state: exitCode=${this.process.exitCode}, killed=${this.process.killed}, pid=${this.process.pid}` : "Process is null";
2752
- const recentLogs = this.logs.slice(-5).map((l) => `[${l.level}] ${l.message}`).join("\n");
2753
- throw new Error(
2754
- `FastEdge-run server did not start within ${timeoutMs}ms on port ${port}
2908
+ return new Promise((resolve, reject) => {
2909
+ const check = () => {
2910
+ if (this.process && this.process.exitCode !== null) {
2911
+ return reject(
2912
+ new Error(
2913
+ `FastEdge-run process exited with code ${this.process.exitCode} before server started`
2914
+ )
2915
+ );
2916
+ }
2917
+ if (this.logs.some((l) => l.message.includes("Listening on"))) {
2918
+ return resolve();
2919
+ }
2920
+ if (Date.now() - startTime >= timeoutMs) {
2921
+ const processInfo = this.process ? `Process state: exitCode=${this.process.exitCode}, killed=${this.process.killed}, pid=${this.process.pid}` : "Process is null";
2922
+ const recentLogs = this.logs.slice(-5).map((l) => `[${l.level}] ${l.message}`).join("\n");
2923
+ return reject(
2924
+ new Error(
2925
+ `FastEdge-run server did not start within ${timeoutMs}ms on port ${port}
2755
2926
  ${processInfo}
2756
2927
  Recent logs:
2757
2928
  ${recentLogs || "(no logs)"}`
2758
- );
2929
+ )
2930
+ );
2931
+ }
2932
+ setTimeout(check, 50);
2933
+ };
2934
+ check();
2935
+ });
2759
2936
  }
2760
2937
  /**
2761
- * Kill the process gracefully (SIGINT) with fallback to SIGKILL
2762
- * FastEdge-run responds to SIGINT for graceful shutdown
2938
+ * Kill the process gracefully (SIGINT) with platform-specific force-kill fallback.
2939
+ * SIGINT is sent first on all platforms — Node.js translates it for Windows.
2940
+ * If the process does not exit within 2 seconds:
2941
+ * - Windows: taskkill /F /T to terminate the process tree
2942
+ * - Unix: SIGKILL
2763
2943
  */
2764
2944
  async killProcess() {
2765
2945
  if (!this.process) return;
@@ -2770,8 +2950,18 @@ ${recentLogs || "(no logs)"}`
2770
2950
  }
2771
2951
  this.process.kill("SIGINT");
2772
2952
  const timeout = setTimeout(() => {
2773
- if (this.process && !this.process.killed) {
2774
- this.process.kill("SIGKILL");
2953
+ if (this.process && this.process.exitCode === null && this.process.signalCode === null) {
2954
+ if (process.platform === "win32") {
2955
+ const pid = this.process.pid;
2956
+ if (pid) {
2957
+ try {
2958
+ execSync2(`taskkill /F /T /PID ${pid}`);
2959
+ } catch {
2960
+ }
2961
+ }
2962
+ } else {
2963
+ this.process.kill("SIGKILL");
2964
+ }
2775
2965
  }
2776
2966
  resolve();
2777
2967
  }, 2e3);
@@ -2794,9 +2984,7 @@ ${recentLogs || "(no logs)"}`
2794
2984
  "application/zip",
2795
2985
  "application/gzip"
2796
2986
  ];
2797
- return binaryTypes.some(
2798
- (type) => contentType.toLowerCase().includes(type)
2799
- );
2987
+ return binaryTypes.some((type) => contentType.toLowerCase().includes(type));
2800
2988
  }
2801
2989
  /**
2802
2990
  * Parse headers from fetch Headers object
@@ -2811,6 +2999,7 @@ ${recentLogs || "(no logs)"}`
2811
2999
  };
2812
3000
 
2813
3001
  // server/runner/PortManager.ts
3002
+ import { createServer } from "net";
2814
3003
  var PortManager = class {
2815
3004
  constructor() {
2816
3005
  this.minPort = 8100;
@@ -2818,18 +3007,31 @@ var PortManager = class {
2818
3007
  this.allocatedPorts = /* @__PURE__ */ new Set();
2819
3008
  this.lastAllocatedPort = this.minPort - 1;
2820
3009
  }
2821
- // Track last allocated port for sequential allocation
2822
3010
  /**
2823
- * Allocate an available port from the pool
2824
- * Sequential allocation to avoid reusing recently released ports (TCP TIME_WAIT)
2825
- * Synchronous to prevent race conditions when allocating in parallel
3011
+ * Check whether a port is actually free at the OS level.
3012
+ * This is necessary when multiple server processes run simultaneously
3013
+ * each has its own PortManager with independent in-memory state, so
3014
+ * in-memory tracking alone is not enough to prevent cross-process conflicts.
3015
+ */
3016
+ isPortFree(port) {
3017
+ return new Promise((resolve) => {
3018
+ const server = createServer();
3019
+ server.once("error", () => resolve(false));
3020
+ server.once("listening", () => server.close(() => resolve(true)));
3021
+ server.listen(port, "127.0.0.1");
3022
+ });
3023
+ }
3024
+ /**
3025
+ * Allocate an available port from the pool.
3026
+ * Combines in-memory tracking (avoids TCP TIME_WAIT reuse within this process)
3027
+ * with an OS-level check (avoids cross-process collisions).
2826
3028
  * @returns The allocated port number
2827
3029
  * @throws Error if no ports are available
2828
3030
  */
2829
- allocate() {
3031
+ async allocate() {
2830
3032
  for (let offset = 1; offset <= this.maxPort - this.minPort + 1; offset++) {
2831
3033
  const port = this.minPort + (this.lastAllocatedPort - this.minPort + offset) % (this.maxPort - this.minPort + 1);
2832
- if (!this.allocatedPorts.has(port)) {
3034
+ if (!this.allocatedPorts.has(port) && await this.isPortFree(port)) {
2833
3035
  this.allocatedPorts.add(port);
2834
3036
  this.lastAllocatedPort = port;
2835
3037
  return port;
@@ -2841,32 +3043,19 @@ var PortManager = class {
2841
3043
  }
2842
3044
  /**
2843
3045
  * Release a previously allocated port back to the pool
2844
- * @param port The port number to release
2845
3046
  */
2846
3047
  release(port) {
2847
3048
  this.allocatedPorts.delete(port);
2848
3049
  }
2849
- /**
2850
- * Get the number of currently allocated ports
2851
- */
2852
3050
  getAllocatedCount() {
2853
3051
  return this.allocatedPorts.size;
2854
3052
  }
2855
- /**
2856
- * Get the number of available ports
2857
- */
2858
3053
  getAvailableCount() {
2859
3054
  return this.maxPort - this.minPort + 1 - this.allocatedPorts.size;
2860
3055
  }
2861
- /**
2862
- * Check if a specific port is allocated
2863
- */
2864
3056
  isAllocated(port) {
2865
3057
  return this.allocatedPorts.has(port);
2866
3058
  }
2867
- /**
2868
- * Reset all allocations (useful for testing)
2869
- */
2870
3059
  reset() {
2871
3060
  this.allocatedPorts.clear();
2872
3061
  }
@@ -2916,6 +3105,8 @@ var NullStateManager = class {
2916
3105
  }
2917
3106
  emitHttpWasmRequestCompleted() {
2918
3107
  }
3108
+ emitHttpWasmLog() {
3109
+ }
2919
3110
  emitReloadWorkspaceWasm() {
2920
3111
  }
2921
3112
  };
@@ -2962,9 +3153,9 @@ async function createRunnerFromBuffer(buffer, config) {
2962
3153
  const wasmType = config?.runnerType ?? await detectWasmType(buffer);
2963
3154
  let runner;
2964
3155
  if (wasmType === "http-wasm") {
2965
- runner = new HttpWasmRunner(new PortManager(), config?.dotenvEnabled ?? false);
3156
+ runner = new HttpWasmRunner(new PortManager(), config?.dotenv?.enabled ?? false);
2966
3157
  } else {
2967
- runner = new ProxyWasmRunner(void 0, config?.dotenvEnabled ?? false);
3158
+ runner = new ProxyWasmRunner(void 0, config?.dotenv?.enabled ?? false);
2968
3159
  }
2969
3160
  await runner.load(buffer, config);
2970
3161
  return runner;