Skip to content

fix(one,vxrn,compiler,vite-plugin-metro): Windows path bugs across build/server/dev/native paths#702

Open
YevheniiKotyrlo wants to merge 2 commits intoonejs:mainfrom
YevheniiKotyrlo:fix/windows-posix-comprehensive
Open

fix(one,vxrn,compiler,vite-plugin-metro): Windows path bugs across build/server/dev/native paths#702
YevheniiKotyrlo wants to merge 2 commits intoonejs:mainfrom
YevheniiKotyrlo:fix/windows-posix-comprehensive

Conversation

@YevheniiKotyrlo
Copy link
Copy Markdown
Contributor

@YevheniiKotyrlo YevheniiKotyrlo commented May 7, 2026

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 commit 61302c5 (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

one build && one serve     # Windows 11, Bun 1.3.13, Node 25
curl http://localhost:3000/time

Server log:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  'C:\work\…\dist\server\dist\server\assets\time_ssr-COqAsxju.js'
imported from 'C:\work\…\node_modules\one\dist\esm\server\oneServe.mjs'

build.ts mints serverJsPath via path.join (native sep). It flows through the build manifest into oneServe.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 fails await import().

The same value is also the input to a regex in build.ts (replace(new RegExp('^${outDir}/'), '')) and a literal-string replace in vercel/build/generate/createSsrServerlessFunction.ts — both also assume forward-slash.

Producer-side fix

build.ts and oneServe.ts mint manifest/dynamic-import paths with path.posix.join so the value is forward-slash on every platform:

Site Mint
build.ts builtMiddlewares entry posix.join(outDir, 'middlewares', chunk.fileName)
build.ts serverJsPath posix.join(outDir, 'server', serverFileName)
oneServe.ts apiFile posix.join(outDir, 'api', fileName + ext)

The downstream regex in build.ts and createSsrServerlessFunction.ts start matching on Windows for free.

Consumer-side helper

The other two oneServe.ts call sites that re-prepend the prefix collapsed into one helper:

// packages/one/src/utils/toServerOutputPath.ts
export function toServerOutputPath(input: string, outDir: string): string {
  const normalized = input.replace(/\\/g, '/')
  const prefix = `${outDir}/server/`
  if (normalized === `${outDir}/server` || normalized.startsWith(prefix)) {
    return normalized
  }
  return posix.join(outDir, 'server', normalized)
}
  • posix.join (not Vite's normalizePath): backslashes are valid filename characters on POSIX, so normalizePath is a no-op there. We need conversion on every platform.
  • startsWith (not .includes): avoids false-positive on foo/dist/server/bar.js.
  • Backslash conversion: defensive boundary against legacy path.join-shaped callers.

7 unit tests cover idempotency, custom outDir, the false-positive guard, and the path-doubling regression. New posixPathContract.test.ts adds 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 across packages/{one,vxrn,vite-plugin-metro,compiler,resolve}. Eleven more producer-side sites in the same class.

High-impact

  1. virtualEntryPlugin.ts setupFile importJSON.stringify(resolve(root, setupFile)) would emit import "C:\\proj\\src\\setup.ts"; switched to pathToFileURL().href for canonical file:// URL. Mirrors the native sibling in createNativeDevEngine.ts. Vite-native repro previously blocked on Windows by an MSVC layout bug in fast-flow-transform's vendored llvh simple_ilist (the Hermes-backed Flow stripper that vxrn invokes during native bundling); the fix is in flight at facebook/hermes#2012 (upstream architectural change) and ships immediately via jbroma/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.
  2. fileSystemRouterPlugin.tsx optimizeDeps.entriespath.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 to path.posix.join and dropped the hardcoded './app' for routerRoot (honors router.root / ONE_ROUTER_ROOT overrides).
  3. createNativeDevEngine.ts resolvedId + setupFile — same pair as virtualEntryPlugin.ts for the native bundler: normalizePath() for the rolldown id; pathToFileURL().href for the setupFile import specifier.
  4. vxrn/exports/dev.ts chokidar 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.)
  5. getVitePath.ts react/jsx-dev-runtime sentinelrealpath returns native on Windows, sentinel endsWith('/react/jsx-dev-runtime.js') would never match. Function is currently exported but has no in-tree caller; fix is preventive for external consumers.
  6. expoManifestRequestHandlerPlugin.ts projectRoot — wraps server.config.root in path.resolve() for native separators. Mirrors the same fix in metroPlugin.ts (commit 61302c5); this sibling boundary was missed.

High-impact (cont.)

  1. vite/one.ts SSR symlink-dedup — compares resolved.id (POSIX, from this.resolve) against realpathSync(nmPkgDir) (native on Windows, from Node fs). The startsWith check 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 and normalizePath(nmPkgDir) on the rebuild side. Only active when ssr.dedupeSymlinkedModules: true, which is opt-in.

Latent (no current functional break, fix is forward-compatibility)

  1. buildPage.ts clientJsPath — minted via join(clientDir, ...) (native sep on Windows). Stored in dist/buildInfo.json as dist\\client\\assets\\foo.js on Windows. Currently only consumed by readFile(clientJsPath, ...) (accepts both separators), so no runtime break — but cross-platform manifest hygiene matches serverJsPath. Fix: normalizePath(join(clientDir, ...)).
  2. getViteMetroPluginOptions.ts ONE_ROUTER_APP_ROOT_RELATIVE_TO_ENTRY + ONE_SETUP_FILE_NATIVEnormalizePath(path.relative(...)) for both. Consumed by require.context() (former) and as a literal import "..." specifier (latter). Metro's isRelativeImport regex (/^[.][.]?(?:[/]|$)/) rejects ..\foo on Windows for the latter; the former is absorbed by Metro's internal path.join so the fix there is hygiene to keep the babel-emitted AST byte-identical across hosts.
  3. build.ts chunkFileNames / assetFileNamesposix.join for rolldown output names. Latent for flat chunks (Path.dirname returns .); diverges only when an API chunk is nested.
  4. build.ts wranglerInputConfig.mainnormalizePath wrap. relative() returns the bare filename _worker-src.js for the current source layout, so the normalize call is a no-op today; defensive against future structure changes.
  5. compiler/transformSWC.ts ignoreId — regex used native path.sep while Vite hands the plugin transform a POSIX id on 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 + normalize id at 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 — wrap path.join output of resolveEditorFilePath in normalizePath, matching sibling getSourceInspectorPath. Production behavior unchanged.
  • packages/resolve/src/index.test.ts — two assertions hardcoded /; replaced with path.join (resolvePath returns native paths).
  • packages/vxrn/src/utils/patches.test.tssymlink(...,'dir') requires Administrator on Windows. Switched to 'junction'; type is 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: stale FSExtra.pathExists mocks (source uses node:fs.existsSync), and two tests calling config() with zero args while source destructures { command } from arg 2. Fixed via vi.mock('node:fs', factory) and a SERVE_HOOK_OPTS constant. 7/7 pass post-fix.

Validation (Windows 11, Bun 1.3.13, Node 25; Linux Docker oven/bun:1.3.13 for cross-platform parity)

Gate Windows Linux Docker (oven/bun:1.3.13)
bun run typecheck (turbo, all 36 packages) clean clean
bun run lint (oxlint, 1383 files) 0 warnings, 0 errors identical
bunx turbo run test --filter='./packages/*' 3/3 packages pass identical
packages/one 421 / 477 pass, 56 skipped (pre-existing fork-test placeholders) identical
packages/resolve 9 / 9 pass identical
packages/vxrn (newly wired into CI by this PR) 33 / 33 pass (broken pre-fix on every platform — see Test-only fixes below) identical

End-to-end (Windows host):

Endpoint Vanilla 1.16.2 Patched
/time SSR loader loaderData = undefined, ERR_MODULE_NOT_FOUND for dist\server\dist\server\… loaderData:{time:"…"} in server context, no log errors
/dashboard page + layout loader both undefined both populated
SSG /, /about, SPA /spa, API /api/healthcheck already worked still work

Web HMR (edited app/index.tsx, change appears in next request) and native HMR (Android 16 emulator, change visible on screen ~25s after edit, captured via adb screencap) both verified on Windows host with native.bundler: 'metro'.

Test plan

  • SSR +ssr page + layout loaders return real data on Windows
  • SSG / SPA / API still work on Windows
  • Native Metro bundler builds + APK + native HMR on emulator
  • lint / typecheck / vitest clean on Windows AND Linux Docker
  • Producer-side fix flows correctly through manifest into oneServe consumers
  • Vercel deploy from Windows — not exercised. The ^${outDir}/ regex in build.ts and the ${outDir}/ literal in createSsrServerlessFunction.ts will match correctly because the producer is now forward-slash, but the deploy was not run.
  • Cloudflare deploy from Windows — not exercised. relative() returns the bare filename for the current source layout, so the normalizePath call is a no-op today.
  • Vite-native bundler (native.bundler: 'vite') — was blocked on Windows by an MSVC EBO layout bug in fast-flow-transform's vendored llvh simple_ilist that surfaced as a phantom "null SMLoc" panic + downstream STATUS_ACCESS_VIOLATION. Root-caused and fixed at facebook/hermes#2012 (upstream __declspec(empty_bases) on simple_ilist) and jbroma/fast-flow-transform#18 (build-time stop-gap). With the rebuilt fft napi binding plugged into node_modules/, curl http://localhost:8081/index.bundle?platform=android&dev=true returns 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_smloc graceful fallback) is no longer needed once feat: add X-React-Native-Project-Root header 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/vxrn into CI

Before this PR, packages/vxrn had no test script — its 33 vitest cases never ran in CI, which is how the broken serverExtensions.test.ts mocks (failing on every platform) and the patches.test.ts Windows EPERM (failing only on Windows) sat broken without anyone noticing. Added "test": "vitest run --dir src" to packages/vxrn/package.json, mirroring @vxrn/resolve. Now turbo picks it up via bun run test and 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.yml currently runs ubuntu-latest only. The three Windows path bugs that prompted this work (#640; #695 cherry-picked as commit 61302c5; the seed bug here) have all been discovered post-merge by users hitting them on their machines. A windows-latest entry on the tests job — or a packages/one-scoped Windows job — would catch the next one before it ships. The 19 posixPathContract.test.ts tests 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:

  1. Security Auditbasic-ftp@5.3.0 advisory GHSA-rpmf-866q-6p89 (transitive in puppeteer/webdriverio test 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-ignored GHSA-3ppc-4f35-3m26 minimatch ReDoS (existing --ignore entry in checks.yml). Not introduced by this PR.
  2. 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.

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.
@YevheniiKotyrlo
Copy link
Copy Markdown
Contributor Author

CI status update

The latest run (25516120128, HEAD c2d877e68) confirms the analysis in the PR body's CI status note:

Job First run (e0cad282b) Current run (c2d877e68)
tests ❌ fail — tests/test/tests/hooks.test.ts Playwright link-click timeout pass
checks ❌ fail — bun audit basic-ftp <=5.3.0 (GHSA-rpmf-866q-6p89) ❌ fail — same advisory

The two failures are independent of this PR's changes:

1. tests/hooks.test.ts was a flake — passes cleanly on the second run with no logic change. The maintainers have hit this before (commits 945c86d5c and 07f53df75, both titled "increase timeout to reduce hooks test flakiness"). My PR's runtime changes are POSIX no-ops on the Linux runner so they cannot have caused this.

2. basic-ftp advisory is a transitive in test depspuppeteer (via tests/test-id-remount) and webdriverio (via tests/hmr), both pre-existing in bun.lock at 5.3.0. The advisory was published in the window between main's last green build (a67717450, 2026-05-06 18:06 UTC) and this PR's first run (2026-05-07 18:05 UTC), so it would also fail on main if main were re-run today.

Two ways to clear the audit gate (both maintainer-side, neither in this PR's scope):

  • Bump basic-ftp past 5.3.0 — transitive, needs puppeteer/webdriverio to update their get-uri dep
  • Add --ignore GHSA-rpmf-866q-6p89 to the bun audit step in .github/workflows/checks.yml, mirroring the existing --ignore GHSA-3ppc-4f35-3m26 (minimatch ReDoS) precedent

Happy to follow up either with a separate PR (advisory --ignore is a one-liner) or to leave it for the maintainers if a basic-ftp upgrade is preferred.

@YevheniiKotyrlo
Copy link
Copy Markdown
Contributor Author

Quick test-suite evidence — running bun run test:one on Windows 11 (Bun 1.3.13, Node 25):

On main:

Test Files  1 failed | 30 passed (31)
     Tests  1 failed | 396 passed | 56 skipped (453)

FAIL  packages/one/src/vite/plugins/sourceInspectorPlugin.test.ts
   > sourceInspectorPlugin helpers > resolveEditorFilePath
   > resolves project-relative source paths against cwd
AssertionError: expected '/packages/one/src/App.tsx' to be '/repo/packages/one/src/App.tsx'

The failing test is resolveEditorFilePath — added by natew in 8eb568eb2 (April 2026). On Windows the function's path.join(cwd, filePath) returns \repo\packages\one\src\App.tsx; the mock fileExists only matches the POSIX form, so it returns filePath unchanged. The defect is in resolveEditorFilePath itself — same defect class as the other path-drift sites this PR fixes.

On this PR's branch (fix/windows-posix-comprehensive):

Test Files  32 passed (32)
     Tests  421 passed | 56 skipped (477)

Zero failures. The normalizePath(path.join(cwd, filePath)) change at packages/one/src/vite/plugins/sourceInspectorPlugin.ts (commit e0cad282b) is exactly what makes the existing test pass on Windows. So this PR doesn't just fix new code — it makes one of natew's existing tests actually pass on Windows for the first time.

(Run command: bun run test:one. Same git tree on both branches. Test count differs because main is slightly behind on tests added in commits this branch is based on.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant