almostnode 0.2.7 → 0.2.9

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 (64) hide show
  1. package/README.md +4 -2
  2. package/dist/CNAME +1 -0
  3. package/dist/__sw__.js +80 -84
  4. package/dist/assets/{runtime-worker-B8_LZkBX.js → runtime-worker-ujGAG2t7.js} +1278 -828
  5. package/dist/assets/runtime-worker-ujGAG2t7.js.map +1 -0
  6. package/dist/frameworks/code-transforms.d.ts.map +1 -1
  7. package/dist/frameworks/next-config-parser.d.ts +16 -0
  8. package/dist/frameworks/next-config-parser.d.ts.map +1 -0
  9. package/dist/frameworks/next-dev-server.d.ts +6 -6
  10. package/dist/frameworks/next-dev-server.d.ts.map +1 -1
  11. package/dist/frameworks/next-html-generator.d.ts +35 -0
  12. package/dist/frameworks/next-html-generator.d.ts.map +1 -0
  13. package/dist/frameworks/next-shims.d.ts +79 -0
  14. package/dist/frameworks/next-shims.d.ts.map +1 -0
  15. package/dist/index.cjs +3024 -2465
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.mjs +3336 -2787
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/og-image.png +0 -0
  22. package/dist/runtime.d.ts +26 -0
  23. package/dist/runtime.d.ts.map +1 -1
  24. package/dist/server-bridge.d.ts +2 -0
  25. package/dist/server-bridge.d.ts.map +1 -1
  26. package/dist/shims/crypto.d.ts +2 -0
  27. package/dist/shims/crypto.d.ts.map +1 -1
  28. package/dist/shims/esbuild.d.ts.map +1 -1
  29. package/dist/shims/fs.d.ts.map +1 -1
  30. package/dist/shims/http.d.ts +29 -0
  31. package/dist/shims/http.d.ts.map +1 -1
  32. package/dist/shims/path.d.ts.map +1 -1
  33. package/dist/shims/stream.d.ts.map +1 -1
  34. package/dist/shims/vfs-adapter.d.ts.map +1 -1
  35. package/dist/shims/ws.d.ts +2 -0
  36. package/dist/shims/ws.d.ts.map +1 -1
  37. package/dist/types/package-json.d.ts +1 -0
  38. package/dist/types/package-json.d.ts.map +1 -1
  39. package/dist/utils/binary-encoding.d.ts +13 -0
  40. package/dist/utils/binary-encoding.d.ts.map +1 -0
  41. package/dist/virtual-fs.d.ts.map +1 -1
  42. package/package.json +4 -4
  43. package/src/convex-app-demo-entry.ts +229 -35
  44. package/src/frameworks/code-transforms.ts +5 -1
  45. package/src/frameworks/next-config-parser.ts +140 -0
  46. package/src/frameworks/next-dev-server.ts +76 -1675
  47. package/src/frameworks/next-html-generator.ts +597 -0
  48. package/src/frameworks/next-shims.ts +1050 -0
  49. package/src/frameworks/tailwind-config-loader.ts +1 -1
  50. package/src/index.ts +2 -0
  51. package/src/runtime.ts +271 -25
  52. package/src/server-bridge.ts +61 -28
  53. package/src/shims/crypto.ts +13 -0
  54. package/src/shims/esbuild.ts +4 -1
  55. package/src/shims/fs.ts +9 -11
  56. package/src/shims/http.ts +312 -3
  57. package/src/shims/path.ts +6 -13
  58. package/src/shims/stream.ts +12 -26
  59. package/src/shims/vfs-adapter.ts +5 -2
  60. package/src/shims/ws.ts +95 -2
  61. package/src/types/package-json.ts +1 -0
  62. package/src/utils/binary-encoding.ts +43 -0
  63. package/src/virtual-fs.ts +7 -15
  64. package/dist/assets/runtime-worker-B8_LZkBX.js.map +0 -1
