fix(one,vxrn,compiler,vite-plugin-metro): Windows path bugs across build/server/dev/native paths#702
Conversation
Static audit of every `path.*` / `await import()` / `JSON.stringify(path)` / chokidar / regex-anchored-on-`/` site surfaced a class of Windows-only bugs where native-separator producers feed POSIX-expecting consumers. Same defect class as onejs#640 / commit 61302c5 (closed PR onejs#695). Each fix is at the producer. packages/one — seed bug + audit follow-ups: - cli/build.ts mints serverJsPath via path.join (native sep). Downstream oneServe.ts runs .includes('${outDir}/server') (forward-slash literal); the check misses on Windows, the prefix is prepended a second time, and `dist\server\dist\server\...` is passed to await import() — production SSR returns 200 but loaderData is undefined. Fix at producer with posix.join: build.ts:616 (builtMiddlewares), :1031 (serverJsPath), oneServe.ts:320 (apiFile). The regex at build.ts:1366 and the literal replace at vercel/build/generate/createSsrServerlessFunction.ts:130 start matching on Windows for free. - utils/toServerOutputPath.ts (new): idempotent prefix-or-keep helper for the symmetric oneServe.ts call sites (lines 168 and 334). posix.join + startsWith with a trailing slash; backslash-input boundary conversion; no false-positive on substrings. 7 unit tests. - vite/plugins/virtualEntryPlugin.ts:97: pathToFileURL().href for the setupFile import specifier. JSON.stringify of a Windows-backslash absolute path is not a canonical ESM specifier shape. Mirrors the createNativeDevEngine fix below. Direct repro is currently blocked by an unrelated rolldown rust panic on Windows; this is preventive correctness. - vite/plugins/fileSystemRouterPlugin.tsx:559: posix.join for optimizeDeps.entries (tinyglobby needs forward-slash patterns). Also dropped the hardcoded './app' for routerRoot — pre-existing bug for users with router.root or ONE_ROUTER_ROOT overrides. - metro-config/getViteMetroPluginOptions.ts:244,264: normalizePath on path.relative results inlined by babel into metro-entry.js. ONE_SETUP_FILE_NATIVE truly needs forward-slash on Windows (Metro's isRelativeImport regex rejects `..\foo`); ONE_ROUTER_APP_ROOT_RELATIVE_TO_ENTRY is hygiene. - cli/build.ts:559,566: posix.join in rolldown chunkFileNames/assetFileNames. Latent for flat chunks; diverges for nested API routes. - cli/build.ts:1480: normalizePath on wranglerInputConfig.main. Latent today (relative() returns bare filename); preventive against future source-layout changes. - cli/buildPage.ts:67: clientJsPath was minted via join(clientDir, ...) → backslash on Windows. Currently only used by readFile (accepts both), but stored in dist/buildInfo.json with backslashes — cross-platform manifest hygiene fix matching serverJsPath. normalizePath wrap. - vite/one.ts:255: SSR symlink-dedup compares Vite's POSIX resolved.id against native realpathSync(nmPkgDir) on Windows; startsWith misses and dedup never fires for pnpm symlinks (allows duplicate React copies in SSR bundle). normalizePath both sides. Only active when ssr.dedupeSymlinkedModules: true (opt-in). packages/compiler: - transformSWC.ts:14: ignoreId regex used native path.sep (`\\` on Windows) but Vite plugin context hands the function a POSIX id on every platform, so the regex never matched on Windows — Vite-internal `.vite/deps/...` files got pushed through full SWC parse-and-transform instead of being short-circuited. Performance regression, not functional break. The function's other caller (vxrn/utils/patches.ts:274) hands native paths. Fix: POSIX-only regex (`/node_modules\/(\.vite|vite)\//`) plus normalize id once at the top of transformSWC so both callers agree on the format. Also fixes an HMR cache-key inconsistency in the same line — `id.replace (process.cwd(), '')` no longer no-ops on Windows when called from the Vite plugin path. packages/vxrn: - utils/getVitePath.ts:81: normalizePath(id) before endsWith('/react/...') sentinel. realpath returns native on Windows. Function has no in-tree caller; preventive for external consumers. - exports/dev.ts:148-157: normalize chokidar's native path and process.cwd() before stripping; viteServer.transformRequest expects POSIX URLs. Also collapsed the dual `'/dist/' || '\\dist\\'` check. Vite-native HMR codepath (Metro HMR uses its own pipeline, unaffected). - utils/createNativeDevEngine.ts:568,587: normalizePath() for the rolldown module id; pathToFileURL().href for the JSON.stringify-embedded setupFile import specifier. packages/vite-plugin-metro: - plugins/expoManifestRequestHandlerPlugin.ts:36: resolve(server.config.root) for the Vite→Metro/Expo boundary. Commit 61302c5 applied the same fix at metroPlugin.ts:88; this sibling boundary was missed. Test-only fixes (cross-platform hygiene): - vite/plugins/sourceInspectorPlugin.ts: normalizePath wrap on resolveEditorFilePath output; mirrors sibling getSourceInspectorPath. Production behavior unchanged. - packages/resolve/src/index.test.ts: construct expected paths via path.join (resolvePath returns native paths in production). - packages/vxrn/src/utils/patches.test.ts: symlink type 'dir' → 'junction'. Per Node fs docs the type arg is ignored on POSIX; on Windows junctions don't require Administrator. Single codepath. 4 fail → 8/8 pass. - packages/vxrn/src/plugins/serverExtensions.test.ts: source was refactored from FSExtra.pathExists to node:fs.existsSync without updating mocks; two config-extension tests also called plugin.config() with zero args while source destructures { command } from arg 2. Switch to vi.mock('node:fs', factory) (ESM module-namespace exports aren't spy-able) and pass the required SERVE_HOOK_OPTS. 4 fail → 7/7 pass on every platform. - packages/vxrn/package.json: add `"test": "vitest run --dir src"` script so turbo picks vxrn up in `bun run test` and CI now runs the 33 vxrn cases. Previously vxrn was typecheck-only on CI, which is how the serverExtensions stale mocks and patches.test EPERM sat broken without anyone noticing. Mirrors @vxrn/resolve. End-to-end verified on Windows host: SSR loaders return real data; web HMR fires; APK builds via Gradle; native HMR fires on Android emulator (Metro bundler). Cross-platform tests identical on Windows and Linux Docker (402 packages/one + 9 packages/resolve + 33 packages/vxrn/src + 20 helper/ posix tests, 0 fail). Vite-native bundler (`native.bundler: 'vite'`) still crashes on Windows with an unrelated rolldown rust panic (`crates/fft/src/hparser/convert.rs:120`); the createNativeDevEngine + dev.ts fixes target that codepath but cannot be directly exercised until rolldown ships a fix.
Following an audit pass: comments narrating WHAT (`posix so forward-slash on Windows`) deleted; multi-line blocks compressed to one-liners; rot phrases removed (`this PR`, hardcoded `build.ts:1031`, "during initial review"); JSDoc shrunk; backslash-acceptance rationale + Windows-specific specifier surprise preserved as one-liners. No behavior change. 421/477 vitest, 33/33 vxrn, 9/9 resolve, lint 0/0.
CI status updateThe latest run (25516120128, HEAD
The two failures are independent of this PR's changes: 1. 2. Two ways to clear the audit gate (both maintainer-side, neither in this PR's scope):
Happy to follow up either with a separate PR (advisory |
|
Quick test-suite evidence — running On The failing test is On this PR's branch ( Zero failures. The (Run command: |
Summary
Fix Windows-only path-separator drift across
one's build, server, dev, native-bundler, and metro-config code paths. Same defect class as merged #640 and commit61302c5(the cherry-pick of closed PR #695): native-separator producers feeding POSIX-expecting consumers (substring checks, regex anchors, JSON-stringified import specifiers, glob patterns).Seed bug — SSR loader path doubling on Windows
Server log:
build.tsmintsserverJsPathviapath.join(native sep). It flows through the build manifest intooneServe.ts's.includes('${outDir}/server')substring check (forward-slash literal) which misses on Windows, so the prefix is prepended a second time and the doubled path failsawait import().The same value is also the input to a regex in
build.ts(replace(new RegExp('^${outDir}/'), '')) and a literal-string replace invercel/build/generate/createSsrServerlessFunction.ts— both also assume forward-slash.Producer-side fix
build.tsandoneServe.tsmint manifest/dynamic-import paths withpath.posix.joinso the value is forward-slash on every platform:build.tsbuiltMiddlewaresentryposix.join(outDir, 'middlewares', chunk.fileName)build.tsserverJsPathposix.join(outDir, 'server', serverFileName)oneServe.tsapiFileposix.join(outDir, 'api', fileName + ext)The downstream regex in
build.tsandcreateSsrServerlessFunction.tsstart matching on Windows for free.Consumer-side helper
The other two
oneServe.tscall sites that re-prepend the prefix collapsed into one helper:posix.join(not Vite'snormalizePath): backslashes are valid filename characters on POSIX, sonormalizePathis a no-op there. We need conversion on every platform.startsWith(not.includes): avoids false-positive onfoo/dist/server/bar.js.path.join-shaped callers.7 unit tests cover idempotency, custom outDir, the false-positive guard, and the path-doubling regression. New
posixPathContract.test.tsadds 19 cross-platform tests asserting forward-slash output of every fix's producer.Static-audit follow-ups (same defect class)
After the seed, audited every
path.*/await import()/JSON.stringify(path)/ chokidar / glob /realpath/process.cwd()site acrosspackages/{one,vxrn,vite-plugin-metro,compiler,resolve}. Eleven more producer-side sites in the same class.High-impact
virtualEntryPlugin.tssetupFile import —JSON.stringify(resolve(root, setupFile))would emitimport "C:\\proj\\src\\setup.ts"; switched topathToFileURL().hreffor canonicalfile://URL. Mirrors the native sibling increateNativeDevEngine.ts. Vite-native repro previously blocked on Windows by an MSVC layout bug infast-flow-transform's vendored llvhsimple_ilist(the Hermes-backed Flow stripper thatvxrninvokes during native bundling); the fix is in flight atfacebook/hermes#2012(upstream architectural change) and ships immediately viajbroma/fast-flow-transform#18(build-time stop-gap until the Hermes pin moves past#2012). With those plus the createNativeDevEngine + dev.ts fixes in this PR, the Vite-native bundler returns a complete RN bundle on Windows for the first time.fileSystemRouterPlugin.tsxoptimizeDeps.entries—path.join('./app', route.file)for the entries array; tinyglobby/fast-glob need forward-slash patterns. Backslash patterns silently match nothing on Windows, disabling the cold-start refresh-prevention. Switched topath.posix.joinand dropped the hardcoded'./app'forrouterRoot(honorsrouter.root/ONE_ROUTER_ROOToverrides).createNativeDevEngine.tsresolvedId+ setupFile — same pair asvirtualEntryPlugin.tsfor the native bundler:normalizePath()for the rolldown id;pathToFileURL().hreffor the setupFile import specifier.vxrn/exports/dev.tschokidar listener — chokidar emits native paths,viteServer.transformRequest(id)keys the module graph by POSIX URLs. Normalize both sides; collapses the dual'/dist/' || '\\dist\\'check. (Vite-native HMR codepath; Metro HMR uses its own pipeline and is not affected.)getVitePath.tsreact/jsx-dev-runtimesentinel —realpathreturns native on Windows, sentinelendsWith('/react/jsx-dev-runtime.js')would never match. Function is currently exported but has no in-tree caller; fix is preventive for external consumers.expoManifestRequestHandlerPlugin.tsprojectRoot— wrapsserver.config.rootinpath.resolve()for native separators. Mirrors the same fix inmetroPlugin.ts(commit61302c5); this sibling boundary was missed.High-impact (cont.)
vite/one.tsSSR symlink-dedup — comparesresolved.id(POSIX, fromthis.resolve) againstrealpathSync(nmPkgDir)(native on Windows, from Node fs). ThestartsWithcheck misses on Windows, so the dedup never fires for pnpm-symlinked packages — silently allows duplicate React/RN copies in the SSR bundle. Fix:normalizePath(realpathSync(nmPkgDir))on the comparison side andnormalizePath(nmPkgDir)on the rebuild side. Only active whenssr.dedupeSymlinkedModules: true, which is opt-in.Latent (no current functional break, fix is forward-compatibility)
buildPage.tsclientJsPath— minted viajoin(clientDir, ...)(native sep on Windows). Stored indist/buildInfo.jsonasdist\\client\\assets\\foo.json Windows. Currently only consumed byreadFile(clientJsPath, ...)(accepts both separators), so no runtime break — but cross-platform manifest hygiene matchesserverJsPath. Fix:normalizePath(join(clientDir, ...)).getViteMetroPluginOptions.tsONE_ROUTER_APP_ROOT_RELATIVE_TO_ENTRY+ONE_SETUP_FILE_NATIVE—normalizePath(path.relative(...))for both. Consumed byrequire.context()(former) and as a literalimport "..."specifier (latter). Metro'sisRelativeImportregex (/^[.][.]?(?:[/]|$)/) rejects..\fooon Windows for the latter; the former is absorbed by Metro's internalpath.joinso the fix there is hygiene to keep the babel-emitted AST byte-identical across hosts.build.tschunkFileNames/assetFileNames—posix.joinfor rolldown output names. Latent for flat chunks (Path.dirnamereturns.); diverges only when an API chunk is nested.build.tswranglerInputConfig.main—normalizePathwrap.relative()returns the bare filename_worker-src.jsfor the current source layout, so the normalize call is a no-op today; defensive against future structure changes.compiler/transformSWC.tsignoreId— regex used nativepath.sepwhile Vite hands the plugin transform a POSIXidon every platform, so the regex never matched on Windows and Vite-internal.vite/deps/...files were pushed through full SWC parse-and-transform instead of being skipped. Wasteful, not incorrect. Fix: POSIX-only regex + normalizeidat the top of the function so both callers (Vite plugin = POSIX,vxrn/utils/patches.ts= native) agree on the format.Test-only fixes
sourceInspectorPlugin.ts— wrappath.joinoutput ofresolveEditorFilePathinnormalizePath, matching siblinggetSourceInspectorPath. Production behavior unchanged.packages/resolve/src/index.test.ts— two assertions hardcoded/; replaced withpath.join(resolvePathreturns native paths).packages/vxrn/src/utils/patches.test.ts—symlink(...,'dir')requires Administrator on Windows. Switched to'junction';typeis ignored on POSIX per Node fs docs, so a single codepath works on every platform. 8/8 pass post-fix (was failing on Windows pre-fix).packages/vxrn/src/plugins/serverExtensions.test.ts— pre-existing bugs failing on every platform: staleFSExtra.pathExistsmocks (source usesnode:fs.existsSync), and two tests callingconfig()with zero args while source destructures{ command }from arg 2. Fixed viavi.mock('node:fs', factory)and aSERVE_HOOK_OPTSconstant. 7/7 pass post-fix.Validation (Windows 11, Bun 1.3.13, Node 25; Linux Docker
oven/bun:1.3.13for cross-platform parity)bun run typecheck(turbo, all 36 packages)bun run lint(oxlint, 1383 files)bunx turbo run test --filter='./packages/*'packages/onepackages/resolvepackages/vxrn(newly wired into CI by this PR)End-to-end (Windows host):
/timeSSR loaderloaderData = undefined,ERR_MODULE_NOT_FOUNDfordist\server\dist\server\…loaderData:{time:"…"}in server context, no log errors/dashboardpage + layout loaderundefined/,/about, SPA/spa, API/api/healthcheckWeb HMR (edited
app/index.tsx, change appears in next request) and native HMR (Android 16 emulator, change visible on screen ~25s after edit, captured viaadb screencap) both verified on Windows host withnative.bundler: 'metro'.Test plan
+ssrpage + layout loaders return real data on Windowslint/typecheck/vitestclean on Windows AND Linux Docker^${outDir}/regex inbuild.tsand the${outDir}/literal increateSsrServerlessFunction.tswill match correctly because the producer is now forward-slash, but the deploy was not run.relative()returns the bare filename for the current source layout, so thenormalizePathcall is a no-op today.native.bundler: 'vite') — was blocked on Windows by an MSVC EBO layout bug infast-flow-transform's vendored llvhsimple_ilistthat surfaced as a phantom "null SMLoc" panic + downstreamSTATUS_ACCESS_VIOLATION. Root-caused and fixed atfacebook/hermes#2012(upstream__declspec(empty_bases)onsimple_ilist) andjbroma/fast-flow-transform#18(build-time stop-gap). With the rebuilt fft napi binding plugged intonode_modules/,curl http://localhost:8081/index.bundle?platform=android&dev=truereturns a complete ~5 MB React Native bundle in ~1.2 s on Windows; previously the bundler segfaulted on the first request. The earlier symptom-level fft#17 (cvt_smlocgraceful fallback) is no longer needed once feat: addX-React-Native-Project-Rootheader to dev server #18 lands. The createNativeDevEngine + dev.ts fixes in this PR are correctly exercised by the now-working Vite-native pipeline.Wires
packages/vxrninto CIBefore this PR,
packages/vxrnhad notestscript — its 33 vitest cases never ran in CI, which is how the brokenserverExtensions.test.tsmocks (failing on every platform) and thepatches.test.tsWindows EPERM (failing only on Windows) sat broken without anyone noticing. Added"test": "vitest run --dir src"topackages/vxrn/package.json, mirroring@vxrn/resolve. Now turbo picks it up viabun run testand the 33 cases run on every CI run. Verified locally on Windows host and Linux Docker — both report 33/33.Known coverage gap (out of scope for this PR)
.github/workflows/checks.ymlcurrently runsubuntu-latestonly. The three Windows path bugs that prompted this work (#640; #695 cherry-picked as commit61302c5; the seed bug here) have all been discovered post-merge by users hitting them on their machines. Awindows-latestentry on thetestsjob — or apackages/one-scoped Windows job — would catch the next one before it ships. The 19posixPathContract.test.tstests in this PR are explicitly designed to run on Windows and would lock in cross-platform parity. Recommended follow-up; not included here so the PR stays focused on the framework fixes themselves.CI status note
The current CI run shows two unrelated failures:
basic-ftp@5.3.0advisoryGHSA-rpmf-866q-6p89(transitive inpuppeteer/webdriveriotest deps). The advisory was published in the window between main's last green build (a67717450, 2026-05-06) and this PR's run (2026-05-07). Same shape as the previously-ignoredGHSA-3ppc-4f35-3m26minimatch ReDoS (existing--ignoreentry inchecks.yml). Not introduced by this PR.tests/test/tests/hooks.test.ts— Playwright timeout on link-click. Known-flaky test: maintainers have bumped its timeout twice on main (945c86d5c,07f53df75). All other 158 tests in that suite pass; PR's runtime changes are POSIX no-ops so cannot have caused this.