diff --git a/.claude/npm-dev-brief.md b/.claude/npm-dev-brief.md new file mode 100644 index 0000000..6f898b8 --- /dev/null +++ b/.claude/npm-dev-brief.md @@ -0,0 +1,173 @@ +# Goal: `npm run dev` works for Node dev servers on iOS + +## MET — SvelteKit, on the simulator, curled from the Mac + + VITE v7.3.6 ready in 19343 ms started once, no restart loop + curl http://localhost:5173/ HTTP 200, 33105 bytes, the rendered app + node_modules/.vite/deps 20 dependencies pre-bundled + +The watcher was exercised rather than inferred, because silence is also what a +dead watcher produces. Appending a line to `src/routes/+page.svelte` in the +running workspace: ~3s later the curl returned 33133 bytes with the marker in +it, the terminal printed exactly one line — +`[vite] (ssr) page reload src/routes/+page.svelte` — and reverting put it back. +One reload, naming the file that changed. That is the original bug's inverse. + +Not a SvelteKit special case, which this brief asks for explicitly. Green on the +same engine: `reactdev` (vite serves .tsx with types erased and JSX compiled), +`hmr` (an edit pushed down a real WebSocket), `firstrun` (`npm create vite` +scaffolds, installs and serves, end to end), `vite`, `npmrun`, `tscwatch`. + +Whole suite at the shipping code: **141 assertions passed, 0 failed**, plus the +five harnesses `verify.sh` declares investigations and does not count. + +### The fixes, in the order they were found + +Each verified on the simulator, each with its evidence in its commit message. + +1. **The restart storm** — the project sat at `/`; see the root cause below. A + program now starts at a named root, so the path it watches has a real parent + and a real basename. +2. **`import` as a method name** — `obj.import(...)` was read as an import. +3. **ESM named imports were a snapshot, not a live binding**, so a module that + exports `let x` and assigns it later stayed frozen at `undefined`. +4. **`Request`/`Response` bodies from a Uint8Array** went through `String(body)` + and arrived as comma-separated character codes, so every SSR response was + digits (`6328744`). +5. **Svelte's compiler ran half in dev mode** (`e45bb6e`): the live-binding + rewrite skipped `dev` because `svelte.dev` inside a thrown error's URL read as + a parameter, and it mis-rewrote shorthand properties written one per line. +6. **lightningcss on wasm, and `file:/x` is the same URL as `file:///x`** + (`c1e5b16`). +7. **`error.stack` had no `Name: message` header** (`7dfec54`), so every tool + logging a stack printed frames and no reason. +8. **`listen()` with no host bound IPv4-only `0.0.0.0`** (`213226a`) where node + binds dual-stack `::`, so a program could not reach its own server by name. +9. **The `/project` alias shadowed a real directory of that name** (`dbd5d2d`). + +## Still open + +- **Tailwind 4 cannot run here; tailwind 3 can, and does.** Version 3 has no + native half — scanner and compiler are both JavaScript — and + `verify/tailwind` pins it: content scanning, plain utilities, a `hover:` + variant, a `md:` breakpoint reaching `@media`, an arbitrary `w-[37px]`, and an + unused class correctly absent. A user who wants tailwind on a phone today + wants 3. + + Version 4 is a platform wall, not our bug. `@tailwindcss/oxide`'s wasi build + declares SHARED memory and JSC answers `WebAssembly.Module doesn't parse at + byte 398: shared memory is not enabled`. Measured, so nobody re-derives it: + `new WebAssembly.Memory({shared: true})` succeeds, but a module whose memory + section carries the shared flag does not parse. The gate is + `JSC::Options::useSharedArrayBuffer`, RESTRICTED in Apple's build — + `JSC_useSharedArrayBuffer=true` is accepted as a name (a bogus name beside it + is rejected, so the parse happened) and `JSC_dumpOptions=1` still prints + `useSharedArrayBuffer=false (default: false)`. This bounds every napi-rs wasi + binding built with threads. `tailwindcss()` is therefore kept out of + `local__test-2`'s `vite.config.ts`, and the project runs. + +- **An error the ENGINE throws still has no header when read through `.stack`.** + JSC materialises `stack` during construction, on its own errors as much as + ours, so nothing catches the moment an engine error is born. Errors JavaScript + constructs are covered (`verify/stackshape`); console and util.inspect rebuild + the header for the rest. + + There IS one more approach, designed and deliberately not attempted, so nobody + has to rediscover it or start it blind. `stack` is writable, and the engine + already rewrites every ESM module — so `catch (e) {` could become + `catch (e) { __mouseFixStack(e);`, giving an engine-thrown error its header at + the moment code catches it. That is exactly where SvelteKit caught the + TypeError this loop chased. Why it was not done: it is a fifth rewrite in + `transpileESM`, and two of the four already there shipped bugs found only by a + real bundle failing (`svelte.dev` inside a string, shorthand across newlines). + It would cover only transpiled ESM, not the CJS vite bundles its own chokidar + into. And it needs its own gate for `catch` with no binding, a rethrown `e`, + and a `catch` inside a template or a comment. Worth doing on a fresh context + with room to gate it properly; not worth starting at the end of one. + +## The target, concretely + +In the simulator workspace `local__test-2` (a SvelteKit project created by +`npx sv create`), `npm run dev` must start **once** — no restart loop; not print +`Failed to run dependency scan`; print its URL; and answer +`curl http://localhost:/` **from the Mac** with the app's HTML. + +The fix belongs in the ENGINE (`swift/`). Do not "fix" it by editing the user's +project or `node_modules`. Temporary instrumentation of `node_modules` is +allowed for diagnosis and MUST be reverted before the iteration ends. It must be +general — React/Vite and any other node dev server. + +## Root cause of the storm — measured, do not re-derive + +A project rooted at `/` breaks file watching, spectacularly. chokidar records a +directory under its parent: `_getWatchedDir(dirname(dir)).add(basename(dir))`. +For `dir === "/"`, POSIX gives dirname `/` and basename `""` — node agrees, our +path module is right — so it files an EMPTY-NAMED child inside the root's own +record. The next `_handleRead('/')` holds the real entries and never contains +`""`, so the diff removes it, `_remove('/', '')` resolves back to the root, and +the whole tracked tree is torn down: an `unlink` for every file in the project, +`vite.config.ts` included. Vite restarts, builds a fresh watcher, and does it +again about three seconds later. Captured in one run as: + + REMOVE dir=/ item= currentSize=14 prevN=3 + +The dependency-scan failure was downstream of that, never a second bug: the scan +is asynchronous, and vite reports `ERR_CLOSED_SERVER` when the plugin container +closes under it — `[plugin: vite:dep-scan] The server is being restarted or +closed. Request is outdated`. Fix the restarts and the scan completes. + +No other platform hits this, because no real project lives at the filesystem +root. Gated now by `verify/watchroot`, which drives the real shell because the +fix lives in the launch path; `verify/chokidar` watches a SUBDIRECTORY, which is +why it was green throughout. + +**A named root was the only option, and the alternative is proven impossible.** +Making `fs.realpathSync('/')` answer a named alias cannot work: chokidar uses +`dir` — the path as passed in — for dirname/basename bookkeeping, and consults +realpath only for its symlink check. Do not attempt it. + +## Ruled out by device probes — do not repeat these + +- esbuild core works, and its plugin callbacks over the pipe work. +- `fs.appendFileSync` creates a missing file correctly. +- A raw `fs.watch` on the config plus `fs.watch('.')` reported **zero** events + while the config was read and while a temp file was written and deleted under + `node_modules/.vite-temp`. "Reading the config fires an event" is FALSE. +- The event was never a leaked initial-scan `add`; it was `unlink`, and the + cause is the empty basename above. +- Directory reading was never at fault: `readdir` and `readdirp` both returned + all 18 entries with working `isFile()`. + +## Measuring on the device — read before trusting a number + +**The dev server keeps running.** After `npm run dev` the terminal is owned by +the vite PROGRAM: anything typed afterwards goes to vite as KEYSTROKES, not to +msh. Several measurements were contaminated this way, showing output from a +previous run because the new command never started. Confirm the prompt reads +`~ $` (kill with canc TWICE) and that the command echoed before trusting a +result. + +**Re-run a red gate before believing it.** Three conclusions in this loop were +wrong the first time and right after a re-run: "the dependency scan is a second +bug"; "devserver is a regression I caused" (a flake — it slept a fixed six +seconds for vite to boot, and now polls); and "event-sequences is pre-existing" +(asserted on a machine where five hung `verify_bin` processes were still +running). A flake and a regression read identically. + +## Verification rule + +Verify on the booted simulator (iPhone 16 Pro, +`58A6B442-292A-4610-9DE8-500E7E8EBC74`) EVERY iteration: build, install, launch, +drive the terminal, screenshot, and curl the port from the Mac. A green build is +not evidence. + +Everything in AGENTS.md applies: gate what you fix, no leftover diagnostics, +docs move with behavior, commit at each boundary with the evidence in the +message. + +## Stop condition — met + +On the simulator, `npm run dev` in `local__test-2` starts once, prints its URL, +does not print `Failed to run dependency scan`, and a curl from the Mac returns +the app's HTML. Gates green, `swift/` committed. Branch: +`fix/rolldown-wasi-and-causes`. diff --git a/STATUS.md b/STATUS.md index ed8a512..f958155 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,9 +1,16 @@ # STATUS.md — the verified state of Mouse -Last reconciled: 2026-07-31, at commit 953dd98 (end of the phase-G run, -27ed75b..953dd98, 184 commits). This file is the one place that says where -every phase stands, with the evidence named. When a phase's state changes, -this file moves with it — same rule as every other doc here. +Last reconciled: 2026-08-12, at commit 755a1c1 (the `npm run dev` run, +d27ffdd..755a1c1), by running the whole suite rather than trusting the last +green one: **143 assertions passed, 0 failed, 0 build-broken**, plus the five +harnesses `verify.sh` declares investigations and does not count — `breadth`, +`glob`, `reachable`, `shapes`, `vitestrun`. Re-run at HEAD rather than left at +the earlier green, because `TerminalSession` changed after it. Before that, +2026-07-31 at 953dd98 (end of the phase-G run, 27ed75b..953dd98, 184 commits). + +This file is the one place that says where every phase stands, with the evidence +named. When a phase's state changes, this file moves with it — same rule as +every other doc here. The letter phases are subsystems, not a sequence. Build order was dependency-driven: T → A → F → G, with D falling out of G. compile.md @@ -16,8 +23,8 @@ numbers some of the same ground differently; the mapping is noted per row. | **T** — terminal screen | VT100/xterm grid, ANSI parser, TUI hosting, keyboard encoding | **Done** | `swift/Mouse/TerminalScreen.swift`, `TerminalWidth.swift`, `TerminalPrograms.swift`. Gated: screen corpus, pyte cross-check, wide-char/alt-screen/DECCKM/bracketed-paste harnesses (`verify/tty`, `altscreen`, `widetui`, `inkwide`) | | **A** — shell language | msh's POSIX-subset grammar: control flow, `$()`, functions, redirects | **Done** | `swift/Mouse/ShellLanguage.swift`. Gated against real `/bin/sh` on the 25-script corpus (`verify/shell`) | | **F** — package manager | Real npm registry, semver, integrity, lockfiles; `npm run`/`create`/`npx`; native-binary → wasm-build substitution | **Done** | `swift/Mouse/PackageManager.swift`. Gated: semver corpus vs pnpm, `verify/pkg`, `firstrun` (scaffold→install→dev end to end) | -| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. ~88-gate suite green at 953dd98 (`verify/`), fixtures byte-identical to real node v22 | -| **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). Remaining piece is the Preview surface (phase C) | +| **G** — the Node layer | Node 22-surface runtime on JavaScriptCore: modules, streams, fs, net/http/1.1+2, crypto, workers, child processes, vm, WASI | **Done** (this run) | `swift/Mouse/NodeEngine.swift` + `NodeSockets`/`NodeWatch`/`NodeKeys`/`NodeScrypt`/`NodeBrotli`/`NodeDNS`. 148-harness suite (`verify/`), fixtures byte-identical to real node v22. Added since: `lightningcss` (the native CSS transformer, substituted with its authors' wasm build), `watchroot` (a watcher rooted at the project root, which is where the restart storm lived), `stackshape` (error.stack carrying its message, the way V8 writes it), `tailwind` (version 3 compiles here; version 4 cannot, and the wall is Apple's) | +| **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). A real SvelteKit project runs `npm run dev` on the device: starts once, pre-bundles its 20 dependencies, and answers a curl from the Mac with HTTP 200 and the rendered 33 kB page. Remaining piece is the Preview surface (phase C) | | **B** — WebView JIT | Move JS/wasm execution into WKWebView for JIT speed | **Not started — optional** | Measured: everything runs interpreted; the JIT buys speed, not capability (system.md:2094). No longer a prerequisite for anything | | **C** — Preview container | In-app viewing surface for what dev servers serve; LAN hosting | **Not started** | The server half works (vite serves clients outside the app — gated in `verify/devserver`); no in-app viewer exists | | **E** — wasm runtime processes | Real processes: `$PATH`, executable bits, `ps`/`kill`/`&`, pipes between programs; other languages (Python first) as wasm32-wasi artifacts | **Runtime half done** | `pkg install python` downloads the official CPython wasm32-wasi build, hash-checks it, unpacks it (zip reader written here — iOS has no `unzip`) and `python hello.py` runs CPython 3.14.6. `swift/Mouse/Runtimes.swift` + mounts in `NodeEngine`. Gated: `verify/python`, `verify/pkgpython`. Written up in system.md §5b. Missing: `$PATH`, executable bits, background jobs (`&` is still refused by name in the lexer) | diff --git a/swift/Mouse/CarouselDeck.swift b/swift/Mouse/CarouselDeck.swift index 9339e24..3668df4 100644 --- a/swift/Mouse/CarouselDeck.swift +++ b/swift/Mouse/CarouselDeck.swift @@ -162,6 +162,18 @@ final class CarouselDeck { ringTerminal = session return session } + + /// Go back to the project picker. Everything the ring was holding belonged to the project it + /// is leaving: the terminal (and whatever it was running), the open file, the workspace + /// itself. Dropping the session without stopping it would strand a running dev server on its + /// port with no screen able to reach it. + @MainActor + func leaveWorkspace() { + ringTerminal?.stopForProjectChange() + ringTerminal = nil + openFilePath = nil + workspace = nil + } /// Extra divider height while a gap-label lesson needs room for its word. Animated model /// state (not derived at render time) so divider growth and the compensating lane re-fit /// share one transaction — the stack's total height never wavers. Set by the view layer. diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index ba73769..098a701 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -198,10 +198,56 @@ final class NodeEngine: @unchecked Sendable { self.env = env self.shell = shell self.tty = tty - self.mounts = mounts.map { (normalizeMountPrefix($0.prefix), $0.url) } - .sorted { $0.prefix.count > $1.prefix.count } + // The workspace answers to a NAMED path as well as to "/", and a program is launched + // with the named one as its cwd. Both spellings resolve to the same directory, so + // nothing that already speaks "/" changes. + // + // This exists because a project rooted at "/" breaks file watching, and the failure is + // spectacular rather than subtle. chokidar records a directory under its parent: + // `_getWatchedDir(dirname(dir)).add(basename(dir))`. For dir == "/", POSIX says + // dirname is "/" and basename is "" — node agrees, our path module is right — so it + // files an EMPTY-NAMED child inside the root's own record. The next directory read + // never lists "", the diff calls it deleted, and removing "" resolves back to the root + // and tears down the whole tracked tree: an `unlink` for every file in the project. + // vite sees its config unlinked, restarts, builds a fresh watcher, and does it again + // about three seconds later — the restart storm, and with it the dependency scan dying + // as ERR_CLOSED_SERVER. No other platform hits this because no real project lives at + // the filesystem root. + // + // The name is CHOSEN, not fixed, because the alias is matched before the root and would + // otherwise shadow a real directory of the same name: `/project/x` would reach + // `/x` and the true `/project/x` could not be addressed at all. + // That was written down as a caveat and it is not hypothetical — two harnesses here keep + // their packages in a directory called `project`, and both stopped finding them. So a + // workspace that already has that name gets `/project-2`, and both paths stay reachable. + // A local copy of the normaliser: the instance method would capture `self` here, and + // `namedRoot` is not assigned yet. + func normalizedPrefix(_ prefix: String) -> String { + var text = prefix.hasPrefix("/") ? prefix : "/" + prefix + while text.count > 1 && text.hasSuffix("/") { text.removeLast() } + return text + } + var resolved: [(prefix: String, url: URL)] = + mounts.map { (prefix: normalizedPrefix($0.prefix), url: $0.url) } + let existing = Set((try? FileManager.default.contentsOfDirectory(atPath: root.path)) ?? []) + let claimed = Set(resolved.map(\.prefix)) + var name = Self.defaultNamedRoot + var attempt = 2 + while existing.contains(String(name.dropFirst())) || claimed.contains(name) { + name = "\(Self.defaultNamedRoot)-\(attempt)" + attempt += 1 + } + self.namedRoot = name + resolved.append((prefix: name, url: root)) + self.mounts = resolved.sorted { $0.prefix.count > $1.prefix.count } } + /// The workspace's named spelling — what a program gets as its cwd, so its own root has a + /// real basename. Per engine, because it steps aside for a real directory of the same name; + /// read it from the engine rather than assuming the default. See the note in `init`. + let namedRoot: String + static let defaultNamedRoot = "/project" + /// Attach the terminal after init — the host program can't hand closures over itself /// to its own initializer. Must happen before `run`. func attachTTY(_ tty: TTY) { @@ -386,6 +432,7 @@ final class NodeEngine: @unchecked Sendable { if !handled, exitCode == nil { err += sourceContext(of: fatalValue) err += remapStack(fatal.hasSuffix("\n") ? fatal : fatal + "\n") + err += causeChain(of: fatalValue) exitCode = 1 } } @@ -436,6 +483,42 @@ final class NodeEngine: @unchecked Sendable { return Result(out: out, err: err, status: exitCode ?? 0) } + /// The `[cause]:` chain under an uncaught error, the way node has printed it since 16.9 — + /// each level indented two more spaces, message first, frames remapped like the top error's. + /// Without this, a library that wraps its real failure (`new Error(summary, { cause })`) + /// prints only the summary: rolldown's loader threw "Cannot find native binding" with npm + /// advice attached, while the actual refusal — JavaScriptCore declining a thread-built wasm + /// module — sat unprinted in the cause. The one honest error surface was hiding the honest + /// error. + /// + /// Two deliberate divergences from node's rendering, which is `util.inspect`'s: no `{`/`}` + /// object braces around nested errors, and no "... N lines matching cause stack trace ..." + /// elision — the plain chain reads better on a phone column and elides nothing. + private func causeChain(of error: JSValue?) -> String { + var text = "" + var current = error?.forProperty("cause") + var depth = 1 + // A cause cycle would loop forever; eight levels prints every chain a real package + // throws (rolldown's is six) without needing identity tracking across JSValues. + while let value = current, !value.isUndefined, !value.isNull, depth <= 8 { + let message = value.toString() ?? "?" + // JSC stacks carry frames only — no leading "Error: message" line like V8's. + var body = message + if value.isObject, let stack = value.forProperty("stack"), !stack.isUndefined, + let frames = stack.toString(), !frames.isEmpty { + body += "\n" + remapStack(frames.hasSuffix("\n") ? String(frames.dropLast()) : frames) + } + let indent = String(repeating: " ", count: depth) + let lines = body.split(separator: "\n", omittingEmptySubsequences: false) + for (index, line) in lines.enumerated() { + text += indent + (index == 0 ? "[cause]: " : " ") + line + "\n" + } + current = value.isObject ? value.forProperty("cause") : nil + depth += 1 + } + return text + } + /// `sourceURL` names the module in stack traces. Without it JSC reports every frame as a /// bare `functionName@` — unreadable for anyone debugging a real error in the terminal, /// which is the app's one honest error surface. @@ -2848,6 +2931,7 @@ final class NodeEngine: @unchecked Sendable { } registerSourceMap(id: id, source: source) let esm = isESModule(id: id, source: source) + let rawSource = source if esm { source = transpileCached(source) } else if source.contains("import") { @@ -2857,7 +2941,22 @@ final class NodeEngine: @unchecked Sendable { } // ESM evaluates under an ASYNC wrapper (its imports may await a top-level-await // dependency); CJS stays a plain sync function. - guard let function = wrapModule(source, async: esm, sourceURL: id) else { + var function = wrapModule(source, async: esm, sourceURL: id) + if function == nil, esm { + // Live bindings are a REFERENCE REWRITE, and a rewrite over third-party code is + // never provably safe: minified bundles put an imported name in places a scanner + // cannot always classify (a class field, a template substitution, an object + // shorthand). Any miss shows up as a SyntaxError, which is exactly the signal + // needed — so the module is transpiled again with bindings left as copies, which + // is what this engine did for its whole life before. Correct where it can be, + // never worse than before where it cannot. + let snapshot = transpileCached(rawSource, liveBindings: false) + if let retried = wrapModule(snapshot, async: true, sourceURL: id) { + source = snapshot + function = retried + } + } + guard let function else { return throwInJS("Cannot parse '\(id)'" + (lastParseProblem.map { ": " + $0 } ?? "")) } let module = context.evaluateScript("({exports: {}})")! @@ -3006,7 +3105,7 @@ final class NodeEngine: @unchecked Sendable { /// are assigned at EOF (function/class hoist; const/let are bound by then). /// Bumped whenever `transpileESM` changes: the cache is content-addressed by SOURCE, so /// without this a stale rewrite would be reused after an engine update. - private static let transpilerVersion = 7 + private static let transpilerVersion = 8 /// Transpiling a big bundle is the dominant cost of launching a bundled CLI — /// claude-code's 9.3 MB takes ~1.85 s of the ~2.4 s load, every launch. The result is a @@ -3023,16 +3122,17 @@ final class NodeEngine: @unchecked Sendable { return directory }() - private func transpileCached(_ source: String) -> String { + private func transpileCached(_ source: String, liveBindings: Bool = true) -> String { guard source.utf8.count >= Self.transpileCacheThreshold, let directory = Self.transpileCacheDirectory else { - return Self.transpileESM(source) + return Self.transpileESM(source, liveBindings: liveBindings) } let digest = SHA256.hash(data: Data(source.utf8)) - let name = digest.map { String(format: "%02x", $0) }.joined() + "-v\(Self.transpilerVersion).cjs" + let name = digest.map { String(format: "%02x", $0) }.joined() + + (liveBindings ? "" : "-snap") + "-v\(Self.transpilerVersion).cjs" let file = directory.appendingPathComponent(name) if let cached = try? String(contentsOf: file, encoding: .utf8) { return cached } - let transpiled = Self.transpileESM(source) + let transpiled = Self.transpileESM(source, liveBindings: liveBindings) try? transpiled.write(to: file, atomically: true, encoding: .utf8) return transpiled } @@ -3202,7 +3302,15 @@ final class NodeEngine: @unchecked Sendable { while end < count, isIdent(bytes[end]) { end += 1 } let word = String(decoding: bytes[index.. String { + /// Blanks the INSIDE of every quoted string, keeping the quotes, every other character and + /// the total length. Only the shadow test uses this: it asks whether a module declares a + /// name, and prose can hold any word. + static func blankQuotedStrings(_ text: String) -> String { + let ns = text as NSString + var chars = [unichar](repeating: 0, count: ns.length) + ns.getCharacters(&chars) + var i = 0 + while i < chars.count { + let quote = chars[i] + guard quote == 0x27 || quote == 0x22 else { i += 1; continue } + var j = i + 1 + // A quoted string ends at its quote or at the line end — an unterminated quote is an + // apostrophe in a comment the mask already blanked, and must not eat the rest of the file. + while j < chars.count, chars[j] != quote, chars[j] != 0x0a { + j += chars[j] == 0x5c ? 2 : 1 + } + let end = min(j, chars.count) + if end > i + 1 { + for k in (i + 1).. String { var text = source // Emitted BEFORE the body: function declarations are hoisted, so a cycle reaching back // into this module finds them, and every export reads through a getter rather than // being frozen at end-of-module. var prologue: [String] = [] - var epilogue: [String] = [] var counter = 0 var maskCache: String? @@ -3268,7 +3401,7 @@ final class NodeEngine: @unchecked Sendable { // bytes with strings, comments and regexes blanked — and then applied to the text at // those offsets. A bundle emits JavaScript as data: vite's worker shim is the literal // line `export default function WorkerWrapper(options) {` inside a template string, and - // rewriting it both corrupted the code vite serves and left the epilogue assigning a + // rewriting it both corrupted the code vite serves and left an export assigning a // binding that does not exist. The mask is recomputed per pass because each pass // changes the text; scanning is linear and this is cached by content anyway. func replace(_ pattern: String, _ transform: (NSTextCheckingResult, NSString) -> String) { @@ -3348,6 +3481,48 @@ final class NodeEngine: @unchecked Sendable { .joined(separator: ", ") } + // Named imports promoted to live bindings: alias -> `.`, applied to every + // reference in a final pass. See the note at the emission site. + var live: [(alias: String, temp: String, source: String)] = [] + // The whole source as code-only text, computed once: declarations cannot be introduced by + // our own rewrites, so the shadowing question is answered against the ORIGINAL. + // + // The general mask keeps QUOTED STRINGS verbatim, because the import patterns read + // specifiers out of them. The shadow test never wants a string's contents, and reading + // them costs correctness: svelte's compiler throws an error whose text is a + // `https://svelte.dev/...` link, and the `dev` in that URL sat inside the enclosing + // call's parentheses, so the parameter-list pattern below decided the module bound its + // own `dev`. It does not — `dev` is `export let dev` in the compiler's state module, + // assigned per compile — and left as a copy it read `false` forever. Components then + // compiled HALF in dev mode: `push_element` emitted, the `FILENAME` it reads not, and + // every SvelteKit page died in svelte's own renderer. + let shadowMask = blankQuotedStrings(rewriteImportForms(source, meta: false, mask: true).text) + /// Does this module bind `name` itself anywhere — a declaration, a parameter, a catch, a + /// destructure? Then the name is not unambiguously the import, and it is left as a copy. + /// Deliberately over-eager: a false "yes" costs live-ness, a false "no" corrupts code. + func shadows(_ name: String) -> Bool { + let n = NSRegularExpression.escapedPattern(for: name) + let patterns = [ + "\\b(?:let|const|var|function|class)\\s+\\Q\(name)\\E\\b", + "\\bcatch\\s*\\(\\s*\\Q\(name)\\E\\b", + // `b.function(…)` is a CALL to a method named function, never a declaration, and + // its arguments are not parameters. Without the lookbehind, any name appearing + // anywhere inside such a call read as a shadow. + "(?", + "\\b" + n + "\\s*=>", + "[\\{,]\\s*\\b" + n + "\\b\\s*[,\\}][^=;\\n]*=[^=]", + ] + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern), + regex.firstMatch(in: shadowMask, + range: NSRange(location: 0, length: (shadowMask as NSString).length)) != nil { + return true + } + } + return false + } + // Every import site awaits ONLY a genuinely-pending (top-level-await) dependency: // `if (x instanceof Promise) x = await x`. A fully-sync dependency evaluates without // suspension, so sync modules stay sync under the async wrapper; a TLA dependency @@ -3371,7 +3546,26 @@ final class NodeEngine: @unchecked Sendable { // closed. Destructuring reads it at import time and turns a legal cycle into a // ReferenceError, which is what execa's send.js ↔ strict.js pair hit. for binding in bindings(named) { + // A named import is a LIVE BINDING in real ESM: the importer sees a value the + // exporter assigns later. A `const` copies it once, and svelte's compiler is + // the case that made this matter — `export let locator;` is undefined at + // import time and filled in during init, so a copy stays undefined forever + // and the parser dies calling it. + // + // Live means reading through the namespace where the name is USED, which is a + // reference rewrite. It is only safe where the name cannot be something else, + // so `shadows` refuses whenever the module also DECLARES that name; those + // fall back to the copy, which is exactly today's behaviour and no worse. + // The declaration is ALWAYS emitted, even when the name is promoted to a + // live binding. The rewrite below skips any occurrence it cannot classify, + // and without a declaration to fall back on such a name would simply not + // exist — svelte's a11y constants died as "Can't find variable: AXObjects" + // for exactly that reason. With the const still there, a missed reference + // reads the old snapshot: the previous behaviour, not a crash. lines.append("const \(binding.alias) = __esmBinding(\(temp), '\(binding.source)');") + if liveBindings && !shadows(binding.alias) { + live.append((alias: binding.alias, temp: temp, source: binding.source)) + } } } if let namespace = group(match, 3, ns) { @@ -3539,12 +3733,142 @@ final class NodeEngine: @unchecked Sendable { // assignment put a phantom key into `Object.keys(ns)`, into a spread of it, and into // anything that walks a module's exports. Defined this way the interop check still // sees it and nothing that ENUMERATES does. + // Live bindings, applied last: every remaining reference to a promoted alias becomes a + // read through the namespace, so a value the exporter assigns later is seen. Only names + // `shadows` cleared get here, so a bare occurrence is the import and nothing else. + // + // Three shapes have to be told apart, and the mask makes that safe by blanking strings, + // comments and regexes first: + // `{ locator }` object shorthand -> `{ locator: __esm1.locator }` (a bare + // `__esm1.locator` there is a syntax error) + // `{ locator: … }` a KEY, not a read -> left alone + // `locator` everything else -> `__esm1.locator` + // `.locator` is skipped by the preceding-character test, which is what already kept + // `runner.import` intact. + var body = prologue.joined(separator: " ") + " " + text + "\n;" + if !live.isEmpty { + let mask = rewriteImportForms(body, meta: false, mask: true).text + let ns = body as NSString + let maskNS = mask as NSString + if maskNS.length == ns.length { + var edits: [(range: NSRange, replacement: String)] = [] + for binding in live { + let pattern = "\\b" + NSRegularExpression.escapedPattern(for: binding.alias) + "\\b" + guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } + for match in regex.matches(in: mask, range: NSRange(location: 0, length: maskNS.length)) { + let at = match.range.location + // Only code. The mask blanks comments, templates and regexes, but it + // keeps QUOTED strings verbatim on purpose — the import patterns read + // specifiers out of them — so a quoted `send` passes the mask test and + // has to be excluded here. Without this, `from './send.mjs'` became + // `from './__esm1.send.mjs'` and the module vanished. + guard maskNS.substring(with: match.range) == ns.substring(with: match.range) else { continue } + var quote: unichar = 0 + var lineStart = at + while lineStart > 0, maskNS.character(at: lineStart - 1) != 0x0a { lineStart -= 1 } + var probe = lineStart + while probe < at { + let c = maskNS.character(at: probe) + if c == 0x5c { probe += 2; continue } // escape + if quote == 0, c == 0x27 || c == 0x22 { quote = c } + else if quote != 0, c == quote { quote = 0 } + probe += 1 + } + if quote != 0 { continue } // inside a string + // NEWLINES are whitespace here. A property list is written one per line, + // so `{\n a,\n b\n}` put a newline between `,` and the name and every + // entry failed the property-start test below — the shorthand became + // `{ a, ns.b }`, which does not parse, and the whole module silently fell + // back to snapshot bindings. svelte's compiler keeps its visitors in + // exactly that shape. + func isSpace(_ c: unichar) -> Bool { c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d } + var before = at - 1 + while before >= 0, isSpace(maskNS.character(at: before)) { before -= 1 } + if before >= 0 { + let previous = maskNS.character(at: before) + if previous == 0x2e || previous == 0x23 { continue } // .name #name + } + // A DECLARATION of the name is never a read — including the + // `const x = __esmBinding(…)` emitted just above, which would otherwise + // become `const __esm1.x = …`. + let keywordEnd = before + 1 + var keywordStart = before + while keywordStart >= 0, maskNS.character(at: keywordStart) != 0x20, + maskNS.character(at: keywordStart) != 0x09, + maskNS.character(at: keywordStart) != 0x0a, + maskNS.character(at: keywordStart) != 0x3b, + maskNS.character(at: keywordStart) != 0x7b { keywordStart -= 1 } + keywordStart += 1 + if keywordEnd > keywordStart { + let keyword = maskNS.substring(with: NSRange(location: keywordStart, + length: keywordEnd - keywordStart)) + if keyword == "const" || keyword == "let" || keyword == "var" + || keyword == "function" || keyword == "class" { continue } + } + var after = at + match.range.length + while after < maskNS.length, isSpace(maskNS.character(at: after)) { after += 1 } + let next: unichar = after < maskNS.length ? maskNS.character(at: after) : 0 + if next == 0x3a { continue } // `name:` is a key + let read = "\(binding.temp).\(binding.source)" + // Object shorthand needs the ENCLOSING bracket, not the preceding + // character: `f(a, named)` and `{ a, named }` both sit after a comma, + // and rewriting the first to `named: …` is a syntax error inside a call + // — which is exactly what broke import-forms. Walk back over balanced + // pairs to whatever bracket actually contains this name. + var enclosing: unichar = 0 + var scan = before + var depth = 0 + while scan >= 0 { + let c = maskNS.character(at: scan) + if c == 0x29 || c == 0x5d || c == 0x7d { depth += 1 } + else if c == 0x28 || c == 0x5b || c == 0x7b { + if depth == 0 { enclosing = c; break } + depth -= 1 + } else if depth == 0 && (c == 0x3b) { break } + scan -= 1 + } + // `${ name }` is a template SUBSTITUTION, not an object: its brace is + // preceded by `$`, and treating it as shorthand emitted `name: …` inside + // a template expression — which is how vite's own cli.js stopped parsing. + var braceIsObject = enclosing == 0x7b + if braceIsObject, scan > 0, maskNS.character(at: scan - 1) == 0x24 { + braceIsObject = false + } + // Shorthand needs BOTH: the enclosing bracket is an object brace, and + // the name sits at the start of a property — directly after `{` or `,`. + // Without the second test, `{ key: imported }` matched (the scan walks + // back past the colon to the brace) and produced + // `{ key: imported: ns.imported }`, which is how fdir stopped parsing. + let previousChar: unichar = before >= 0 ? maskNS.character(at: before) : 0 + let atPropertyStart = previousChar == 0x7b || previousChar == 0x2c + let shorthand = braceIsObject && atPropertyStart && (next == 0x2c || next == 0x7d) + edits.append((match.range, shorthand ? "\(binding.alias): \(read)" : read)) + } + } + if !edits.isEmpty { + var result = "" + var cursor = 0 + for edit in edits.sorted(by: { $0.range.location < $1.range.location }) { + guard edit.range.location >= cursor else { continue } + result += ns.substring(with: NSRange(location: cursor, + length: edit.range.location - cursor)) + result += edit.replacement + cursor = edit.range.location + edit.range.length + } + if cursor < ns.length { + result += ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)) + } + body = result + } + } + } + // Everything before the body goes on ONE line and everything after it on the last, for // the same reason the wrapper does: a module's line 5 has to still be line 5. The // prologue was four lines of live-export getters, so an ESM stack pointed four lines // past the throw. return "Object.defineProperty(module.exports, '__esModule', { value: true }); try { " - + prologue.joined(separator: " ") + " " + text + "\n;" + epilogue.joined(separator: " ") + + body + " ;module.__esmDone = true; } catch (__esmThrown) { module.__esmError = __esmThrown; throw __esmThrown; }" } @@ -4637,6 +4961,90 @@ final class NodeEngine: @unchecked Sendable { }; globalThis.CountQueuingStrategy = class CountQueuingStrategy { constructor(options) { this.highWaterMark = options && options.highWaterMark || 1; } }; globalThis.ByteLengthQueuingStrategy = class ByteLengthQueuingStrategy { constructor(options) { this.highWaterMark = options && options.highWaterMark || 16384; } }; + // A V8 stack reads "TypeError: message" and then " at fn (file:line:col)" lines. JSC + // gives the frames alone, "fn@file:line:col", with no first line at all — so on this + // engine anything that logs `err.stack` prints frames and no reason. SvelteKit's + // format_server_error prints exactly that and nothing else, which is why a page that + // failed to render showed forty frames and never said what went wrong; and vite's + // ssrRewriteStacktrace matches `/^ {4}at /`, so no SSR frame was ever mapped back to the + // .svelte file it came from either. + // + // JSC materialises `stack` as an own data property while the error is being constructed — + // on errors the ENGINE throws as much as on ones JavaScript builds — so there is no hook + // that sees every error. What is reachable is every error JavaScript constructs, which is + // what a library throws on purpose, and that is what this covers. A TypeError the engine + // raises still reads as bare frames through `.stack`; console and util.inspect rebuild the + // header for those, as they already did. + globalThis.__mouseV8Frames = function(raw) { + const out = []; + for (const line of String(raw || '').split('\n')) { + if (!line) continue; + if (/^\s+at\s/.test(line)) { out.push(line); continue; } // already V8-shaped + const at = line.lastIndexOf('@'); + if (at < 0) { out.push(' at ' + line); continue; } + const name = line.slice(0, at); + const where = line.slice(at + 1) || ''; + out.push(name ? ' at ' + name + ' (' + where + ')' : ' at ' + where); + } + return out.join('\n'); + }; + (function() { + const kinds = [Error, EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError]; + if (typeof AggregateError === 'function') kinds.push(AggregateError); + const replacements = new Map(); + for (const Native of kinds) { + // Named, and the name is load-bearing: the frames JSC captures include this function + // and whatever native frames sit under it, none of which are part of the error's + // history — V8 does not show the Error constructor either. Counting frames off the top + // guessed wrong (it left `at Wrapped ()` as the innermost frame), so the + // cut is made at the marker instead. + const Wrapped = function __mouseErrorConstructor() { + const error = Reflect.construct(Native, arguments, new.target || Wrapped); + const lines = String(error.stack || '').split('\n'); + const mine = lines.findIndex(function(l) { return l.indexOf('__mouseErrorConstructor@') === 0; }); + const raw = (mine >= 0 ? lines.slice(mine + 1) : lines).join('\n'); + let text = null; + // An ACCESSOR, not a value: building the string costs a split and a join over every + // frame, and the overwhelming majority of errors are constructed, caught and never + // asked for a stack. It also matches V8, where `stack` is lazy — and vite's + // rebindErrorStacktrace, which reads the descriptor before overwriting, takes the + // configurable branch either way. + Object.defineProperty(error, 'stack', { + configurable: true, + enumerable: false, + get: function() { + if (text === null) { + const header = (error.name || 'Error') + + (error.message ? ': ' + error.message : ''); + const frames = globalThis.__mouseV8Frames(raw); + text = frames ? header + '\n' + frames : header; + } + return text; + }, + set: function(value) { text = value; }, + }); + return error; + }; + // The prototype is the native one, untouched, so `instanceof` keeps answering for + // errors the engine threw as well as these, and `e.constructor === Error` stays true. + Wrapped.prototype = Native.prototype; + Object.defineProperty(Native.prototype, 'constructor', + { value: Wrapped, writable: true, configurable: true }); + Object.defineProperty(Wrapped, 'name', { value: Native.name, configurable: true }); + for (const key of Object.getOwnPropertyNames(Native)) { + if (key === 'prototype' || key === 'name' || key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(Native, key); + if (descriptor) Object.defineProperty(Wrapped, key, descriptor); + } + replacements.set(Native, Wrapped); + } + // `TypeError.__proto__ === Error` in the language, and code reads it; keep the shape. + for (const [Native, Wrapped] of replacements) { + const parent = replacements.get(Object.getPrototypeOf(Native)); + if (parent) Object.setPrototypeOf(Wrapped, parent); + globalThis[Native.name] = Wrapped; + } + })(); // V8's stack-trace protocol: when Error.prepareStackTrace is set, captureStackTrace // hands it structured CallSite objects (depd — under express — walks them). JSC's // native captureStackTrace ignores the protocol, so emulate it from JSC stack lines @@ -4668,7 +5076,7 @@ final class NodeEngine: @unchecked Sendable { target.stack = Error.prepareStackTrace(target, callSites); } else { target.stack = (target.name || 'Error') + (target.message ? ': ' + target.message : '') + '\n' - + raw.map(function(l){ return ' at ' + l; }).join('\n'); + + globalThis.__mouseV8Frames(raw.join('\n')); } }; globalThis.atob = function(base64) { return Buffer.from(String(base64), 'base64').toString('binary'); }; @@ -4937,9 +5345,19 @@ final class NodeEngine: @unchecked Sendable { this.password = credentials && credentials.indexOf(':') >= 0 ? credentials.slice(credentials.indexOf(':') + 1) : ''; this.hostname = match[3] || ''; this.port = match[4] || ''; + // `file:` is a SPECIAL scheme in the URL standard: its serialization always carries + // the authority marker even though it never has a real host, and `localhost` IS the + // empty host. Ours kept whatever the text happened to say, so `new URL('file:' + p)` + // — which is the `import.meta.url` polyfill esbuild emits, and how lightningcss-wasm + // locates its own .wasm — stayed `file:/project/…` with a single slash. `fs` then + // went looking for a file whose name started with `file:`. Only `file:` is handled + // here; `http:/host/x`, which the standard also re-reads as an authority, is left as + // it was. + const isFile = this.protocol === 'file:'; + if (isFile && this.hostname.toLowerCase() === 'localhost') this.hostname = ''; this.host = this.hostname + (this.port ? ':' + this.port : ''); // A hierarchical URL always has a path; node reports '/' where the text has none. - const hierarchical = text.indexOf('//') === this.protocol.length; + const hierarchical = isFile || text.indexOf('//') === this.protocol.length; this._hierarchical = hierarchical; // A path is percent-encoded text, not raw text. Existing '%' escapes are left alone // (re-encoding them would corrupt any URL that arrived already encoded); everything @@ -5512,6 +5930,11 @@ final class NodeEngine: @unchecked Sendable { return Buffer.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); } if (value instanceof ArrayBuffer) return Buffer.from(new Uint8Array(value)); + // An ARRAY of numbers is bytes, which is what `Buffer.from([…])` means in node. Falling + // through to String() turned one into its own decimal listing: a SvelteKit error page + // arrived over the wire as "60,33,100,111,99,116,..." — the source text of + // `` rendered as comma-separated character codes. + if (Array.isArray(value)) return Buffer.from(value); return Buffer.from(String(value), encoding && encoding !== 'buffer' ? encoding : 'utf8'); }; // TypeScript, compiled by the project's OWN typescript package — the ts-node model, and @@ -6468,6 +6891,31 @@ final class NodeEngine: @unchecked Sendable { const stack = reason.stack; text = stack && String(stack).indexOf(reason.message) >= 0 ? String(stack) : String(reason) + (stack ? '\n' + stack : ''); + // The `[cause]:` chain, as node prints it since 16.9 — each level indented two more + // spaces, frames under the message. A library that wraps its real failure + // (`new Error(summary, { cause })`) otherwise prints ONLY the summary: rolldown's + // loader says "Cannot find native binding" with npm advice, while the true refusal — + // the engine declining a thread-built wasm module — sat unprinted in the cause. No + // object braces and no stack elision, unlike node's inspect: the plain chain elides + // nothing, which on a phone column is the point. + let cause = reason.cause; + let depth = 1; + while (cause !== undefined && cause !== null && depth <= 8) { + const indent = ' '.repeat(depth); + let body = String(cause && cause.message !== undefined ? cause : cause); + if (cause instanceof Error) { + body = String(cause); + if (cause.stack && String(cause.stack).length) { + body += '\n' + String(cause.stack).replace(/\n$/, ''); + } + } + const lines = body.split('\n'); + for (let i = 0; i < lines.length; i++) { + text += '\n' + indent + (i === 0 ? '[cause]: ' : ' ') + lines[i]; + } + cause = (cause instanceof Error || typeof cause === 'object') && cause ? cause.cause : undefined; + depth += 1; + } } else { // node wraps a non-Error rejection rather than printing it bare. text = 'UnhandledPromiseRejection: This error originated either by throwing inside of ' + @@ -11271,7 +11719,10 @@ final class NodeEngine: @unchecked Sendable { // contract, appending an mtime to `searchParams` to cache-bust an ESM config import, // which needs an actual URL object rather than a bare { href }. fileURLToPath: function(url) { - const href = typeof url === 'string' ? url : String((url && url.href) || url); + let href = typeof url === 'string' ? url : String((url && url.href) || url); + // A file URL written with one slash is the same URL; node parses the string before + // reading it, so `fileURLToPath('file:/a')` is '/a' rather than a scheme complaint. + if (/^file:(?!\/\/)/i.test(href)) { try { href = new URL(href).href; } catch (error) {} } if (!/^file:\/\//i.test(href)) { const error = new TypeError('The URL must be of scheme file'); error.code = 'ERR_INVALID_URL_SCHEME'; @@ -12007,8 +12458,12 @@ final class NodeEngine: @unchecked Sendable { if (hasBody && (this.method === 'GET' || this.method === 'HEAD')) { throw new TypeError('Request with GET/HEAD method cannot have body.'); } - defineBody(this, !hasBody ? Buffer.alloc(0) - : (Buffer.isBuffer(body) ? body : Buffer.from(String(body))), hasBody); + // __toBytes, not String(): a Uint8Array is BYTES, and stringifying one yields its + // decimal listing. That is literally what went over the wire — a rendered page + // arrived as "60,33,100,111,99,..." instead of "" — because + // `new Response(new TextEncoder().encode(html))` is how a framework returns a page, + // and a Uint8Array is not a Buffer. + defineBody(this, !hasBody ? Buffer.alloc(0) : __toBytes(body), hasBody); } clone() { if (this.bodyUsed) throw new TypeError('Body is unusable: Body has already been read'); @@ -12038,8 +12493,12 @@ final class NodeEngine: @unchecked Sendable { // A ReadableStream body stays a stream — that is what makes a streaming response // readable as it arrives instead of after it finishes. if (body && typeof body.getReader === 'function') { defineStreamBody(this, body); return; } - defineBody(this, !hasBody ? Buffer.alloc(0) - : (Buffer.isBuffer(body) ? body : Buffer.from(String(body))), hasBody); + // __toBytes, not String(): a Uint8Array is BYTES, and stringifying one yields its + // decimal listing. That is literally what went over the wire — a rendered page + // arrived as "60,33,100,111,99,..." instead of "" — because + // `new Response(new TextEncoder().encode(html))` is how a framework returns a page, + // and a Uint8Array is not a Buffer. + defineBody(this, !hasBody ? Buffer.alloc(0) : __toBytes(body), hasBody); } get ok() { return this.status >= 200 && this.status < 300; } clone() { @@ -17038,7 +17497,44 @@ final class NodeEngine: @unchecked Sendable { Server.prototype.listen = function() { const args = Array.prototype.slice.call(arguments); const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null; - let port = 0, host = '0.0.0.0', backlog = 511; + // Listening twice without an intervening close is a PROGRAMMING ERROR in node, and it + // throws ERR_SERVER_ALREADY_LISTEN. This used to allocate a second host socket and + // orphan the first — invisible on a machine whose process exits and frees the fd, fatal + // here where the engine outlives every program. vite's dev server calls listen again on + // EADDRINUSE and, through SvelteKit's warmup/restart, on a server already bound; each + // silent re-listen leaked a LISTENING socket, and a real device ran up thousands (3607 + // measured) before the port walk buried the terminal in "Port N is in use". Throwing is + // both correct and the fix: node's own EADDRINUSE retry closes first, and callers that + // relied on the silent second bind were leaking on node too. + // Listening again while a socket is already claimed used to allocate a SECOND host + // socket and orphan the first. On a machine whose process exits that is invisible; here + // the engine outlives every program, so each one leaked a LISTENING fd — a real device + // reached 3607 of them, and vite's port walk then reported thousands of ports "in use" + // (they were: by us) until the terminal was unusable. + // + // node throws ERR_SERVER_ALREADY_LISTEN here, and that was tried first. It is the wrong + // answer for this engine, because vite REACHES this path legitimately: its + // `createServerCloseFn` only closes a server it has seen emit 'listening' + // (`if (hasListened) server.close(…); else resolve()`), so a restart following a failed + // first bind re-listens a server it never closed. Throwing there killed a dev server + // that had already printed its URL. + // + // So the previous socket is RELEASED and the new listen proceeds. That fixes the leak — + // the only fd in flight is the one the caller asked for — without inventing a fatal + // error on a path real packages take. The divergence from node is deliberate and is the + // conservative direction: node's throw protects a caller from losing track of a socket, + // and here nothing is lost, because the socket is closed rather than orphaned. + if (this._sid) { + bridge.netDestroy(this._sid); + this._sid = 0; + this.listening = false; + this._address = null; + } + this._closing = false; + // EMPTY, not '0.0.0.0': the host being absent is information the socket layer needs, + // because node's no-host wildcard is dual-stack `::` and not IPv4's `0.0.0.0`. Naming + // the IPv4 wildcard here threw that away before it could be acted on. + let port = 0, host = '', backlog = 511; // A path listens on a socket FILE. const unixPath = (args[0] && typeof args[0] === 'object' && args[0].path) ? String(args[0].path) : (typeof args[0] === 'string' && !/^\d+$/.test(args[0]) ? args[0] : null); @@ -17053,7 +17549,7 @@ final class NodeEngine: @unchecked Sendable { } if (args[0] && typeof args[0] === 'object') { port = args[0].port || 0; - host = args[0].host || '0.0.0.0'; + host = args[0].host || ''; backlog = args[0].backlog || backlog; } else { port = Number(args[0]) || 0; @@ -17132,6 +17628,12 @@ final class NodeEngine: @unchecked Sendable { } case 'error': this.listening = false; + // The bind failed, so there is no host socket behind `_sid` — clear it, both to + // free the guard above (node's EADDRINUSE retry re-listens the same server) and + // because `_sid` is now the marker for "a socket is claimed", mirroring node's + // `_handle`. Leaving it set turned the legitimate retry into a false + // ERR_SERVER_ALREADY_LISTEN. + this._sid = null; this.emit('error', Object.assign(new Error(payload.message), { code: payload.code })); break; case 'close': diff --git a/swift/Mouse/NodeSockets.swift b/swift/Mouse/NodeSockets.swift index 6e299ce..40bf7ee 100644 --- a/swift/Mouse/NodeSockets.swift +++ b/swift/Mouse/NodeSockets.swift @@ -291,26 +291,52 @@ final class SocketTable: @unchecked Sendable { ai_protocol: 0, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil) var list: UnsafeMutablePointer? - let name = host.isEmpty ? "0.0.0.0" : host - let status = getaddrinfo(name, String(port), &hints, &list) - guard status == 0, let first = list else { + // NO HOST means the wildcard, and node's wildcard is `::` with IPV6_V6ONLY off — one + // dual-stack socket that answers IPv6 and IPv4 alike. Ours bound "0.0.0.0", IPv4 + // only, and `localhost` resolves to ::1 FIRST on this platform, so a program that + // called its own server by name was refused: `http.get({ port })` with no host, which + // is how node's own docs write it, could not reach a server this engine had just + // started. Sockets given an explicit '127.0.0.1' worked, which is exactly why it hid. + let wildcard = host.isEmpty + let status = getaddrinfo(wildcard ? nil : host, String(port), &hints, &list) + guard status == 0, let list else { fail(id: id, handler: handler, message: "getaddrinfo \(host)", code: "EADDRNOTAVAIL") return } defer { freeaddrinfo(list) } - - let fd = socket(first.pointee.ai_family, SOCK_STREAM, 0) - guard fd >= 0 else { - fail(id: id, handler: handler, message: "socket failed", code: errnoCode()) - return + let name = wildcard ? "::" : host + + var candidates: [addrinfo] = [] + var cursor: UnsafeMutablePointer? = list + while let current = cursor { candidates.append(current.pointee); cursor = current.pointee.ai_next } + // IPv6 first for the wildcard, because only the v6 socket can carry both families. + if wildcard { + candidates = candidates.filter { $0.ai_family == AF_INET6 } + + candidates.filter { $0.ai_family != AF_INET6 } + } + + var fd: Int32 = -1 + var lastCode = "EADDRNOTAVAIL" + for candidate in candidates { + let trial = socket(candidate.ai_family, SOCK_STREAM, 0) + if trial < 0 { lastCode = errnoCode(); continue } + var one: Int32 = 1 + setsockopt(trial, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size)) + setsockopt(trial, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + if wildcard, candidate.ai_family == AF_INET6 { + var off: Int32 = 0 + setsockopt(trial, IPPROTO_IPV6, IPV6_V6ONLY, &off, socklen_t(MemoryLayout.size)) + } + if bind(trial, candidate.ai_addr, candidate.ai_addrlen) == 0 { fd = trial; break } + lastCode = errnoCode() + close(trial) + // A port already taken is an ANSWER, not a reason to try the other family: + // falling through would quietly hand out a second server on a busy port and + // turn EADDRINUSE — which callers act on — into silence. + if lastCode == "EADDRINUSE" || lastCode == "EACCES" { break } } - var one: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size)) - setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) - guard bind(fd, first.pointee.ai_addr, first.pointee.ai_addrlen) == 0 else { - let code = errnoCode() - close(fd) - fail(id: id, handler: handler, message: "listen \(code) \(name):\(port)", code: code) + guard fd >= 0 else { + fail(id: id, handler: handler, message: "listen \(lastCode) \(name):\(port)", code: lastCode) return } guard Darwin.listen(fd, Int32(backlog)) == 0 else { diff --git a/swift/Mouse/PackageManager.swift b/swift/Mouse/PackageManager.swift index 10d37aa..4cb43ff 100644 --- a/swift/Mouse/PackageManager.swift +++ b/swift/Mouse/PackageManager.swift @@ -408,18 +408,46 @@ enum PackageManager { static let wasmSubstitutes: [String: String] = [ "rollup": "@rollup/wasm-node", "esbuild": "esbuild-wasm", + // lightningcss loads `lightningcss--` or a sibling `.node` and has no + // wasi branch to fall back to, so on a phone it is simply a missing module — and it is + // not optional: vite reaches for it as a CSS transformer, and tailwind 4 pulls it in + // through `@tailwindcss/node`. Its authors publish `lightningcss-wasm` at the SAME + // version, and that package's `node` export condition is a synchronous build: it reads + // its own wasm with `fs.readFileSync`, instantiates it at module scope, and exports the + // same transform/bundle/Features/composeVisitors surface the native one does. No + // `await init()` for the caller, which is what makes it substitutable at all — the + // package that depends on it calls a sync function and gets one. + "lightningcss": "lightningcss-wasm", ] - /// The same idea as `wasmSubstitutes`, but for the shape napi-rs uses: a package lists every - /// platform's binary in `optionalDependencies` and its loader tries them in turn, ending at - /// `…-wasm32-wasi` — the author's own portable build, published for platforms they do not - /// ship a binary for. Every native one is skipped here (none can load on iOS) and that one - /// is installed, so the loader's last resort is the one that is present. rolldown, which - /// vite 7 bundles with, is exactly this shape. - static func wasiBinding(of package: ResolvedPackage) -> (name: String, requirement: String)? { + /// The same idea as `wasmSubstitutes`, but for the shape napi-rs uses: a package lists its + /// platform binaries in `optionalDependencies` and its loader tries them in turn, with + /// `…-wasm32-wasi` — the author's own portable build — as the last resort. Every native one + /// is skipped here (none can load on iOS) and the wasi one is installed, so the loader's + /// last resort is the one that is present. + /// + /// Two shapes, because the ecosystem moved. Originally the wasi build sat IN + /// `optionalDependencies` and this function just found it. rolldown 1.x STOPPED listing it + /// — the natives are all that remain — which on a phone turned `npm run dev` of a vite 8 + /// project into rolldown's "Cannot find native binding" spew: the loader's wasi branch + /// probes `@rolldown/binding-wasm32-wasi` by name, but nothing had installed it. So when + /// the listing is gone, the name is DERIVED from the natives' own naming shape + /// (`@scope/binding--` → `…-wasm32-wasi`), pinned to the package's exact + /// version — napi-rs publishes every binding in lockstep. A derived name is a GUESS about + /// the registry, so it resolves as `optional`: a package with no wasi build installs + /// exactly as before rather than failing the whole tree. + static func wasiBinding(of package: ResolvedPackage) -> (name: String, requirement: String, derived: Bool)? { for (name, requirement) in package.optionalDependencies.sorted(by: { $0.key < $1.key }) where name.hasSuffix("-wasm32-wasi") { - return (name, requirement) + return (name, requirement, false) + } + for name in package.optionalDependencies.keys.sorted() { + for platform in ["-darwin-", "-linux-", "-win32-", "-freebsd-", "-android-", "-openharmony-"] { + if let range = name.range(of: platform) { + let stem = String(name[.. >> <`, including on compounds: `done < file`, `fi > out`), sequencing (`; && || !`), `test`/`[`, `read`, `set -e -x -o pipefail` and `set -- args`, `shift`, `break`/`continue`/`exit`, `eval`, `source`/`.`, script execution (`sh file args`, `sh -c 'cmd'`, `./script.sh` via shebang, `curl url | sh` — the install-script shape), comments and line continuations, and history (`history`, `!!`, `!N`). The language is verified against **real `/bin/sh`**: a 25-script corpus (control flow, functions, expansions, `set -e`, `while read`, an install-shaped script) runs through both shells with stdout and exit status compared (method in AGENTS.md). **`npm` / `pnpm` / `npx` are real** (`Mouse/PackageManager.swift`, phase F of system.md): `npm install [pkg[@range]]` resolves against the npm registry (full semver ranges), verifies tarball integrity (sha512), unpacks natively (`TarGz`), lays out a Node-compatible hoisted `node_modules`, records executables in a manifest, and updates `package.json`; `npm install` with no args installs from `package.json`, `npm ls` lists the tree, `npx ` installs and resolves the package's bin, and **`npm run