diff --git a/.github/workflows/node-compat.yml b/.github/workflows/node-compat.yml index 7c33a40..d8e56e2 100644 --- a/.github/workflows/node-compat.yml +++ b/.github/workflows/node-compat.yml @@ -16,6 +16,10 @@ on: - 'src/virtual-fs.ts' - '.github/workflows/node-compat.yml' +permissions: + contents: read + pull-requests: write + jobs: test: name: Node.js Compatibility diff --git a/.gitignore b/.gitignore index b63c879..f54113a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ npm-debug.log* # Temp folders for testing temp/ + +# Scratch files +e2e/deploy-debug.spec.ts +examples/macaly-demo.html diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc7fec..70818dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.12] - 2026-02-12 + +### Added + +- **Generic bin stubs:** `npm install` now reads each package's `bin` field and creates executable scripts in `/node_modules/.bin/`. CLI tools like `vitest`, `eslint`, `tsc`, etc. work automatically via the `node` command — no custom commands needed. +- **Streaming `container.run()` API:** Long-running commands support `onStdout`/`onStderr` callbacks and `AbortController` signal for cancellation. +- **`container.sendInput()`:** Send stdin data to running processes (emits both `data` and `keypress` events for readline compatibility). +- **Vitest demo with xterm.js:** New `examples/vitest-demo.html` showcasing real vitest execution in the browser with watch mode, syntax-highlighted terminal output, and file editing. +- **E2E tests for vitest demo:** 5 Playwright tests covering install, test execution, tab switching, failure detection, and watch mode restart. +- **`rollup` shim:** Stub module so vitest's dependency chain resolves without errors. +- **`fs.realpathSync.native`:** Added as alias for `realpathSync` (used by vitest internals). +- **`fs.createReadStream` / `fs.createWriteStream`:** Basic implementations using VirtualFS. +- **`path.delimiter` and `path.win32`:** Added missing path module properties. +- **`process.getuid()`, `process.getgid()`, `process.umask()`:** Added missing process methods used by npm packages. +- **`util.deprecate()`:** Returns the original function with a no-op deprecation warning. + +### Changed + +- **`Object.defineProperty` patch on `globalThis`:** Forces `configurable: true` for properties defined on `globalThis`, so libraries that define non-configurable globals (like vitest's `__vitest_index__`) can be re-run without errors. +- **VFS adapter executable mode:** Files in `/node_modules/.bin/` now return `0o755` mode so just-bash treats them as executable. +- **`Runtime.clearCache()` clears in-place:** Previously created a new empty object, leaving closures referencing the stale cache. Now deletes keys in-place. +- **Watch mode uses restart pattern:** Vitest caches modules internally (Vite's ModuleRunner), so file changes require a full vitest restart (abort + re-launch) rather than stdin-triggered re-runs. + +### Removed + +- **Custom vitest command:** Deleted `src/shims/vitest-command.ts` and removed vitest-specific handling from `child_process.ts`. Vitest now runs through the generic bin stub + `node` command like any other CLI tool. + ## [0.2.11] - 2026-02-09 ### Fixed diff --git a/README.md b/README.md index cb8237e..7e1e234 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ Built by the creators of [Macaly.com](https://macaly.com) — a tool that lets a - **Virtual File System** - Full in-memory filesystem with Node.js-compatible API - **Node.js API Shims** - 40+ shimmed modules (`fs`, `path`, `http`, `events`, and more) -- **npm Package Installation** - Install and run real npm packages in the browser +- **npm Package Installation** - Install and run real npm packages in the browser with automatic bin stub creation +- **Run Any CLI Tool** - npm packages with `bin` entries (vitest, eslint, tsc, etc.) work automatically - **Dev Servers** - Built-in Vite and Next.js development servers - **Hot Module Replacement** - React Refresh support for instant updates - **TypeScript Support** - First-class TypeScript/TSX transformation via esbuild-wasm @@ -127,6 +128,69 @@ container.execute(` // Output: Hello world ``` +### Running Shell Commands + +```typescript +import { createContainer } from 'almostnode'; + +const container = createContainer(); + +// Write a package.json with scripts +container.vfs.writeFileSync('/package.json', JSON.stringify({ + name: 'my-app', + scripts: { + build: 'echo Building...', + test: 'vitest run' + } +})); + +// Run shell commands directly +const result = await container.run('npm run build'); +console.log(result.stdout); // "Building..." + +await container.run('npm test'); +await container.run('echo hello && echo world'); +await container.run('ls /'); +``` + +Supported npm commands: `npm run + + diff --git a/examples/vitest-demo.html b/examples/vitest-demo.html new file mode 100644 index 0000000..0ae7135 --- /dev/null +++ b/examples/vitest-demo.html @@ -0,0 +1,97 @@ + + + + + + Vitest Testing Demo — almostnode + + + + +
+ ← demos + / + + / + Vitest Testing + vitest · npm run test +
+ +
+
+
+ Editor +
+ +
+ Initializing... +
+
+
+
utils.js
+
utils.test.js
+
package.json
+
+ +
+ +
+
+ Terminal + +
+
+
+
+ + + + diff --git a/index.html b/index.html index 57e623e..0cd0179 100644 --- a/index.html +++ b/index.html @@ -1350,6 +1350,18 @@

Express Server

express · npm install + +

npm Scripts

+

Interactive terminal to run package.json scripts with npm run, lifecycle hooks, and bash.

+ npm run · bash · terminal +
+ + +

Vitest Testing

+

Run real vitest unit tests in the browser with npm run test — using @vitest/expect assertions.