@@ -109,7 +109,7 @@ export function stripTypescriptSyntax(content: string): string {
109
109
 
110
110
  // Remove type annotations on variables
111
111
  // e.g., const config: Config = { ... }
112
- result = result.replace(/:\s*Config\s*=/g, ' =');
112
+ result = result.replace(/:\s*[A-Z]\w*\s*=/g, ' =');
113
113
 
114
114
  // Remove 'as const' assertions
115
115
  result = result.replace(/\s+as\s+const\s*/g, ' ');
package/src/index.ts CHANGED
@@ -76,6 +76,7 @@ export function createContainer(options?: ContainerOptions): {
76
76
  serverBridge: ServerBridge;
77
77
  execute: (code: string, filename?: string) => { exports: unknown };
78
78
  runFile: (filename: string) => { exports: unknown };
79
+ createREPL: () => { eval: (code: string) => unknown };
79
80
  on: (event: string, listener: (...args: unknown[]) => void) => void;
80
81
  } {
81
82
  const vfs = new VirtualFS();
@@ -93,6 +94,7 @@ export function createContainer(options?: ContainerOptions): {
93
94
  serverBridge,
94
95
  execute: (code: string, filename?: string) => runtime.execute(code, filename),
95
96
  runFile: (filename: string) => runtime.runFile(filename),
97
+ createREPL: () => runtime.createREPL(),
96
98
  on: (event: string, listener: (...args: unknown[]) => void) => {
97
99
  serverBridge.on(event, listener);
98
100
  },
package/src/runtime.ts CHANGED
@@ -9,6 +9,7 @@ import { VirtualFS } from './virtual-fs';
9
9
  import type { IRuntime, IExecuteResult, IRuntimeOptions } from './runtime-interface';
10
10
  import type { PackageJson } from './types/package-json';
11
11
  import { simpleHash } from './utils/hash';
12
+ import { uint8ToBase64, uint8ToHex } from './utils/binary-encoding';
12
13
  import { createFsShim, FsShim } from './shims/fs';
13
14
  import * as pathShim from './shims/path';
14
15
  import { createProcess, Process } from './shims/process';
@@ -456,16 +457,26 @@ function createRequire(
456
457
  return null;
457
458
  };
458
459
 
460
+ // Apply browser field object remapping for a resolved file within a package
461
+ const applyBrowserFieldRemap = (resolvedPath: string, pkg: PackageJson, pkgRoot: string): string | null => {
462
+ if (!pkg.browser || typeof pkg.browser !== 'object') return resolvedPath;
463
+ const browserMap = pkg.browser as Record<string, string | false>;
464
+ // Build relative path from package root (e.g., "./lib/node.js")
465
+ const relPath = './' + pathShim.relative(pkgRoot, resolvedPath);
466
+ // Also check without extension for common patterns
467
+ const relPathNoExt = relPath.replace(/\.(js|json|cjs|mjs)$/, '');
468
+ for (const key of [relPath, relPathNoExt]) {
469
+ if (key in browserMap) {
470
+ if (browserMap[key] === false) return null; // Module excluded in browser
471
+ return tryResolveFile(pathShim.join(pkgRoot, browserMap[key] as string));
472
+ }
473
+ }
474
+ return resolvedPath;
475
+ };
476
+
459
477
  // Helper to resolve from a node_modules directory
460
478
  const tryResolveFromNodeModules = (nodeModulesDir: string, moduleId: string): string | null => {
461
- const fullPath = pathShim.join(nodeModulesDir, moduleId);
462
-
463
- // Check if path exists (as file or directory)
464
- const resolved = tryResolveFile(fullPath);
465
- if (resolved) return resolved;
466
-
467
- // Check if this is a package (has package.json)
468
- // For sub-paths like "pkg/sub", we need to find the package root first
479
+ // Determine the package name and root
469
480
  const parts = moduleId.split('/');
470
481
  const pkgName = parts[0].startsWith('@') && parts.length > 1
471
482
  ? `${parts[0]}/${parts[1]}` // Scoped package
@@ -474,6 +485,7 @@ function createRequire(
474
485
  const pkgRoot = pathShim.join(nodeModulesDir, pkgName);
475
486
  const pkgPath = pathShim.join(pkgRoot, 'package.json');
476
487
 
488
+ // Check package.json first — it controls entry points (browser, main, exports)
477
489
  const pkg = getParsedPackageJson(pkgPath);
478
490
  if (pkg) {
479
491
  // Use resolve.exports to handle the exports field
@@ -493,15 +505,27 @@ function createRequire(
493
505
  }
494
506
  }
495
507
 
496
- // If this is the package root (no sub-path), use main entry
508
+ // If this is the package root (no sub-path), use browser/main entry
497
509
  if (pkgName === moduleId) {
498
- const main = pkg.main || 'index.js';
510
+ // Prefer browser field (string form) since we're running in a browser
511
+ let main: string | undefined;
512
+ if (typeof pkg.browser === 'string') {
513
+ main = pkg.browser;
514
+ }
515
+ if (!main) {
516
+ main = pkg.main || 'index.js';
517
+ }
499
518
  const mainPath = pathShim.join(pkgRoot, main);
500
519
  const resolvedMain = tryResolveFile(mainPath);
501
520
  if (resolvedMain) return resolvedMain;
502
521
  }
503
522
  }
504
523
 
524
+ // Fall back to direct file/directory resolution (for sub-paths or packages without package.json)
525
+ const fullPath = pathShim.join(nodeModulesDir, moduleId);
526
+ const resolved = tryResolveFile(fullPath);
527
+ if (resolved) return resolved;
528
+
505
529
  return null;
506
530
  };
507
531
 
@@ -548,6 +572,12 @@ function createRequire(
548
572
  // Cache before loading to handle circular dependencies
549
573
  moduleCache[resolvedPath] = module;
550
574
 
575
+ // Evict oldest entry if cache exceeds bounds
576
+ const cacheKeys = Object.keys(moduleCache);
577
+ if (cacheKeys.length > 2000) {
578
+ delete moduleCache[cacheKeys[0]];
579
+ }
580
+
551
581
  // Handle JSON files
552
582
  if (resolvedPath.endsWith('.json')) {
553
583
  const content = vfs.readFileSync(resolvedPath, 'utf8');
@@ -859,6 +889,10 @@ export class Runtime {
859
889
  // Initialize esbuild shim with VFS for file access
860
890
  esbuildShim.setVFS(vfs);
861
891
 
892
+ // Polyfill Error.captureStackTrace/prepareStackTrace for Safari/WebKit
893
+ // (V8-specific API used by Express's depd and other npm packages)
894
+ this.setupStackTracePolyfill();
895
+
862
896
  // Polyfill TextDecoder to handle base64/base64url/hex gracefully
863
897
  // (Some CLI tools incorrectly try to use TextDecoder for these)
864
898
  this.setupTextDecoderPolyfill();
@@ -907,27 +941,15 @@ export class Runtime {
907
941
  : new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
908
942
 
909
943
  if (this.encoding === 'base64') {
910
- let binary = '';
911
- for (let i = 0; i < bytes.length; i++) {
912
- binary += String.fromCharCode(bytes[i]);
913
- }
914
- return btoa(binary);
944
+ return uint8ToBase64(bytes);
915
945
  }
916
946
 
917
947
  if (this.encoding === 'base64url') {
918
- let binary = '';
919
- for (let i = 0; i < bytes.length; i++) {
920
- binary += String.fromCharCode(bytes[i]);
921
- }
922
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
948
+ return uint8ToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
923
949
  }
924
950
 
925
951
  if (this.encoding === 'hex') {
926
- let hex = '';
927
- for (let i = 0; i < bytes.length; i++) {
928
- hex += bytes[i].toString(16).padStart(2, '0');
929
- }
930
- return hex;
952
+ return uint8ToHex(bytes);
931
953
  }
932
954
 
933
955
  // Fallback: decode as utf-8
@@ -946,6 +968,146 @@ export class Runtime {
946
968
  globalThis.TextDecoder = PolyfillTextDecoder as unknown as typeof TextDecoder;
947
969
  }
948
970
 
971
+ /**
972
+ * Polyfill V8's Error.captureStackTrace and Error.prepareStackTrace for Safari/WebKit.
973
+ * Express's `depd` and other npm packages use these V8-specific APIs which don't
974
+ * exist in Safari, causing "callSite.getFileName is not a function" errors.
975
+ */
976
+ private setupStackTracePolyfill(): void {
977
+ // Only polyfill if not already available (i.e., not V8/Chrome)
978
+ if (typeof (Error as any).captureStackTrace === 'function') return;
979
+
980
+ // Set a default stackTraceLimit so Math.max(10, undefined) doesn't produce NaN
981
+ // (depd and other packages read this value)
982
+ if ((Error as any).stackTraceLimit === undefined) {
983
+ (Error as any).stackTraceLimit = 10;
984
+ }
985
+
986
+ // Parse a stack trace string into structured frames
987
+ function parseStack(stack: string): Array<{fn: string, file: string, line: number, col: number}> {
988
+ if (!stack) return [];
989
+ const frames: Array<{fn: string, file: string, line: number, col: number}> = [];
990
+ const lines = stack.split('\n');
991
+
992
+ for (const raw of lines) {
993
+ const line = raw.trim();
994
+ if (!line || line.startsWith('Error') || line.startsWith('TypeError')) continue;
995
+
996
+ let fn = '', file = '', lineNo = 0, colNo = 0;
997
+
998
+ // Safari format: "functionName@file:line:col" or "@file:line:col"
999
+ const safariMatch = line.match(/^(.*)@(.*?):(\d+):(\d+)$/);
1000
+ if (safariMatch) {
1001
+ fn = safariMatch[1] || '';
1002
+ file = safariMatch[2];
1003
+ lineNo = parseInt(safariMatch[3], 10);
1004
+ colNo = parseInt(safariMatch[4], 10);
1005
+ frames.push({ fn, file, line: lineNo, col: colNo });
1006
+ continue;
1007
+ }
1008
+
1009
+ // Chrome format: "at functionName (file:line:col)" or "at file:line:col"
1010
+ const chromeMatch = line.match(/^at\s+(?:(.+?)\s+\()?(.*?):(\d+):(\d+)\)?$/);
1011
+ if (chromeMatch) {
1012
+ fn = chromeMatch[1] || '';
1013
+ file = chromeMatch[2];
1014
+ lineNo = parseInt(chromeMatch[3], 10);
1015
+ colNo = parseInt(chromeMatch[4], 10);
1016
+ frames.push({ fn, file, line: lineNo, col: colNo });
1017
+ continue;
1018
+ }
1019
+ }
1020
+ return frames;
1021
+ }
1022
+
1023
+ // Create a mock CallSite object from a parsed frame
1024
+ function createCallSite(frame: {fn: string, file: string, line: number, col: number}) {
1025
+ return {
1026
+ getFileName: () => frame.file || null,
1027
+ getLineNumber: () => frame.line || null,
1028
+ getColumnNumber: () => frame.col || null,
1029
+ getFunctionName: () => frame.fn || null,
1030
+ getMethodName: () => frame.fn || null,
1031
+ getTypeName: () => null,
1032
+ getThis: () => undefined,
1033
+ getFunction: () => undefined,
1034
+ getEvalOrigin: () => undefined,
1035
+ isNative: () => false,
1036
+ isConstructor: () => false,
1037
+ isToplevel: () => !frame.fn,
1038
+ isEval: () => false,
1039
+ toString: () => frame.fn
1040
+ ? `${frame.fn} (${frame.file}:${frame.line}:${frame.col})`
1041
+ : `${frame.file}:${frame.line}:${frame.col}`,
1042
+ };
1043
+ }
1044
+
1045
+ // Helper: parse stack and create CallSite objects, used by both captureStackTrace and .stack getter
1046
+ function buildCallSites(stack: string, constructorOpt?: Function) {
1047
+ const frames = parseStack(stack);
1048
+ let startIdx = 0;
1049
+ if (constructorOpt && constructorOpt.name) {
1050
+ for (let i = 0; i < frames.length; i++) {
1051
+ if (frames[i].fn === constructorOpt.name) {
1052
+ startIdx = i + 1;
1053
+ break;
1054
+ }
1055
+ }
1056
+ }
1057
+ return frames.slice(startIdx).map(createCallSite);
1058
+ }
1059
+
1060
+ // Symbol to store raw stack string, used by the .stack getter
1061
+ const stackSymbol = Symbol('rawStack');
1062
+
1063
+ // Intercept .stack on Error.prototype so that packages using the V8 pattern
1064
+ // "Error.prepareStackTrace = fn; new Error().stack" also get CallSite objects.
1065
+ // In V8, reading .stack lazily triggers prepareStackTrace; Safari doesn't do this.
1066
+ Object.defineProperty(Error.prototype, 'stack', {
1067
+ get() {
1068
+ const rawStack = (this as any)[stackSymbol];
1069
+ if (rawStack !== undefined && typeof (Error as any).prepareStackTrace === 'function') {
1070
+ const callSites = buildCallSites(rawStack);
1071
+ try {
1072
+ return (Error as any).prepareStackTrace(this, callSites);
1073
+ } catch {
1074
+ return rawStack;
1075
+ }
1076
+ }
1077
+ return rawStack;
1078
+ },
1079
+ set(value: string) {
1080
+ (this as any)[stackSymbol] = value;
1081
+ },
1082
+ configurable: true,
1083
+ enumerable: false,
1084
+ });
1085
+
1086
+ // Polyfill Error.captureStackTrace
1087
+ (Error as any).captureStackTrace = function(target: any, constructorOpt?: Function) {
1088
+ // Temporarily clear prepareStackTrace to get the raw stack string
1089
+ // (otherwise our .stack getter would call prepareStackTrace recursively)
1090
+ const savedPrepare = (Error as any).prepareStackTrace;
1091
+ (Error as any).prepareStackTrace = undefined;
1092
+ const err = new Error();
1093
+ const rawStack = err.stack || '';
1094
+ (Error as any).prepareStackTrace = savedPrepare;
1095
+
1096
+ // If prepareStackTrace is set, provide structured call sites
1097
+ if (typeof savedPrepare === 'function') {
1098
+ const callSites = buildCallSites(rawStack, constructorOpt);
1099
+ try {
1100
+ target.stack = savedPrepare(target, callSites);
1101
+ } catch (e) {
1102
+ console.warn('[almostnode] Error.prepareStackTrace threw:', e);
1103
+ target.stack = rawStack;
1104
+ }
1105
+ } else {
1106
+ target.stack = rawStack;
1107
+ }
1108
+ };
1109
+ }
1110
+
949
1111
  /**
950
1112
  * Execute code as a module (synchronous - backward compatible)
951
1113
  */
@@ -1085,6 +1247,90 @@ ${code}
1085
1247
  getProcess(): Process {
1086
1248
  return this.process;
1087
1249
  }
1250
+
1251
+ /**
1252
+ * Create a REPL context that evaluates expressions and persists state.
1253
+ *
1254
+ * Returns an object with an `eval` method that:
1255
+ * - Returns the value of the last expression (unlike `execute` which returns module.exports)
1256
+ * - Persists variables between calls (`var x = 1` then `x` works)
1257
+ * - Has access to `require`, `console`, `process`, `Buffer` (same as execute)
1258
+ *
1259
+ * Security: The eval runs inside a Generator's local scope via direct eval,
1260
+ * NOT in the global scope. Only the runtime's own require/console/process are
1261
+ * exposed — the same sandbox boundary as execute(). Variables created in the
1262
+ * REPL are confined to the generator's closure and cannot leak to the page.
1263
+ *
1264
+ * Note: `const`/`let` are transformed to `var` so they persist across calls
1265
+ * (var hoists to the generator's function scope, const/let are block-scoped
1266
+ * to each eval call and would be lost).
1267
+ */
1268
+ createREPL(): { eval: (code: string) => unknown } {
1269
+ const require = createRequire(
1270
+ this.vfs,
1271
+ this.fsShim,
1272
+ this.process,
1273
+ '/',
1274
+ this.moduleCache,
1275
+ this.options,
1276
+ this.processedCodeCache
1277
+ );
1278
+ const consoleWrapper = createConsoleWrapper(this.options.onConsole);
1279
+ const process = this.process;
1280
+ const buffer = bufferShim.Buffer;
1281
+
1282
+ // Use a Generator to maintain a persistent eval scope.
1283
+ // Generator functions preserve their local scope across yields, so
1284
+ // var declarations from eval() persist between calls. Direct eval
1285
+ // runs in the generator's scope (not global), providing isolation.
1286
+ const GeneratorFunction = Object.getPrototypeOf(function* () {}).constructor;
1287
+ const replGen = new GeneratorFunction(
1288
+ 'require',
1289
+ 'console',
1290
+ 'process',
1291
+ 'Buffer',
1292
+ `var __code, __result;
1293
+ while (true) {
1294
+ __code = yield;
1295
+ try {
1296
+ __result = eval(__code);
1297
+ yield { value: __result, error: null };
1298
+ } catch (e) {
1299
+ yield { value: undefined, error: e };
1300
+ }
1301
+ }`
1302
+ )(require, consoleWrapper, process, buffer);
1303
+ replGen.next(); // prime the generator
1304
+
1305
+ return {
1306
+ eval(code: string): unknown {
1307
+ // Transform const/let to var for persistence across REPL calls.
1308
+ // var declarations in direct eval are added to the enclosing function
1309
+ // scope (the generator), so they survive across yields.
1310
+ const transformed = code.replace(/^\s*(const|let)\s+/gm, 'var ');
1311
+
1312
+ // Try as expression first (wrapping in parens), fall back to statement.
1313
+ // replGen.next(code) sends code to the generator, which evals it and
1314
+ // yields the result — so the result is in the return value of .next().
1315
+ const exprResult = replGen.next('(' + transformed + ')').value as { value: unknown; error: unknown };
1316
+ if (!exprResult.error) {
1317
+ // Advance past the wait-for-code yield so it's ready for next call
1318
+ replGen.next();
1319
+ return exprResult.value;
1320
+ }
1321
+
1322
+ // Expression parse failed — advance past wait-for-code, then try as statement
1323
+ replGen.next();
1324
+ const stmtResult = replGen.next(transformed).value as { value: unknown; error: unknown };
1325
+ if (stmtResult.error) {
1326
+ replGen.next(); // advance past wait-for-code yield
1327
+ throw stmtResult.error;
1328
+ }
1329
+ replGen.next(); // advance past wait-for-code yield
1330
+ return stmtResult.value;
1331
+ },
1332
+ };
1333
+ }
1088
1334
  }
1089
1335
 
1090
1336
  /**
@@ -12,6 +12,9 @@ import {
12
12
  } from './shims/http';
13
13
  import { EventEmitter } from './shims/events';
14
14
  import { Buffer } from './shims/stream';
15
+ import { uint8ToBase64 } from './utils/binary-encoding';
16
+
17
+ const _encoder = new TextEncoder();
15
18
 
16
19
  /**
17
20
  * Interface for virtual servers that can be registered with the bridge
@@ -50,11 +53,13 @@ export interface InitServiceWorkerOptions {
50
53
  * Server Bridge manages virtual HTTP servers and routes requests
51
54
  */
52
55
  export class ServerBridge extends EventEmitter {
56
+ static DEBUG = false;
53
57
  private servers: Map<number, VirtualServer> = new Map();
54
58
  private baseUrl: string;
55
59
  private options: BridgeOptions;
56
60
  private messageChannel: MessageChannel | null = null;
57
61
  private serviceWorkerReady: boolean = false;
62
+ private keepaliveInterval: ReturnType<typeof setInterval> | null = null;
58
63
 
59
64
  constructor(options: BridgeOptions = {}) {
60
65
  super();
@@ -164,6 +169,15 @@ export class ServerBridge extends EventEmitter {
164
169
 
165
170
  const swUrl = options?.swUrl ?? '/__sw__.js';
166
171
 
172
+ // Set up controllerchange listener BEFORE registration so we don't miss the event.
173
+ // clients.claim() in the SW's activate handler fires controllerchange, and it can
174
+ // happen before our activation wait completes.
175
+ const controllerReady = navigator.serviceWorker.controller
176
+ ? Promise.resolve()
177
+ : new Promise<void>((resolve) => {
178
+ navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
179
+ });
180
+
167
181
  // Register service worker
168
182
  const registration = await navigator.serviceWorker.register(swUrl, {
169
183
  scope: '/',
@@ -180,11 +194,13 @@ export class ServerBridge extends EventEmitter {
180
194
  if (sw.state === 'activated') {
181
195
  resolve();
182
196
  } else {
183
- sw.addEventListener('statechange', () => {
197
+ const handler = () => {
184
198
  if (sw.state === 'activated') {
199
+ sw.removeEventListener('statechange', handler);
185
200
  resolve();
186
201
  }
187
- });
202
+ };
203
+ sw.addEventListener('statechange', handler);
188
204
  }
189
205
  });
190
206
 
@@ -197,6 +213,36 @@ export class ServerBridge extends EventEmitter {
197
213
  this.messageChannel.port2,
198
214
  ]);
199
215
 
216
+ // Wait for SW to actually control this page (clients.claim() in SW activate handler)
217
+ // Without this, fetch requests bypass the SW and go directly to the server
218
+ await controllerReady;
219
+
220
+ // Re-establish communication when the SW loses its port (idle termination)
221
+ // or when the SW is replaced (new deployment). The SW sends 'sw-needs-init'
222
+ // to all clients when a request arrives but mainPort is null.
223
+ const reinit = () => {
224
+ if (navigator.serviceWorker.controller) {
225
+ this.messageChannel = new MessageChannel();
226
+ this.messageChannel.port1.onmessage = this.handleServiceWorkerMessage.bind(this);
227
+ navigator.serviceWorker.controller.postMessage(
228
+ { type: 'init', port: this.messageChannel.port2 },
229
+ [this.messageChannel.port2]
230
+ );
231
+ }
232
+ };
233
+ navigator.serviceWorker.addEventListener('controllerchange', reinit);
234
+ navigator.serviceWorker.addEventListener('message', (event) => {
235
+ if (event.data?.type === 'sw-needs-init') {
236
+ reinit();
237
+ }
238
+ });
239
+
240
+ // Keep the SW alive with periodic pings. Browsers terminate idle SWs
241
+ // after ~30s, losing the MessageChannel port and all in-memory state.
242
+ this.keepaliveInterval = setInterval(() => {
243
+ this.messageChannel?.port1.postMessage({ type: 'keepalive' });
244
+ }, 20_000);
245
+
200
246
  this.serviceWorkerReady = true;
201
247
  this.emit('sw-ready');
202
248
  }
@@ -207,14 +253,14 @@ export class ServerBridge extends EventEmitter {
207
253
  private async handleServiceWorkerMessage(event: MessageEvent): Promise<void> {
208
254
  const { type, id, data } = event.data;
209
255
 
210
- console.log('[ServerBridge] SW message:', type, id, data?.url);
256
+ ServerBridge.DEBUG && console.log('[ServerBridge] SW message:', type, id, data?.url);
211
257
 
212
258
  if (type === 'request') {
213
259
  const { port, method, url, headers, body, streaming } = data;
214
260
 
215
- console.log('[ServerBridge] Handling request:', port, method, url, 'streaming:', streaming);
261
+ ServerBridge.DEBUG && console.log('[ServerBridge] Handling request:', port, method, url, 'streaming:', streaming);
216
262
  if (streaming) {
217
- console.log('[ServerBridge] 🔴 Will use streaming handler');
263
+ ServerBridge.DEBUG && console.log('[ServerBridge] 🔴 Will use streaming handler');
218
264
  }
219
265
 
220
266
  try {
@@ -224,21 +270,16 @@ export class ServerBridge extends EventEmitter {
224
270
  } else {
225
271
  // Handle regular request
226
272
  const response = await this.handleRequest(port, method, url, headers, body);
227
- console.log('[ServerBridge] Response:', response.statusCode, 'body length:', response.body?.length);
273
+ ServerBridge.DEBUG && console.log('[ServerBridge] Response:', response.statusCode, 'body length:', response.body?.length);
228
274
 
229
275
  // Convert body to base64 string to avoid structured cloning issues with Uint8Array
230
276
  let bodyBase64 = '';
231
277
  if (response.body && response.body.length > 0) {
232
- // Convert Uint8Array to base64 string
233
278
  const bytes = response.body instanceof Uint8Array ? response.body : new Uint8Array(0);
234
- let binary = '';
235
- for (let i = 0; i < bytes.length; i++) {
236
- binary += String.fromCharCode(bytes[i]);
237
- }
238
- bodyBase64 = btoa(binary);
279
+ bodyBase64 = uint8ToBase64(bytes);
239
280
  }
240
281
 
241
- console.log('[ServerBridge] Sending response to SW, body base64 length:', bodyBase64.length);
282
+ ServerBridge.DEBUG && console.log('[ServerBridge] Sending response to SW, body base64 length:', bodyBase64.length);
242
283
 
243
284
  this.messageChannel?.port1.postMessage({
244
285
  type: 'response',
@@ -287,7 +328,7 @@ export class ServerBridge extends EventEmitter {
287
328
  // Check if the server supports streaming (has handleStreamingRequest method)
288
329
  const server = virtualServer.server as any;
289
330
  if (typeof server.handleStreamingRequest === 'function') {
290
- console.log('[ServerBridge] 🟢 Server has streaming support, calling handleStreamingRequest');
331
+ ServerBridge.DEBUG && console.log('[ServerBridge] 🟢 Server has streaming support, calling handleStreamingRequest');
291
332
  // Use streaming handler
292
333
  const bodyBuffer = body ? Buffer.from(new Uint8Array(body)) : undefined;
293
334
 
@@ -298,7 +339,7 @@ export class ServerBridge extends EventEmitter {
298
339
  bodyBuffer,
299
340
  // onStart - called with headers
300
341
  (statusCode: number, statusMessage: string, respHeaders: Record<string, string>) => {
301
- console.log('[ServerBridge] 🟢 onStart called, sending stream-start');
342
+ ServerBridge.DEBUG && console.log('[ServerBridge] 🟢 onStart called, sending stream-start');
302
343
  this.messageChannel?.port1.postMessage({
303
344
  type: 'stream-start',
304
345
  id,
@@ -307,13 +348,9 @@ export class ServerBridge extends EventEmitter {
307
348
  },
308
349
  // onChunk - called for each chunk
309
350
  (chunk: string | Uint8Array) => {
310
- const bytes = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk;
311
- let binary = '';
312
- for (let i = 0; i < bytes.length; i++) {
313
- binary += String.fromCharCode(bytes[i]);
314
- }
315
- const chunkBase64 = btoa(binary);
316
- console.log('[ServerBridge] 🟡 onChunk called, sending stream-chunk, size:', chunkBase64.length);
351
+ const bytes = typeof chunk === 'string' ? _encoder.encode(chunk) : chunk;
352
+ const chunkBase64 = uint8ToBase64(bytes);
353
+ ServerBridge.DEBUG && console.log('[ServerBridge] 🟡 onChunk called, sending stream-chunk, size:', chunkBase64.length);
317
354
  this.messageChannel?.port1.postMessage({
318
355
  type: 'stream-chunk',
319
356
  id,
@@ -322,7 +359,7 @@ export class ServerBridge extends EventEmitter {
322
359
  },
323
360
  // onEnd - called when response is complete
324
361
  () => {
325
- console.log('[ServerBridge] 🟢 onEnd called, sending stream-end');
362
+ ServerBridge.DEBUG && console.log('[ServerBridge] 🟢 onEnd called, sending stream-end');
326
363
  this.messageChannel?.port1.postMessage({ type: 'stream-end', id });
327
364
  }
328
365
  );
@@ -344,14 +381,10 @@ export class ServerBridge extends EventEmitter {
344
381
 
345
382
  if (response.body && response.body.length > 0) {
346
383
  const bytes = response.body instanceof Uint8Array ? response.body : new Uint8Array(0);
347
- let binary = '';
348
- for (let i = 0; i < bytes.length; i++) {
349
- binary += String.fromCharCode(bytes[i]);
350
- }
351
384
  this.messageChannel?.port1.postMessage({
352
385
  type: 'stream-chunk',
353
386
  id,
354
- data: { chunkBase64: btoa(binary) },
387
+ data: { chunkBase64: uint8ToBase64(bytes) },
355
388
  });
356
389
  }
357
390
 
@@ -16,6 +16,18 @@ export function randomBytes(size: number): Buffer {
16
16
  return Buffer.from(array);
17
17
  }
18
18
 
19
+ export function randomFillSync(
20
+ buffer: Uint8Array | Buffer,
21
+ offset?: number,
22
+ size?: number
23
+ ): Uint8Array | Buffer {
24
+ const start = offset || 0;
25
+ const len = size !== undefined ? size : (buffer.length - start);
26
+ const view = new Uint8Array(buffer.buffer, buffer.byteOffset + start, len);
27
+ crypto.getRandomValues(view);
28
+ return buffer;
29
+ }
30
+
19
31
  export function randomUUID(): string {
20
32
  return crypto.randomUUID();
21
33
  }
@@ -808,6 +820,7 @@ async function importKey(
808
820
 
809
821
  export default {
810
822
  randomBytes,
823
+ randomFillSync,
811
824
  randomUUID,
812
825
  randomInt,
813
826
  getRandomValues,
@@ -717,7 +717,7 @@ export async function build(options: BuildOptions): Promise<BuildResult> {
717
717
  // This preserves the original paths for esbuild's output file naming.
718
718
  let entryPoints = options.entryPoints;
719
719
  if (entryPoints && globalVFS) {
720
- const absWorkingDir = options.absWorkingDir || '/';
720
+ const absWorkingDir = options.absWorkingDir || (typeof globalThis !== 'undefined' && globalThis.process && typeof globalThis.process.cwd === 'function' ? globalThis.process.cwd() : '/');
721
721
  entryPoints = entryPoints.map(ep => {
722
722
  // Handle paths that came from previous builds with vfs: namespace prefix
723
723
  if (ep.includes('vfs:')) {
@@ -750,11 +750,14 @@ export async function build(options: BuildOptions): Promise<BuildResult> {
750
750
  }
751
751
 
752
752
  // In browser, we need write: false to get outputFiles
753
+ // Pass absWorkingDir so metafile paths are relative to the correct directory
754
+ const resolvedAbsWorkingDir = options.absWorkingDir || (typeof globalThis !== 'undefined' && globalThis.process && typeof globalThis.process.cwd === 'function' ? globalThis.process.cwd() : '/');
753
755
  const result = await esbuildInstance.build({
754
756
  ...options,
755
757
  entryPoints,
756
758
  plugins,
757
759
  write: false,
760
+ absWorkingDir: resolvedAbsWorkingDir,
758
761
  }) as BuildResult;
759
762
 
760
763
  // Strip 'vfs:' namespace prefix from output file paths