+ vitest · npm run test +
+ diff --git a/package-lock.json b/package-lock.json index f8e6d90..0e0ae43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "almostnode", - "version": "0.2.6", + "version": "0.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "almostnode", - "version": "0.2.6", + "version": "0.2.11", "license": "MIT", "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "brotli": "^1.3.3", @@ -1467,6 +1469,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", diff --git a/package.json b/package.json index 900923c..bfd368a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "almostnode", - "version": "0.2.11", + "version": "0.2.12", "description": "Node.js in your browser. Just like that.", "type": "module", "license": "MIT", @@ -85,6 +85,8 @@ "prepublishOnly": "npm run test:run && npm run build:publish" }, "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "brotli": "^1.3.3", diff --git a/src/index.ts b/src/index.ts index 88bf03b..2a61564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,23 @@ import { VirtualFS } from './virtual-fs'; import { Runtime, RuntimeOptions } from './runtime'; import { PackageManager } from './npm'; import { ServerBridge, getServerBridge } from './server-bridge'; +import { exec as cpExec, setStreamingCallbacks, clearStreamingCallbacks, sendStdin } from './shims/child_process'; + +export interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface RunOptions { + cwd?: string; + /** Callback for streaming stdout chunks as they arrive (for long-running commands like vitest watch) */ + onStdout?: (data: string) => void; + /** Callback for streaming stderr chunks as they arrive */ + onStderr?: (data: string) => void; + /** AbortSignal to cancel long-running commands */ + signal?: AbortSignal; +} export interface ContainerOptions extends RuntimeOptions { baseUrl?: string; @@ -76,6 +93,8 @@ export function createContainer(options?: ContainerOptions): { serverBridge: ServerBridge; execute: (code: string, filename?: string) => { exports: unknown }; runFile: (filename: string) => { exports: unknown }; + run: (command: string, options?: RunOptions) => Promise; + sendInput: (data: string) => void; createREPL: () => { eval: (code: string) => unknown }; on: (event: string, listener: (...args: unknown[]) => void) => void; } { @@ -94,6 +113,34 @@ export function createContainer(options?: ContainerOptions): { serverBridge, execute: (code: string, filename?: string) => runtime.execute(code, filename), runFile: (filename: string) => runtime.runFile(filename), + run: (command: string, runOptions?: RunOptions): Promise => { + // If signal is already aborted, resolve immediately + if (runOptions?.signal?.aborted) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 130 }); + } + + // Set streaming callbacks for long-running commands (e.g. vitest watch) + const hasStreaming = runOptions?.onStdout || runOptions?.onStderr || runOptions?.signal; + if (hasStreaming) { + setStreamingCallbacks({ + onStdout: runOptions?.onStdout, + onStderr: runOptions?.onStderr, + signal: runOptions?.signal, + }); + } + + return new Promise((resolve) => { + cpExec(command, { cwd: runOptions?.cwd }, (error, stdout, stderr) => { + if (hasStreaming) clearStreamingCallbacks(); + resolve({ + stdout: String(stdout), + stderr: String(stderr), + exitCode: error ? ((error as any).code ?? 1) : 0, + }); + }); + }); + }, + sendInput: (data: string) => sendStdin(data), createREPL: () => runtime.createREPL(), on: (event: string, listener: (...args: unknown[]) => void) => { serverBridge.on(event, listener); diff --git a/src/npm-scripts-demo-entry.ts b/src/npm-scripts-demo-entry.ts new file mode 100644 index 0000000..06d6f66 --- /dev/null +++ b/src/npm-scripts-demo-entry.ts @@ -0,0 +1,120 @@ +/** + * npm Scripts Demo — Entry Point + * Interactive terminal for running npm scripts and bash commands + */ + +import { createContainer } from './index'; + +// DOM elements +const pkgEditor = document.getElementById('pkgEditor') as HTMLTextAreaElement; +const terminalOutput = document.getElementById('terminalOutput') as HTMLDivElement; +const terminalInput = document.getElementById('terminalInput') as HTMLInputElement; +const statusEl = document.getElementById('status') as HTMLSpanElement; + +// State +const commandHistory: string[] = []; +let historyIndex = -1; +let isRunning = false; + +// Create the container +const container = createContainer(); + +// Default server.js for "npm start" +container.vfs.writeFileSync('/server.js', `console.log('Server starting on port 3000...'); +console.log('Ready to accept connections'); +`); + +// Write initial package.json +syncPackageJson(); + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function appendToTerminal(text: string, className: string = 'stdout') { + const span = document.createElement('span'); + span.className = className; + span.innerHTML = escapeHtml(text); + if (!text.endsWith('\n')) span.innerHTML += '\n'; + terminalOutput.appendChild(span); + terminalOutput.scrollTop = terminalOutput.scrollHeight; +} + +function syncPackageJson() { + try { + // Validate JSON before writing + JSON.parse(pkgEditor.value); + container.vfs.writeFileSync('/package.json', pkgEditor.value); + } catch { + // Invalid JSON — skip sync, will error on npm run + } +} + +async function executeCommand(command: string) { + if (!command.trim()) return; + if (isRunning) return; + + isRunning = true; + terminalInput.disabled = true; + statusEl.textContent = 'Running...'; + + // Add to history + commandHistory.push(command); + historyIndex = commandHistory.length; + + // Show the command + appendToTerminal(`$ ${command}`, 'cmd'); + + // Sync package.json from editor to VFS + syncPackageJson(); + + try { + const result = await container.run(command); + if (result.stdout) appendToTerminal(result.stdout, 'stdout'); + if (result.stderr) appendToTerminal(result.stderr, 'stderr'); + if (result.exitCode !== 0) { + appendToTerminal(`exit code: ${result.exitCode}`, 'dim'); + } + } catch (error) { + appendToTerminal(`Error: ${error}`, 'stderr'); + } + + isRunning = false; + terminalInput.disabled = false; + terminalInput.focus(); + statusEl.textContent = 'Ready'; +} + +// Terminal input handling +terminalInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const command = terminalInput.value.trim(); + terminalInput.value = ''; + executeCommand(command); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (historyIndex > 0) { + historyIndex--; + terminalInput.value = commandHistory[historyIndex]; + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (historyIndex < commandHistory.length - 1) { + historyIndex++; + terminalInput.value = commandHistory[historyIndex]; + } else { + historyIndex = commandHistory.length; + terminalInput.value = ''; + } + } +}); + +// Show welcome message +appendToTerminal('almostnode npm scripts demo', 'info'); +appendToTerminal('Type a command below, e.g. npm run build\n', 'dim'); + +// Focus the input +terminalInput.focus(); diff --git a/src/npm/index.ts b/src/npm/index.ts index d9eca73..f77d102 100644 --- a/src/npm/index.ts +++ b/src/npm/index.ts @@ -15,6 +15,20 @@ import { downloadAndExtract, extractTarball } from './tarball'; import * as path from '../shims/path'; import { initTransformer, transformPackage, isTransformerReady } from '../transform'; +/** + * Normalize a package.json bin field into a consistent Record. + * Handles both string form ("bin": "cli.js") and object form ("bin": {"cmd": "cli.js"}). + */ +function normalizeBin(pkgName: string, bin?: Record | string): Record { + if (!bin) return {}; + if (typeof bin === 'string') { + // String form uses the package name (without scope) as the command name + const cmdName = pkgName.includes('/') ? pkgName.split('/').pop()! : pkgName; + return { [cmdName]: bin }; + } + return bin; +} + export interface InstallOptions { registry?: string; save?: boolean; @@ -193,6 +207,26 @@ export class PackageManager { } } + // Create bin stubs in /node_modules/.bin/ + try { + const pkgJsonPath = path.join(pkgPath, 'package.json'); + if (this.vfs.existsSync(pkgJsonPath)) { + const pkgJson = JSON.parse(this.vfs.readFileSync(pkgJsonPath, 'utf8')); + const binEntries = normalizeBin(name, pkgJson.bin); + const binDir = path.join(nodeModulesPath, '.bin'); + for (const [cmdName, entryPath] of Object.entries(binEntries)) { + this.vfs.mkdirSync(binDir, { recursive: true }); + const targetPath = path.join(pkgPath, entryPath); + this.vfs.writeFileSync( + path.join(binDir, cmdName), + `node "${targetPath}" "$@"\n` + ); + } + } + } catch { + // Non-critical — skip if bin stub creation fails + } + added.push(name); }) ); diff --git a/src/runtime.ts b/src/runtime.ts index 7f76d5f..666a241 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -52,115 +52,134 @@ import * as domainShim from './shims/domain'; import * as diagnosticsChannelShim from './shims/diagnostics_channel'; import * as sentryShim from './shims/sentry'; import assertShim from './shims/assert'; -import { resolve as resolveExports } from 'resolve.exports'; +import { resolve as resolveExports, imports as resolveImports } from 'resolve.exports'; +import { transformEsmToCjsSimple } from './frameworks/code-transforms'; +import * as acorn from 'acorn'; + +/** + * Walk an acorn AST recursively, calling the callback for every node. + */ +function walkAst(node: any, callback: (node: any) => void): void { + if (!node || typeof node !== 'object') return; + if (typeof node.type === 'string') { + callback(node); + } + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue; + const child = node[key]; + if (child && typeof child === 'object') { + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && typeof item.type === 'string') { + walkAst(item, callback); + } + } + } else if (typeof child.type === 'string') { + walkAst(child, callback); + } + } + } +} /** * Transform dynamic imports in code: import('x') -> __dynamicImport('x') - * This allows dynamic imports to work in our eval-based runtime + * Regex-based fallback for when AST parsing fails. */ -function transformDynamicImports(code: string): string { - // Use a regex that matches import( but not things like: - // - "import(" in strings - // - // import( in comments - // This is a simple approach that works for most bundled code - // For a more robust solution, we'd need a proper parser - - // Match: import( with optional whitespace, not preceded by word char or $ - // This handles: import('x'), import ("x"), await import('x'), etc. +function transformDynamicImportsRegex(code: string): string { return code.replace(/(? = []; + + walkAst(ast, (node: any) => { + // import.meta → import_meta (variable provided by module wrapper) + if (node.type === 'MetaProperty' && node.meta?.name === 'import' && node.property?.name === 'meta') { + deepReplacements.push([node.start, node.end, 'import_meta']); + } + // import('x') → __dynamicImport('x') + if (node.type === 'ImportExpression') { + // Replace just the 'import' keyword, preserving the (...) part + deepReplacements.push([node.start, node.start + 6, '__dynamicImport']); + } + }); + + // Check for actual import/export declarations + const hasImportDecl = ast.body.some((n: any) => n.type === 'ImportDeclaration'); + const hasExportDecl = ast.body.some((n: any) => n.type?.startsWith('Export')); + + // Apply deep replacements from end to start (preserves earlier positions) let transformed = code; + deepReplacements.sort((a, b) => b[0] - a[0]); + for (const [start, end, replacement] of deepReplacements) { + transformed = transformed.slice(0, start) + replacement + transformed.slice(end); + } + + // Transform import/export declarations (re-parses the modified code) + if (hasImportDecl || hasExportDecl) { + transformed = transformEsmToCjsSimple(transformed); - // Transform import.meta.url to a file:// URL + if (hasExportDecl) { + transformed = 'Object.defineProperty(exports, "__esModule", { value: true });\n' + transformed; + } + } + + return transformed; +} + +/** + * Regex-based fallback for ESM to CJS transform (when acorn can't parse). + */ +function transformEsmToCjsRegexFallback(code: string, filename: string): string { + let transformed = code; + + // Replace import.meta (regex — may match in strings, but this is the fallback) transformed = transformed.replace(/\bimport\.meta\.url\b/g, `"file://${filename}"`); transformed = transformed.replace(/\bimport\.meta\.dirname\b/g, `"${pathShim.dirname(filename)}"`); transformed = transformed.replace(/\bimport\.meta\.filename\b/g, `"${filename}"`); transformed = transformed.replace(/\bimport\.meta\b/g, `({ url: "file://${filename}", dirname: "${pathShim.dirname(filename)}", filename: "${filename}" })`); - // Transform named imports: import { a, b } from 'x' -> const { a, b } = require('x') - transformed = transformed.replace( - /\bimport\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*;?/g, - (_, imports, module) => { - const cleanImports = imports.replace(/\s+as\s+/g, ': '); - return `const {${cleanImports}} = require("${module}");`; - } - ); + // Replace dynamic imports + transformed = transformDynamicImportsRegex(transformed); - // Transform default imports: import x from 'y' -> const x = require('y').default || require('y') - transformed = transformed.replace( - /\bimport\s+(\w+)\s+from\s+['"]([^'"]+)['"]\s*;?/g, - (_, name, module) => { - return `const ${name} = (function() { const m = require("${module}"); return m && m.__esModule ? m.default : m; })();`; - } - ); - - // Transform namespace imports: import * as x from 'y' -> const x = require('y') - transformed = transformed.replace( - /\bimport\s+\*\s+as\s+(\w+)\s+from\s+['"]([^'"]+)['"]\s*;?/g, - 'const $1 = require("$2");' - ); - - // Transform side-effect imports: import 'x' -> require('x') - transformed = transformed.replace( - /\bimport\s+['"]([^'"]+)['"]\s*;?/g, - 'require("$1");' - ); - - // Transform export default: export default x -> module.exports.default = x; module.exports = x - transformed = transformed.replace( - /\bexport\s+default\s+/g, - 'module.exports = module.exports.default = ' - ); - - // Transform named exports: export { a, b } -> module.exports.a = a; module.exports.b = b - transformed = transformed.replace( - /\bexport\s+\{([^}]+)\}\s*;?/g, - (_, exports) => { - const items = exports.split(',').map((item: string) => { - const [local, exported] = item.trim().split(/\s+as\s+/); - const exportName = exported || local; - return `module.exports.${exportName.trim()} = ${local.trim()};`; - }); - return items.join('\n'); + // Transform import/export (AST with its own regex fallback) + const hasImport = /\bimport\s+[\w{*'"]/m.test(code); + const hasExport = /\bexport\s+(?:default|const|let|var|function|class|{|\*)/m.test(code); + if (hasImport || hasExport) { + transformed = transformEsmToCjsSimple(transformed); + if (hasExport) { + transformed = 'Object.defineProperty(exports, "__esModule", { value: true });\n' + transformed; } - ); - - // Transform export const/let/var: export const x = 1 -> const x = 1; module.exports.x = x - transformed = transformed.replace( - /\bexport\s+(const|let|var)\s+(\w+)\s*=/g, - '$1 $2 = module.exports.$2 =' - ); - - // Transform export function: export function x() {} -> function x() {} module.exports.x = x - transformed = transformed.replace( - /\bexport\s+function\s+(\w+)/g, - 'function $1' - ); - - // Transform export class: export class X {} -> class X {} module.exports.X = X - transformed = transformed.replace( - /\bexport\s+class\s+(\w+)/g, - 'class $1' - ); - - // Mark as ES module for interop - if (hasExport) { - transformed = 'Object.defineProperty(exports, "__esModule", { value: true });\n' + transformed; } return transformed; @@ -206,6 +225,8 @@ export interface RuntimeOptions { cwd?: string; env?: Record; onConsole?: (method: string, args: unknown[]) => void; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; } export interface RequireFunction { @@ -239,12 +260,12 @@ function createStringDecoderModule() { */ function createTimersModule() { return { - setTimeout: globalThis.setTimeout.bind(globalThis), - setInterval: globalThis.setInterval.bind(globalThis), + setTimeout: globalThis.setTimeout, + setInterval: globalThis.setInterval, setImmediate: (fn: () => void) => setTimeout(fn, 0), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - clearInterval: globalThis.clearInterval.bind(globalThis), - clearImmediate: globalThis.clearTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout, + clearInterval: globalThis.clearInterval, + clearImmediate: globalThis.clearTimeout, }; } @@ -332,10 +353,67 @@ const builtinModules: Record = { diagnostics_channel: diagnosticsChannelShim, // prettier uses createRequire which doesn't work in our runtime, so we shim it prettier: prettierShim, - // Some packages explicitly require 'console' - console: console, + // Some packages explicitly require 'console' (with Console constructor) + console: { + ...console, + Console: class Console { + private _stdout: { write: (s: string) => void } | null; + private _stderr: { write: (s: string) => void } | null; + constructor(options?: unknown) { + // Node's Console accepts (stdout, stderr) or { stdout, stderr } + const opts = options as Record | undefined; + if (opts && typeof opts === 'object' && 'write' in opts) { + // new Console(stdout, stderr) — first arg is stdout stream + this._stdout = opts as unknown as { write: (s: string) => void }; + this._stderr = (arguments[1] as { write: (s: string) => void }) || this._stdout; + } else if (opts && typeof opts === 'object' && 'stdout' in opts) { + // new Console({ stdout, stderr }) + this._stdout = opts.stdout as { write: (s: string) => void } || null; + this._stderr = (opts.stderr as { write: (s: string) => void }) || this._stdout; + } else { + this._stdout = null; + this._stderr = null; + } + } + private _write(stream: 'out' | 'err', args: unknown[]) { + const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n'; + const target = stream === 'err' ? this._stderr : this._stdout; + if (target) target.write(msg); + else if (stream === 'err') console.error(...args); + else console.log(...args); + } + log(...args: unknown[]) { this._write('out', args); } + error(...args: unknown[]) { this._write('err', args); } + warn(...args: unknown[]) { this._write('err', args); } + info(...args: unknown[]) { this._write('out', args); } + debug(...args: unknown[]) { this._write('out', args); } + trace(...args: unknown[]) { this._write('err', args); } + dir(obj: unknown) { this._write('out', [obj]); } + time(_label?: string) {} + timeEnd(_label?: string) {} + timeLog(_label?: string) {} + assert(value: unknown, ...args: unknown[]) { if (!value) this._write('err', ['Assertion failed:', ...args]); } + clear() {} + count(_label?: string) {} + countReset(_label?: string) {} + group(..._args: unknown[]) {} + groupCollapsed(..._args: unknown[]) {} + groupEnd() {} + table(data: unknown) { this._write('out', [data]); } + }, + }, // util/types is accessed as a subpath 'util/types': utilShim.types, + // path subpaths (our path shim is already POSIX-based) + 'path/posix': pathShim, + 'path/win32': pathShim.win32, + // timers subpaths + 'timers/promises': { + setTimeout: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)), + setInterval: globalThis.setInterval, + setImmediate: (value?: unknown) => new Promise(resolve => setTimeout(() => resolve(value), 0)), + scheduler: { wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) }, + }, // Sentry SDK (no-op since error tracking isn't useful in browser runtime) '@sentry/node': sentryShim, '@sentry/core': sentryShim, @@ -385,6 +463,28 @@ function createRequire( return id; } + // Package imports: #something resolves via nearest package.json "imports" field + if (id.startsWith('#')) { + let searchDir = fromDir; + while (searchDir !== '/') { + const pkgPath = pathShim.join(searchDir, 'package.json'); + const pkg = getParsedPackageJson(pkgPath); + if (pkg?.imports) { + try { + const resolved = resolveImports(pkg, id, { require: true }); + if (resolved && resolved.length > 0) { + const fullPath = pathShim.join(searchDir, resolved[0]); + if (vfs.existsSync(fullPath)) return fullPath; + } + } catch { + // resolveImports throws if no match found + } + } + searchDir = pathShim.dirname(searchDir); + } + throw new Error(`Cannot find module '${id}'`); + } + // Check resolution cache const cacheKey = `${fromDir}|${id}`; const cached = resolutionCache.get(cacheKey); @@ -490,28 +590,47 @@ function createRequire( if (pkg) { // Use resolve.exports to handle the exports field if (pkg.exports) { - try { - // resolveExports expects the full module specifier (e.g., 'convex/server') - // and returns the resolved path(s) relative to package root - const resolved = resolveExports(pkg, moduleId, { require: true }); - if (resolved && resolved.length > 0) { - const exportPath = resolved[0]; - const fullExportPath = pathShim.join(pkgRoot, exportPath); - const resolvedFile = tryResolveFile(fullExportPath); - if (resolvedFile) return resolvedFile; + // Try require first, then import. Some packages have broken ESM builds (convex). + // If the CJS entry throws "cannot be imported with require()", the loadModule + // fallback will retry with the import condition. + for (const conditions of [{ require: true }, { import: true }] as const) { + try { + const resolved = resolveExports(pkg, moduleId, conditions); + if (resolved && resolved.length > 0) { + const exportPath = resolved[0]; + const fullExportPath = pathShim.join(pkgRoot, exportPath); + const resolvedFile = tryResolveFile(fullExportPath); + if (resolvedFile) { + // Skip CJS stub files that just throw "cannot be imported with require()" + // These are common in ESM-only packages (vitest, etc.) + if (resolvedFile.endsWith('.cjs')) { + try { + const content = vfs.readFileSync(resolvedFile, 'utf8') as string; + if (content.trimStart().startsWith('throw ')) { + continue; // Skip this entry, try next condition + } + } catch { /* proceed if we can't read */ } + } + return resolvedFile; + } + } + } catch { + // resolveExports throws if no match found, try next } - } catch { - // resolveExports throws if no match found, fall through to main } } - // If this is the package root (no sub-path), use browser/main entry + // If this is the package root (no sub-path), use browser/main/module entry if (pkgName === moduleId) { // Prefer browser field (string form) since we're running in a browser let main: string | undefined; if (typeof pkg.browser === 'string') { main = pkg.browser; } + if (!main && pkg.module) { + // module field is used by ESM-only packages (e.g., estree-walker) + main = pkg.module as string; + } if (!main) { main = pkg.main || 'index.js'; } @@ -598,25 +717,17 @@ function createRequire( if (!code) { code = rawCode; - // Transform ESM to CJS if needed (for .mjs files or ESM that wasn't pre-transformed) - // This handles files that weren't transformed during npm install - // BUT skip .cjs files and already-bundled CJS code - const isCjsFile = resolvedPath.endsWith('.cjs'); - const isAlreadyBundledCjs = code.startsWith('"use strict";\nvar __') || - code.startsWith("'use strict';\nvar __"); - - const hasEsmImport = /\bimport\s+[\w{*'"]/m.test(code); - const hasEsmExport = /\bexport\s+(?:default|const|let|var|function|class|{|\*)/m.test(code); - - if (!isCjsFile && !isAlreadyBundledCjs) { - if (resolvedPath.endsWith('.mjs') || resolvedPath.includes('/esm/') || hasEsmImport || hasEsmExport) { - code = transformEsmToCjs(code, resolvedPath); - } + // Strip shebang line if present (e.g. #!/usr/bin/env node) + if (code.startsWith('#!')) { + code = code.slice(code.indexOf('\n') + 1); } - // Transform dynamic imports: import('x') -> __dynamicImport('x') - // This allows dynamic imports to work in our eval-based runtime - code = transformDynamicImports(code); + // Transform ESM to CJS if needed (for .mjs files or ESM that wasn't pre-transformed) + // transformEsmToCjs uses AST to handle import/export, import.meta, and dynamic imports + // It also handles already-CJS files safely (AST finds no ESM nodes → no-op) + if (!resolvedPath.endsWith('.cjs')) { + code = transformEsmToCjs(code, resolvedPath); + } // Cache the processed code processedCodeCache?.set(codeCacheKey, code); @@ -667,9 +778,8 @@ ${code} try { fn = eval(wrappedCode); } catch (evalError) { - console.error('[runtime] Eval failed for:', resolvedPath); - console.error('[runtime] First 500 chars of code:', code.substring(0, 500)); - throw evalError; + const msg = evalError instanceof Error ? evalError.message : String(evalError); + throw new SyntaxError(`${msg} (in ${resolvedPath})`); } // Create dynamic import function for this module context const dynamicImport = createDynamicImport(moduleRequire); @@ -690,6 +800,10 @@ ${code} } catch (error) { // Remove from cache on error delete moduleCache[resolvedPath]; + // Enhance runtime errors with the module path for easier debugging + if (error instanceof Error && !error.message.includes('(in /')) { + error.message = `${error.message} (in ${resolvedPath})`; + } throw error; } @@ -874,6 +988,8 @@ export class Runtime { this.process = createProcess({ cwd: options.cwd || '/', env: options.env, + onStdout: options.onStdout, + onStderr: options.onStderr, }); // Create fs shim with cwd getter for relative path resolution this.fsShim = createFsShim(vfs, () => this.process.cwd()); @@ -889,6 +1005,30 @@ export class Runtime { // Initialize esbuild shim with VFS for file access esbuildShim.setVFS(vfs); + // Polyfill setImmediate/clearImmediate (Node.js globals not available in browsers) + if (typeof globalThis.setImmediate === 'undefined') { + (globalThis as any).setImmediate = (fn: (...args: unknown[]) => void, ...args: unknown[]) => setTimeout(fn, 0, ...args); + (globalThis as any).clearImmediate = (id: number) => clearTimeout(id); + } + + // Patch setTimeout/setInterval to return Node.js-compatible Timeout objects + // Node.js timers return objects with .ref()/.unref()/.refresh()/.hasRef() methods + // Browser timers return plain numbers. Many npm packages (vitest, etc.) call .unref() + if (!(globalThis.setTimeout as any).__patched) { + const origSetTimeout = globalThis.setTimeout.bind(globalThis); + const origSetInterval = globalThis.setInterval.bind(globalThis); + const origClearTimeout = globalThis.clearTimeout.bind(globalThis); + const origClearInterval = globalThis.clearInterval.bind(globalThis); + const wrapTimer = (id: ReturnType) => { + const t = { _id: id, ref() { return t; }, unref() { return t; }, hasRef() { return true; }, refresh() { return t; }, [Symbol.toPrimitive]() { return id; } }; + return t; + }; + (globalThis as any).setTimeout = Object.assign((...args: Parameters) => wrapTimer(origSetTimeout(...args)), { __patched: true }); + (globalThis as any).setInterval = Object.assign((...args: Parameters) => wrapTimer(origSetInterval(...args)), { __patched: true }); + (globalThis as any).clearTimeout = (t: any) => origClearTimeout(t?._id ?? t); + (globalThis as any).clearInterval = (t: any) => origClearInterval(t?._id ?? t); + } + // Polyfill Error.captureStackTrace/prepareStackTrace for Safari/WebKit // (V8-specific API used by Express's depd and other npm packages) this.setupStackTracePolyfill(); @@ -1147,11 +1287,22 @@ export class Runtime { // Create console wrapper const consoleWrapper = createConsoleWrapper(this.options.onConsole); + // Transform code the same way loadModule does + // Strip shebang line if present (e.g. #!/usr/bin/env node) + if (code.startsWith('#!')) { + code = code.slice(code.indexOf('\n') + 1); + } + + // Transform ESM to CJS if needed (AST-based, handles import.meta and dynamic imports too) + if (!filename.endsWith('.cjs')) { + code = transformEsmToCjs(code, filename); + } + // Execute code // Use the same wrapper pattern as loadModule for consistency try { const importMetaUrl = 'file://' + filename; - const wrappedCode = `(function($exports, $require, $module, $filename, $dirname, $process, $console, $importMeta) { + const wrappedCode = `(function($exports, $require, $module, $filename, $dirname, $process, $console, $importMeta, $dynamicImport) { var exports = $exports; var require = $require; var module = $module; @@ -1160,6 +1311,7 @@ var __dirname = $dirname; var process = $process; var console = $console; var import_meta = $importMeta; +var __dynamicImport = $dynamicImport; // Set up global.process and globalThis.process for code that accesses them directly var global = globalThis; globalThis.process = $process; @@ -1170,6 +1322,9 @@ ${code} }).call(this); })`; + // Create dynamic import function for this module context + const dynamicImport = createDynamicImport(require); + const fn = eval(wrappedCode); fn( module.exports, @@ -1179,7 +1334,8 @@ ${code} dirname, this.process, consoleWrapper, - { url: importMetaUrl, dirname, filename } + { url: importMetaUrl, dirname, filename }, + dynamicImport ); module.loaded = true; @@ -1231,7 +1387,10 @@ ${code} * Clear the module cache */ clearCache(): void { - this.moduleCache = {}; + // Clear contents in-place so closures that captured the reference still see the cleared cache + for (const key of Object.keys(this.moduleCache)) { + delete this.moduleCache[key]; + } } /** diff --git a/src/shims/child_process.ts b/src/shims/child_process.ts index 3cbf112..c51eec1 100644 --- a/src/shims/child_process.ts +++ b/src/shims/child_process.ts @@ -22,17 +22,88 @@ if (typeof globalThis.process === 'undefined') { } import { Bash, defineCommand } from 'just-bash'; +import type { CommandContext, ExecResult as JustBashExecResult } from 'just-bash'; import { EventEmitter } from './events'; import { Readable, Writable, Buffer } from './stream'; import type { VirtualFS } from '../virtual-fs'; import { VirtualFSAdapter } from './vfs-adapter'; import { Runtime } from '../runtime'; +import type { PackageJson } from '../types/package-json'; // Singleton bash instance - uses VFS adapter for two-way file sync let bashInstance: Bash | null = null; let vfsAdapter: VirtualFSAdapter | null = null; let currentVfs: VirtualFS | null = null; +// Track active forked child processes so the node command can detect when children exit. +// When the last child exits, the node command uses a shorter idle timeout. +let _activeForkedChildren = 0; +let _onForkedChildExit: (() => void) | null = null; + +// Patch Object.defineProperty globally to force configurable: true on globalThis properties. +// In real Node.js, each process has its own globalThis. In our browser environment, +// all forks share globalThis, so libraries like vitest that define non-configurable +// properties (e.g. __vitest_index__) need them to be configurable for re-runs. +const _realDefineProperty = Object.defineProperty; +Object.defineProperty = function(target: object, key: PropertyKey, descriptor: PropertyDescriptor): object { + if (target === globalThis && descriptor && !descriptor.configurable) { + descriptor = { ...descriptor, configurable: true }; + } + return _realDefineProperty.call(Object, target, key, descriptor) as object; +} as typeof Object.defineProperty; + +// Module-level streaming callbacks for long-running commands (e.g. vitest watch) +// Set by container.run() before calling exec, cleared after +let _streamStdout: ((data: string) => void) | null = null; +let _streamStderr: ((data: string) => void) | null = null; +let _abortSignal: AbortSignal | null = null; + +/** + * Set streaming callbacks for the next command execution. + * Used by container.run() to enable streaming output from custom commands. + */ +export function setStreamingCallbacks(opts: { + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + signal?: AbortSignal; +}): void { + _streamStdout = opts.onStdout || null; + _streamStderr = opts.onStderr || null; + _abortSignal = opts.signal || null; +} + +/** + * Clear streaming callbacks after command execution. + */ +export function clearStreamingCallbacks(): void { + _streamStdout = null; + _streamStderr = null; + _abortSignal = null; +} + +// Reference to the currently running node command's process stdin. +// Used to send stdin input to long-running commands (e.g. vitest watch mode). +let _activeProcessStdin: { emit: (event: string, ...args: unknown[]) => void } | null = null; + +/** + * Send data to the stdin of the currently running node process. + * Emits both 'data' and 'keypress' events (vitest uses readline keypress events). + */ +export function sendStdin(data: string): void { + if (_activeProcessStdin) { + _activeProcessStdin.emit('data', data); + for (const ch of data) { + _activeProcessStdin.emit('keypress', ch, { + sequence: ch, + name: ch, + ctrl: false, + meta: false, + shift: false, + }); + } + } +} + /** * Initialize the child_process shim with a VirtualFS instance * Creates a single Bash instance with VirtualFSAdapter for efficient file access @@ -57,46 +128,193 @@ export function initChildProcess(vfs: VirtualFS): void { ? scriptPath : `${ctx.cwd}/${scriptPath}`.replace(/\/+/g, '/'); + if (!currentVfs.existsSync(resolvedPath)) { + return { stdout: '', stderr: `Error: Cannot find module '${resolvedPath}'\n`, exitCode: 1 }; + } + + let stdout = ''; + let stderr = ''; + + // Track whether process.exit() was called + let exitCalled = false; + let exitCode = 0; + let syncExecution = true; + let exitResolve: ((code: number) => void) | null = null; + const exitPromise = new Promise((resolve) => { exitResolve = resolve; }); + + // Helper to append to stdout, also streaming if configured + const appendStdout = (data: string) => { + stdout += data; + if (_streamStdout) _streamStdout(data); + }; + const appendStderr = (data: string) => { + stderr += data; + if (_streamStderr) _streamStderr(data); + }; + + // Create a runtime with output capture for both console.log AND process.stdout.write + const runtime = new Runtime(currentVfs, { + cwd: ctx.cwd, + env: ctx.env, + onConsole: (method, consoleArgs) => { + const msg = consoleArgs.map(a => String(a)).join(' ') + '\n'; + if (method === 'error') { + appendStderr(msg); + } else { + appendStdout(msg); + } + }, + onStdout: (data: string) => { + appendStdout(data); + }, + onStderr: (data: string) => { + appendStderr(data); + }, + }); + + // Override process.exit to resolve the completion promise + const proc = runtime.getProcess(); + proc.exit = ((code = 0) => { + if (!exitCalled) { + exitCalled = true; + exitCode = code; + proc.emit('exit', code); + exitResolve!(code); + } + // In sync context, throw to stop execution (like real process.exit) + // In async context, return silently to avoid unhandled rejections + if (syncExecution) { + throw new Error(`Process exited with code ${code}`); + } + }) as (code?: number) => never; + + // Set up process.argv for the script + proc.argv = ['node', resolvedPath, ...args.slice(1)]; + + // For long-running commands (watch mode), report as TTY so tools like + // vitest set up interactive features (file watching, stdin commands). + // Also track stdin so external code can send input via sendStdin(). + if (_abortSignal) { + proc.stdout.isTTY = true; + proc.stderr.isTTY = true; + proc.stdin.isTTY = true; + proc.stdin.setRawMode = () => proc.stdin; + _activeProcessStdin = proc.stdin; + } + try { - // Check if file exists - if (!currentVfs.existsSync(resolvedPath)) { - return { stdout: '', stderr: `Error: Cannot find module '${resolvedPath}'\n`, exitCode: 1 }; + // Run the script (synchronous part) + runtime.runFile(resolvedPath); + } catch (error) { + // process.exit() throws to stop sync execution — this is expected + if (error instanceof Error && error.message.startsWith('Process exited with code')) { + return { stdout, stderr, exitCode }; } + // Real error + const errorMsg = error instanceof Error + ? `${error.message}\n${error.stack || ''}` + : String(error); + return { stdout, stderr: stderr + `Error: ${errorMsg}\n`, exitCode: 1 }; + } finally { + // After runFile returns, switch to async mode (no more throwing from process.exit) + syncExecution = false; + } - let stdout = ''; - let stderr = ''; + // If process.exit was called synchronously (but didn't throw for some reason), return + if (exitCalled) { + return { stdout, stderr, exitCode }; + } - // Create a runtime with the current environment - const runtime = new Runtime(currentVfs, { - cwd: ctx.cwd, - env: ctx.env, - onConsole: (method, consoleArgs) => { - const msg = consoleArgs.map(a => String(a)).join(' ') + '\n'; - if (method === 'error') { - stderr += msg; - } else { - stdout += msg; - } - }, - }); + // Script returned without calling process.exit(). + // Heuristic: if we already captured output, the script likely finished synchronously + // (e.g. a simple "console.log('hello')" script). Return immediately. + if (stdout.length > 0 || stderr.length > 0) { + // Brief pause for any trailing microtasks + await new Promise(r => setTimeout(r, 0)); + return { stdout, stderr, exitCode: exitCalled ? exitCode : 0 }; + } - // Set up process.argv for the script - const processShim = (globalThis as any).process || {}; - const originalArgv = processShim.argv; - processShim.argv = ['node', resolvedPath, ...args.slice(1)]; - (globalThis as any).process = processShim; + // No output yet — script likely has async work (e.g. vitest test runner). + // Wait for process.exit() or until output stabilizes. + // Also catch unhandled rejections from async code to surface errors. - try { - // Run the script - runtime.runFile(resolvedPath); - return { stdout, stderr, exitCode: 0 }; - } finally { - // Restore original argv - processShim.argv = originalArgv; + // Catch unhandled rejections from the script's async code + const rejectionHandler = (event: PromiseRejectionEvent) => { + const reason = event.reason; + // Ignore process.exit throws (they're expected) + if (reason instanceof Error && reason.message.startsWith('Process exited with code')) { + event.preventDefault(); + return; } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return { stdout: '', stderr: `Error: ${errorMsg}\n`, exitCode: 1 }; + const msg = reason instanceof Error + ? `Unhandled rejection: ${reason.message}\n${reason.stack || ''}\n` + : `Unhandled rejection: ${String(reason)}\n`; + appendStderr(msg); + }; + globalThis.addEventListener('unhandledrejection', rejectionHandler); + + // Listen for forked child exits to shorten the idle timeout. + // Many CLI tools (vitest, jest, etc.) fork workers and exit shortly after + // all children complete. We use a shorter timeout once children are done. + let childrenExited = false; + const prevChildExitHandler = _onForkedChildExit; + _onForkedChildExit = () => { + if (_activeForkedChildren <= 0) childrenExited = true; + prevChildExitHandler?.(); + }; + + try { + // Poll until process.exit is called, output stabilizes, or we time out + const MAX_TOTAL_MS = 60000; + const IDLE_TIMEOUT_MS = 500; + const POST_CHILD_EXIT_IDLE_MS = 100; // short timeout after children finish + const CHECK_MS = 50; + const startTime = Date.now(); + let lastOutputLen = stdout.length + stderr.length; + let idleMs = 0; + + // When an abort signal is present (e.g. watch mode), don't apply idle timeout — + // only exit when aborted or process.exit is called. + const isLongRunning = !!_abortSignal; + + while (!exitCalled) { + // Check abort signal for long-running commands (watch mode) + if (_abortSignal?.aborted) break; + + // Check if exitPromise resolved (non-blocking) + const raceResult = await Promise.race([ + exitPromise.then(() => 'exit' as const), + new Promise<'tick'>(r => setTimeout(() => r('tick'), CHECK_MS)), + ]); + + if (raceResult === 'exit' || exitCalled) break; + if (_abortSignal?.aborted) break; + + const currentLen = stdout.length + stderr.length; + if (currentLen > lastOutputLen) { + // New output — reset idle timer + lastOutputLen = currentLen; + idleMs = 0; + } else { + idleMs += CHECK_MS; + } + + // Use shorter idle timeout once all forked children have exited + // Skip idle timeout for long-running commands (watch mode) + if (!isLongRunning) { + const effectiveIdle = childrenExited ? POST_CHILD_EXIT_IDLE_MS : IDLE_TIMEOUT_MS; + if (lastOutputLen > 0 && idleMs >= effectiveIdle) break; + } + + // Hard timeout (skip for long-running commands) + if (!isLongRunning && Date.now() - startTime >= MAX_TOTAL_MS) break; + } + + return { stdout, stderr, exitCode: exitCalled ? exitCode : 0 }; + } finally { + _activeProcessStdin = null; + _onForkedChildExit = prevChildExitHandler; + globalThis.removeEventListener('unhandledrejection', rejectionHandler); } }); @@ -154,6 +372,48 @@ export function initChildProcess(vfs: VirtualFS): void { } }); + // Create custom 'npm' command that runs scripts from package.json + const npmCommand = defineCommand('npm', async (args, ctx) => { + if (!currentVfs) { + return { stdout: '', stderr: 'VFS not initialized\n', exitCode: 1 }; + } + + const subcommand = args[0]; + + if (!subcommand || subcommand === 'help' || subcommand === '--help') { + return { + stdout: 'Usage: npm \n\nCommands:\n run