diff --git a/packages/one/src/cli/build.ts b/packages/one/src/cli/build.ts index 2a767c5317..0fef0ab7b7 100644 --- a/packages/one/src/cli/build.ts +++ b/packages/one/src/cli/build.ts @@ -5,7 +5,7 @@ import { resolvePath } from '@vxrn/resolve' import FSExtra from 'fs-extra' import MicroMatch from 'micromatch' import type { OutputAsset, RolldownOutput } from 'rolldown' -import { type InlineConfig, mergeConfig, build as viteBuild } from 'vite' +import { type InlineConfig, mergeConfig, normalizePath, build as viteBuild } from 'vite' import { type ClientManifestEntry, fillOptions, @@ -19,7 +19,7 @@ import { setServerGlobals } from '../server/setServerGlobals' import { getPathnameFromFilePath } from '../utils/getPathnameFromFilePath' import { getRouterRootFromOneOptions } from '../utils/getRouterRootFromOneOptions' import { isRolldown } from '../utils/isRolldown' -import { toAbsolute } from '../utils/toAbsolute' +import { toAbsolute, toAbsoluteUrl } from '../utils/toAbsolute' import { buildVercelOutputDirectory } from '../vercel/build/buildVercelOutputDirectory' import { getManifest } from '../vite/getManifest' import { loadUserOneOptions } from '../vite/loadConfig' @@ -347,7 +347,7 @@ export async function build(args: { if (middlewareBuildInfo) { for (const middleware of manifest.middlewareRoutes) { const absoluteRoot = resolve(process.cwd(), options.root) - const fullPath = join(absoluteRoot, routerRoot, middleware.file) + const fullPath = normalizePath(join(absoluteRoot, routerRoot, middleware.file)) const outChunks = middlewareBuildInfo.output.filter((x) => x.type === 'chunk') const chunk = outChunks.find((x) => x.facadeModuleId === fullPath) if (!chunk) throw new Error(`internal err finding middleware`) @@ -356,7 +356,7 @@ export async function build(args: { } // for the require Sitemap in getRoutes - globalThis['require'] = createRequire(join(import.meta.url, '..')) + globalThis['require'] = createRequire(import.meta.dirname + '/') const assets: OutputAsset[] = [] @@ -426,7 +426,10 @@ export async function build(args: { // layout files start with _layout if (file.startsWith('_layout') && id.includes(`/${routerRoot}/`)) { // contextKey format is "./_layout.tsx" or "./subdir/_layout.tsx" - const relativePath = relative(process.cwd(), id).replace(`${routerRoot}/`, '') + const relativePath = normalizePath(relative(process.cwd(), id)).replace( + `${routerRoot}/`, + '' + ) const contextKey = `./${relativePath}` layoutServerPaths.set(contextKey, output.fileName) } @@ -469,10 +472,8 @@ export async function build(args: { } // resolve the full module path for this route - const routeModulePath = join( - resolve(process.cwd(), options.root), - routerRoot, - foundRoute.file.slice(2) + const routeModulePath = normalizePath( + join(resolve(process.cwd(), options.root), routerRoot, foundRoute.file.slice(2)) ) // find the server chunk containing this route @@ -761,7 +762,7 @@ export async function build(args: { let exported try { - exported = await import(toAbsolute(serverJsPath)) + exported = await import(toAbsoluteUrl(serverJsPath)) } catch (err) { console.error(`Error importing page (original error)`, err) // err cause not showing in vite or something diff --git a/packages/one/src/cli/buildPage.ts b/packages/one/src/cli/buildPage.ts index d0402aceb9..08565c27d7 100644 --- a/packages/one/src/cli/buildPage.ts +++ b/packages/one/src/cli/buildPage.ts @@ -5,7 +5,7 @@ import { LOADER_JS_POSTFIX_UNCACHED } from '../constants' import type { LoaderProps } from '../types' import { getLoaderPath, getPreloadCSSPath, getPreloadPath } from '../utils/cleanUrl' import { isResponse } from '../utils/isResponse' -import { toAbsolute } from '../utils/toAbsolute' +import { toAbsolute, toAbsoluteUrl } from '../utils/toAbsolute' import { replaceLoader } from '../vite/replaceLoader' import type { One, RouteInfo } from '../vite/types' @@ -174,7 +174,7 @@ prefetchCSS() recordTiming('writeCSSPreload', performance.now() - t0) t0 = performance.now() - const exported = await import(toAbsolute(serverJsPath)) + const exported = await import(toAbsoluteUrl(serverJsPath)) recordTiming('importServerModule', performance.now() - t0) const loaderProps: LoaderProps = { path, params } @@ -195,7 +195,7 @@ prefetchCSS() // derive server dir from clientDir (e.g. dist/client -> dist/server) const serverDir = join(clientDir, '..', 'server') const layoutExported = await import( - toAbsolute(join(serverDir, layoutServerPath)) + toAbsoluteUrl(join(serverDir, layoutServerPath)) ) const layoutLoaderData = await layoutExported?.loader?.(loaderProps) return { contextKey: layout.contextKey, loaderData: layoutLoaderData } @@ -478,7 +478,7 @@ params:\n\n${JSON.stringify(params || null, null, 2)}` async function getRender(serverEntry: string) { try { - const serverImport = await import(serverEntry) + const serverImport = await import(toAbsoluteUrl(serverEntry)) const render = serverImport.default.render || diff --git a/packages/one/src/metro-config/getViteMetroPluginOptions.test.ts b/packages/one/src/metro-config/getViteMetroPluginOptions.test.ts new file mode 100644 index 0000000000..d96918a5ac --- /dev/null +++ b/packages/one/src/metro-config/getViteMetroPluginOptions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { normalizeReSource } from './getViteMetroPluginOptions' + +// String.raw avoids double-escaping: String.raw`[\\/]` is the 5-char string [ \ \ / ] +// which is exactly what regex.source produces for /[\\/]/ + +describe('normalizeReSource', () => { + it(String.raw`[\\/] to \/`, () => { + expect(normalizeReSource(String.raw`[\\/]`)).toBe(String.raw`\/`) + }) + + it(String.raw`[^\\/] to [^/]`, () => { + expect(normalizeReSource(String.raw`[^\\/]`)).toBe('[^/]') + }) + + it('full Windows micromatch regex', () => { + // micromatch.makeRe('**/*.web.(ts|tsx)').source on Windows (picomatch 2.x) + const windowsSource = String.raw`^(?:(?:^|[\\/]|(?:(?:(?!(?:^|[\\/])\.).)*?)[\\/])(?!\.)(?=.)[^\\/]*?\.web\.(ts|tsx))$` + const posixSource = String.raw`^(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.web\.(ts|tsx))$` + + expect(normalizeReSource(windowsSource)).toBe(posixSource) + }) + + it('no-op for POSIX regex', () => { + const posixSource = String.raw`^(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.web\.(ts|tsx))$` + + expect(normalizeReSource(posixSource)).toBe(posixSource) + }) + + it('multiple groups in one source', () => { + const source = String.raw`a[\\/]b[\\/]c[^\\/]d` + expect(normalizeReSource(source)).toBe(String.raw`a\/b\/c[^/]d`) + }) +}) diff --git a/packages/one/src/metro-config/getViteMetroPluginOptions.ts b/packages/one/src/metro-config/getViteMetroPluginOptions.ts index 1117039a5c..173b9f92d9 100644 --- a/packages/one/src/metro-config/getViteMetroPluginOptions.ts +++ b/packages/one/src/metro-config/getViteMetroPluginOptions.ts @@ -8,6 +8,14 @@ import { ROUTE_NATIVE_EXCLUSION_GLOB_PATTERNS, } from '../router/glob-patterns' +/** + * On Windows, micromatch.makeRe() produces regex patterns with `[\\/]` or `[^\\/]` + * instead of `\/` and `[^/]`. Normalize them so the startsWith check works. + */ +export function normalizeReSource(source: string): string { + return source.replace(/\[\\\\\/\]/g, '\\/').replace(/\[\^\\\\\/\]/g, '[^/]') +} + export function getViteMetroPluginOptions({ projectRoot, relativeRouterRoot, @@ -56,7 +64,7 @@ export function getViteMetroPluginOptions({ * ^(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\+api\.(ts|tsx))$ * ``` */ - const reSource = re.source + const reSource = normalizeReSource(re.source) if ( !( @@ -112,13 +120,7 @@ export function getViteMetroPluginOptions({ // .filter((i): i is NonNullable => !!i) // ), }, - nodeModulesPaths: tsconfigPathsConfigLoadResult.absoluteBaseUrl - ? [ - // "vite-tsconfig-paths" for Metro - tsconfigPathsConfigLoadResult.absoluteBaseUrl, - ...(defaultConfig?.resolver?.nodeModulesPaths || []), - ] - : defaultConfig?.resolver?.nodeModulesPaths, + nodeModulesPaths: defaultConfig?.resolver?.nodeModulesPaths, resolveRequest: (context, moduleName, platform) => { if (moduleName.endsWith('.css')) { return { @@ -153,12 +155,13 @@ export function getViteMetroPluginOptions({ const defaultResolveRequest = defaultConfig?.resolver?.resolveRequest || context.resolveRequest const res = defaultResolveRequest(context, moduleName, platform) - if (res && 'filePath' in res && res.filePath.includes('/src/index.ts')) { + const svgSrcSuffix = `${path.sep}src${path.sep}index.ts` + if (res && 'filePath' in res && res.filePath.includes(svgSrcSuffix)) { return { ...res, filePath: res.filePath.replace( - '/src/index.ts', - '/lib/commonjs/index.js' + svgSrcSuffix, + `${path.sep}lib${path.sep}commonjs${path.sep}index.js` ), } } diff --git a/packages/one/src/server/oneServe.ts b/packages/one/src/server/oneServe.ts index d65bb7fc08..0739863b40 100644 --- a/packages/one/src/server/oneServe.ts +++ b/packages/one/src/server/oneServe.ts @@ -1,7 +1,7 @@ import type { Hono, MiddlewareHandler } from 'hono' import type { BlankEnv } from 'hono/types' import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { CSS_PRELOAD_JS_POSTFIX, LOADER_JS_POSTFIX_UNCACHED, @@ -16,7 +16,7 @@ import { } from '../createHandleRequest' import type { RenderAppProps } from '../types' import { getPathFromLoaderPath } from '../utils/cleanUrl' -import { toAbsolute } from '../utils/toAbsolute' +import { toAbsoluteUrl } from '../utils/toAbsolute' import type { One } from '../vite/types' import type { RouteInfoCompiled } from './createRoutesManifest' import { setSSRLoaderData } from './ssrLoaderData' @@ -160,8 +160,8 @@ export async function oneServe( routeExported = lazyKey ? options?.lazyRoutes?.pages?.[lazyKey] ? await options.lazyRoutes.pages[lazyKey]() - : await import(toAbsolute(resolvedPath)) - : await import(toAbsolute(serverPath!)) + : await import(toAbsoluteUrl(resolvedPath)) + : await import(toAbsoluteUrl(serverPath!)) moduleImportCache.set(cacheKey, routeExported) } @@ -264,8 +264,7 @@ export async function oneServe( const entry = options?.lazyRoutes?.serverEntry ? await options.lazyRoutes.serverEntry() : await import( - resolve( - process.cwd(), + toAbsoluteUrl( `${serverOptions.root}/${outDir}/server/_virtual_one-entry.${typeof oneOptions.build?.server === 'object' && oneOptions.build.server.outputFormat === 'cjs' ? 'c' : ''}js` ) ) @@ -300,13 +299,8 @@ export async function oneServe( } // both vite and rolldown-vite replace brackets with underscores in output filenames const fileName = route.page.slice(1).replace(/\[/g, '_').replace(/\]/g, '_') - const apiFile = join( - process.cwd(), - outDir, - 'api', - fileName + (apiCJS ? '.cjs' : '.js') - ) - return await import(apiFile) + const apiFile = join(outDir, 'api', fileName + (apiCJS ? '.cjs' : '.js')) + return await import(toAbsoluteUrl(apiFile)) }, async loadMiddleware(route) { @@ -314,7 +308,7 @@ export async function oneServe( if (options?.lazyRoutes?.middlewares?.[route.contextKey]) { return await options.lazyRoutes.middlewares[route.contextKey]() } - return await import(toAbsolute(route.contextKey)) + return await import(toAbsoluteUrl(route.contextKey)) }, async handleLoader({ route, loaderProps }) { diff --git a/packages/one/src/utils/toAbsolute.ts b/packages/one/src/utils/toAbsolute.ts index 6bfb52ee17..a8e754e0ae 100644 --- a/packages/one/src/utils/toAbsolute.ts +++ b/packages/one/src/utils/toAbsolute.ts @@ -1,3 +1,8 @@ import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +/** Resolve to native filesystem path — for fs operations (readFile, writeFile, join). */ export const toAbsolute = (p: string) => resolve(process.cwd(), p) + +/** Resolve to file:// URL — for dynamic import() which requires URLs on Windows. */ +export const toAbsoluteUrl = (p: string) => pathToFileURL(resolve(process.cwd(), p)).href diff --git a/packages/one/src/utils/workerImport.ts b/packages/one/src/utils/workerImport.ts index 0bde559c32..bfdf319183 100644 --- a/packages/one/src/utils/workerImport.ts +++ b/packages/one/src/utils/workerImport.ts @@ -4,7 +4,7 @@ * * Usage: Pass the caller's import.meta.url to resolve relative paths correctly. */ -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, resolve } from 'node:path' type ModuleCache = Map @@ -33,7 +33,7 @@ export async function workerImport( const mjsPath = absolutePath.endsWith('.mjs') ? absolutePath : `${absolutePath}.mjs` // @ts-ignore - runtime needs .mjs extension for proper ESM resolution - const mod = await import(mjsPath) + const mod = await import(pathToFileURL(mjsPath).href) cache.set(cacheKey, mod) return mod } diff --git a/packages/one/src/vite/plugins/criticalCSSPlugin.test.ts b/packages/one/src/vite/plugins/criticalCSSPlugin.test.ts index 8ebc7bf624..6408e03702 100644 --- a/packages/one/src/vite/plugins/criticalCSSPlugin.test.ts +++ b/packages/one/src/vite/plugins/criticalCSSPlugin.test.ts @@ -1,5 +1,8 @@ +import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const PROJECT_ROOT = resolve('/project') + describe('criticalCSSPlugin', () => { beforeEach(() => { vi.resetModules() @@ -14,12 +17,12 @@ describe('criticalCSSPlugin', () => { const { criticalCSSPlugin } = await import('./criticalCSSPlugin') const plugin = criticalCSSPlugin() - ;(plugin.configResolved as any)({ root: '/project' }) + ;(plugin.configResolved as any)({ root: PROJECT_ROOT }) const result = await (plugin.resolveId as any).call( { resolve: vi.fn() }, './styles.css', - '/project/src/App.tsx' + resolve('/project/src/App.tsx') ) expect(result).toBeNull() }) @@ -28,12 +31,12 @@ describe('criticalCSSPlugin', () => { const { criticalCSSPlugin } = await import('./criticalCSSPlugin') const plugin = criticalCSSPlugin() - ;(plugin.configResolved as any)({ root: '/project' }) + ;(plugin.configResolved as any)({ root: PROJECT_ROOT }) const result = await (plugin.resolveId as any).call( { resolve: vi.fn() }, './data.json', - '/project/src/App.tsx' + resolve('/project/src/App.tsx') ) expect(result).toBeNull() }) @@ -42,25 +45,25 @@ describe('criticalCSSPlugin', () => { const { criticalCSSPlugin } = await import('./criticalCSSPlugin') const plugin = criticalCSSPlugin() - ;(plugin.configResolved as any)({ root: '/project' }) + ;(plugin.configResolved as any)({ root: PROJECT_ROOT }) + const resolvedCssPath = resolve('/project/src/styles.inline.css') const mockResolve = vi.fn().mockResolvedValue({ - id: '/project/src/styles.inline.css', + id: resolvedCssPath, }) + const importerPath = resolve('/project/src/App.tsx') const result = await (plugin.resolveId as any).call( { resolve: mockResolve }, './styles.inline.css', - '/project/src/App.tsx' + importerPath ) - expect(mockResolve).toHaveBeenCalledWith( - './styles.inline.css', - '/project/src/App.tsx', - { skipSelf: true } - ) + expect(mockResolve).toHaveBeenCalledWith('./styles.inline.css', importerPath, { + skipSelf: true, + }) - expect(result).toEqual({ id: '/project/src/styles.inline.css' }) + expect(result).toEqual({ id: resolvedCssPath }) }) it('should track inline CSS source paths', async () => { @@ -68,34 +71,37 @@ describe('criticalCSSPlugin', () => { await import('./criticalCSSPlugin') const plugin = criticalCSSPlugin() - ;(plugin.configResolved as any)({ root: '/project' }) + ;(plugin.configResolved as any)({ root: PROJECT_ROOT }) const mockResolve = vi.fn().mockResolvedValue({ - id: '/project/src/layout.inline.css', + id: resolve('/project/src/layout.inline.css'), }) await (plugin.resolveId as any).call( { resolve: mockResolve }, './layout.inline.css', - '/project/src/App.tsx' + resolve('/project/src/App.tsx') ) const sources = getCriticalCSSSources() - expect(sources.has('src/layout.inline.css')).toBe(true) + // relative() produces forward slashes on POSIX, backslashes on Windows + expect( + sources.has('src/layout.inline.css') || sources.has('src\\layout.inline.css') + ).toBe(true) }) it('should return null when resolve fails', async () => { const { criticalCSSPlugin } = await import('./criticalCSSPlugin') const plugin = criticalCSSPlugin() - ;(plugin.configResolved as any)({ root: '/project' }) + ;(plugin.configResolved as any)({ root: PROJECT_ROOT }) const mockResolve = vi.fn().mockResolvedValue(null) const result = await (plugin.resolveId as any).call( { resolve: mockResolve }, './nonexistent.inline.css', - '/project/src/App.tsx' + resolve('/project/src/App.tsx') ) expect(result).toBeNull() @@ -108,21 +114,25 @@ describe('criticalCSSPlugin', () => { await import('./criticalCSSPlugin') const plugin = criticalCSSPlugin() - ;(plugin.configResolved as any)({ root: '/project' }) + ;(plugin.configResolved as any)({ root: PROJECT_ROOT }) const mockResolve = vi.fn().mockResolvedValue({ - id: '/project/app/layout.inline.css', + id: resolve('/project/app/layout.inline.css'), }) await (plugin.resolveId as any).call( { resolve: mockResolve }, './layout.inline.css', - '/project/app/_layout.tsx' + resolve('/project/app/_layout.tsx') ) + // Build a manifest keyed by the relative path that criticalCSSSources actually stores + const sources = getCriticalCSSSources() + const sourceKey = [...sources].find((s) => s.includes('layout.inline.css'))! + const manifest = { - 'app/layout.inline.css': { + [sourceKey]: { file: 'assets/layout-abc123.css', - src: 'app/layout.inline.css', + src: sourceKey, }, 'app/_layout.tsx': { file: 'assets/layout-def456.js', diff --git a/packages/one/src/vite/plugins/criticalCSSPlugin.ts b/packages/one/src/vite/plugins/criticalCSSPlugin.ts index 0427ac4054..d935cb6152 100644 --- a/packages/one/src/vite/plugins/criticalCSSPlugin.ts +++ b/packages/one/src/vite/plugins/criticalCSSPlugin.ts @@ -1,4 +1,5 @@ import { relative } from 'node:path' +import { normalizePath } from 'vite' import type { Plugin } from 'vite' /** @@ -62,8 +63,8 @@ export function criticalCSSPlugin(): Plugin { const resolved = await this.resolve(id, importer, { skipSelf: true }) if (resolved) { - // store as relative path to match manifest keys - const relativePath = relative(root, resolved.id) + // store as relative path to match manifest keys (forward slashes to match Vite) + const relativePath = normalizePath(relative(root, resolved.id)) criticalCSSSources.add(relativePath) return resolved } diff --git a/packages/one/src/vite/plugins/imageDataPlugin.test.ts b/packages/one/src/vite/plugins/imageDataPlugin.test.ts index 5bcb662333..f7669bb93a 100644 --- a/packages/one/src/vite/plugins/imageDataPlugin.test.ts +++ b/packages/one/src/vite/plugins/imageDataPlugin.test.ts @@ -1,5 +1,12 @@ +import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// Platform-correct test paths — resolve() normalizes to native separators +const PROJECT_ROOT = resolve('/project') +const PUBLIC_DIR = resolve('/project/public') +const COMPONENTS_DIR = resolve('/project/src/components') +const HERO_FILE = resolve('/project/src/components/Hero.tsx') + // Mock fs before importing the plugin vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') @@ -11,6 +18,13 @@ vi.mock('node:fs', async () => { } }) +function mockConfig() { + return { + publicDir: PUBLIC_DIR, + root: PROJECT_ROOT, + } +} + describe('imageDataPlugin', () => { beforeEach(() => { vi.resetModules() @@ -25,13 +39,8 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const result = await (plugin.resolveId as any)('./image.jpg', undefined) @@ -42,13 +51,8 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } // ?imagedata in the middle should not match @@ -60,53 +64,38 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const result = await (plugin.resolveId as any)( '/test-image.jpg?imagedata', undefined ) - expect(result).toBe('\0imagedata:/project/public/test-image.jpg') + expect(result).toBe('\0imagedata:' + resolve(PUBLIC_DIR, 'test-image.jpg')) }) it('should resolve relative imports', async () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const result = await (plugin.resolveId as any)( './test-image.jpg?imagedata', - '/project/src/components/Hero.tsx' + HERO_FILE ) - expect(result).toBe('\0imagedata:/project/src/components/test-image.jpg') + expect(result).toBe('\0imagedata:' + resolve(COMPONENTS_DIR, 'test-image.jpg')) }) it('should return null for non-existent files', async () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const result = await (plugin.resolveId as any)( @@ -122,13 +111,8 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const result = await (plugin.resolveId as any)( @@ -142,18 +126,13 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const result = await (plugin.resolveId as any)( '../../../../etc/passwd?imagedata', - '/project/src/components/Hero.tsx' + HERO_FILE ) expect(result).toBeNull() }) @@ -162,13 +141,8 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const result = await (plugin.resolveId as any)( @@ -182,22 +156,17 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } // Going up and back down should still work if within bounds // From /project/src/components, ../.. goes to /project, then src/test-image.jpg const result = await (plugin.resolveId as any)( '../../src/test-image.jpg?imagedata', - '/project/src/components/Hero.tsx' + HERO_FILE ) - expect(result).toBe('\0imagedata:/project/src/test-image.jpg') + expect(result).toBe('\0imagedata:' + resolve(PROJECT_ROOT, 'src/test-image.jpg')) }) }) @@ -206,13 +175,8 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const mockContext = { @@ -227,33 +191,28 @@ describe('imageDataPlugin', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const mockContext = { addWatchFile: vi.fn(), } + const testFilePath = resolve(PUBLIC_DIR, 'test-image.jpg') + // This will fail because the file doesn't actually exist // But it should gracefully fallback const result = await (plugin.load as any).call( mockContext, - '\0imagedata:/project/public/test-image.jpg' + '\0imagedata:' + testFilePath ) expect(result).toContain('export default') expect(result).toContain('"src":"/test-image.jpg"') expect(result).toContain('"width":') expect(result).toContain('"height":') - expect(mockContext.addWatchFile).toHaveBeenCalledWith( - '/project/public/test-image.jpg' - ) + expect(mockContext.addWatchFile).toHaveBeenCalledWith(testFilePath) }) }) @@ -279,22 +238,19 @@ describe('imageDataPlugin output format', () => { const { imageDataPlugin } = await import('./imageDataPlugin') const plugin = imageDataPlugin() - const mockConfig = { - publicDir: '/project/public', - root: '/project', - } - if (plugin.configResolved) { - ;(plugin.configResolved as any)(mockConfig) + ;(plugin.configResolved as any)(mockConfig()) } const mockContext = { addWatchFile: vi.fn(), } + const testFilePath = resolve(PUBLIC_DIR, 'test-image.jpg') + const result = await (plugin.load as any).call( mockContext, - '\0imagedata:/project/public/test-image.jpg' + '\0imagedata:' + testFilePath ) // Extract JSON from the export diff --git a/packages/one/src/vite/plugins/imageDataPlugin.ts b/packages/one/src/vite/plugins/imageDataPlugin.ts index 5241748a45..82ce2a9d85 100644 --- a/packages/one/src/vite/plugins/imageDataPlugin.ts +++ b/packages/one/src/vite/plugins/imageDataPlugin.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs' -import { dirname, relative, resolve } from 'node:path' +import { dirname, relative, resolve, sep } from 'node:path' import type { Plugin, ResolvedConfig } from 'vite' +import { normalizePath } from 'vite' import { processImageMeta } from '../../image/getImageData' const IMAGEDATA_SUFFIX = '?imagedata' @@ -12,14 +13,14 @@ export function imageDataPlugin(): Plugin { function getSrcPath(filePath: string): string { return filePath.startsWith(publicDir) - ? '/' + relative(publicDir, filePath) - : '/' + relative(root, filePath) + ? '/' + normalizePath(relative(publicDir, filePath)) + : '/' + normalizePath(relative(root, filePath)) } function isPathWithinBounds(filePath: string, allowedDir: string): boolean { const resolved = resolve(filePath) const allowed = resolve(allowedDir) - return resolved.startsWith(allowed + '/') || resolved === allowed + return resolved.startsWith(allowed + sep) || resolved === allowed } function createImageDataExport(src: string, width = 0, height = 0, blurDataURL = '') { diff --git a/packages/one/types/metro-config/getViteMetroPluginOptions.d.ts b/packages/one/types/metro-config/getViteMetroPluginOptions.d.ts index 8049d42cac..ac21ec19f3 100644 --- a/packages/one/types/metro-config/getViteMetroPluginOptions.d.ts +++ b/packages/one/types/metro-config/getViteMetroPluginOptions.d.ts @@ -1,4 +1,9 @@ import type { metroPlugin } from '@vxrn/vite-plugin-metro'; +/** + * On Windows, micromatch.makeRe() produces regex patterns with `[\\/]` or `[^\\/]` + * instead of `\/` and `[^/]`. Normalize them so the startsWith check works. + */ +export declare function normalizeReSource(source: string): string; export declare function getViteMetroPluginOptions({ projectRoot, relativeRouterRoot, ignoredRouteFiles, userDefaultConfigOverrides, setupFile, }: { projectRoot: string; relativeRouterRoot: string; diff --git a/packages/one/types/metro-config/getViteMetroPluginOptions.test.d.ts b/packages/one/types/metro-config/getViteMetroPluginOptions.test.d.ts new file mode 100644 index 0000000000..85fb2c96b2 --- /dev/null +++ b/packages/one/types/metro-config/getViteMetroPluginOptions.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=getViteMetroPluginOptions.test.d.ts.map \ No newline at end of file diff --git a/packages/one/types/utils/toAbsolute.d.ts b/packages/one/types/utils/toAbsolute.d.ts index 2abcbbf84e..889cf041f8 100644 --- a/packages/one/types/utils/toAbsolute.d.ts +++ b/packages/one/types/utils/toAbsolute.d.ts @@ -1,2 +1,5 @@ +/** Resolve to native filesystem path — for fs operations (readFile, writeFile, join). */ export declare const toAbsolute: (p: string) => string; +/** Resolve to file:// URL — for dynamic import() which requires URLs on Windows. */ +export declare const toAbsoluteUrl: (p: string) => string; //# sourceMappingURL=toAbsolute.d.ts.map \ No newline at end of file diff --git a/packages/resolve/src/index.tsx b/packages/resolve/src/index.tsx index e3a01d29d1..8ea28b659d 100644 --- a/packages/resolve/src/index.tsx +++ b/packages/resolve/src/index.tsx @@ -1,13 +1,24 @@ import module from 'node:module' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' /** * Resolves a module path from the specified directory. * For npm packages, returns the ESM entry point when available. */ export const resolvePath = (path: string, from = process.cwd()): string => { - const require = module.createRequire(from.endsWith('/') ? from : from + '/') + if (process.platform === 'win32' && from.startsWith('/')) { + try { + from = fileURLToPath('file://' + from) + } catch { + from = from.slice(1) + } + } + from = resolve(from) + const require = module.createRequire( + from.endsWith('/') || from.endsWith('\\') ? from : from + '/' + ) const resolved = require.resolve(path, { paths: [from] }) // For relative paths or node: builtins, just return the resolved path diff --git a/packages/vite-plugin-metro/src/metro-config/getMetroConfigFromViteConfig.ts b/packages/vite-plugin-metro/src/metro-config/getMetroConfigFromViteConfig.ts index 0f0754d13c..2d91ac7f7f 100644 --- a/packages/vite-plugin-metro/src/metro-config/getMetroConfigFromViteConfig.ts +++ b/packages/vite-plugin-metro/src/metro-config/getMetroConfigFromViteConfig.ts @@ -1,4 +1,5 @@ import type { ResolvedConfig } from 'vite' +import { resolve } from 'node:path' import micromatch from 'micromatch' // For Metro and Expo, we only import types here. @@ -23,7 +24,7 @@ export async function getMetroConfigFromViteConfig( // prefer argv.projectRoot (user override) over config.root (vite resolved) // this is needed for monorepo setups where config.root may resolve to // the monorepo root instead of the app subdirectory - const projectRoot = metroPluginOptions.argv?.projectRoot ?? config.root + const projectRoot = resolve(metroPluginOptions.argv?.projectRoot ?? config.root) const { mainModuleName, argv, defaultConfigOverrides, watchman, excludeModules } = metroPluginOptions diff --git a/packages/vite-plugin-metro/src/plugins/metroPlugin.ts b/packages/vite-plugin-metro/src/plugins/metroPlugin.ts index 0a36972aee..072f7c62a4 100644 --- a/packages/vite-plugin-metro/src/plugins/metroPlugin.ts +++ b/packages/vite-plugin-metro/src/plugins/metroPlugin.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' import type { PluginOption } from 'vite' import launchEditor from 'launch-editor' import { createDebugger } from '@vxrn/debug' @@ -84,7 +85,7 @@ export function metroPlugin(options: MetroPluginOptions = {}): PluginOption { // }, configureServer(server) { - const { root: projectRoot } = server.config + const projectRoot = resolve(server.config.root) let metroReady = false diff --git a/packages/vite-plugin-metro/src/utils/projectImport.ts b/packages/vite-plugin-metro/src/utils/projectImport.ts index e09a4b7a2a..f0cd8573a1 100644 --- a/packages/vite-plugin-metro/src/utils/projectImport.ts +++ b/packages/vite-plugin-metro/src/utils/projectImport.ts @@ -1,5 +1,6 @@ import { createDebugger } from '@vxrn/debug' import module from 'node:module' +import { pathToFileURL } from 'node:url' export const { debug } = createDebugger('vite-metro:projectImport') @@ -18,7 +19,7 @@ export async function projectImport( debug?.(`Importing "${path}" from project root: "${projectRoot}" at "${importPath}"`) - const out = await import(importPath) + const out = await import(pathToFileURL(importPath).href) // somewhat hacky fix but for some reason in new takeout repo its double-wrapping default export if (out?.default?.default) { diff --git a/packages/vxrn/src/config/getBaseViteConfigOnly.ts b/packages/vxrn/src/config/getBaseViteConfigOnly.ts index f3422c067c..b6b526807c 100644 --- a/packages/vxrn/src/config/getBaseViteConfigOnly.ts +++ b/packages/vxrn/src/config/getBaseViteConfigOnly.ts @@ -98,10 +98,7 @@ export async function getBaseViteConfig( // have these native-only subpaths { find: /^react-native\/Libraries\/.*/, - replacement: resolvePath( - '@vxrn/vite-plugin-metro/empty', - new URL('.', import.meta.url).pathname - ), + replacement: resolvePath('@vxrn/vite-plugin-metro/empty', import.meta.dirname), }, { find: 'react-native/package.json', diff --git a/packages/vxrn/src/exports/prebuild.ts b/packages/vxrn/src/exports/prebuild.ts index 7466204096..ed977d73fa 100644 --- a/packages/vxrn/src/exports/prebuild.ts +++ b/packages/vxrn/src/exports/prebuild.ts @@ -2,6 +2,7 @@ import { resolvePath } from '@vxrn/resolve' import { detectPackageManager, type PackageManagerName } from '@vxrn/utils' import FSExtra from 'fs-extra' import path from 'node:path' +import { pathToFileURL } from 'node:url' import colors from 'picocolors' import { fillOptions } from '../config/getOptionsFilled' import { applyBuiltInPatches } from '../utils/patches' @@ -29,7 +30,8 @@ export const prebuild = async ({ try { // Import Expo from the user's project instead of from where vxrn is installed, since vxrn may be installed globally or at the root workspace. const importPath = resolvePath('@expo/cli/build/src/prebuild/index.js', root) - const expoPrebuild = (await import(importPath)).default.expoPrebuild + const expoPrebuild = (await import(pathToFileURL(importPath).href)).default + .expoPrebuild await expoPrebuild([ ...(platform ? ['--platform', platform] : []), ...(noInstall ? ['--no-install'] : []), diff --git a/packages/vxrn/src/exports/prebuildWithoutExpo.ts b/packages/vxrn/src/exports/prebuildWithoutExpo.ts index 96400619dd..d74866b15b 100644 --- a/packages/vxrn/src/exports/prebuildWithoutExpo.ts +++ b/packages/vxrn/src/exports/prebuildWithoutExpo.ts @@ -1,5 +1,6 @@ import module from 'node:module' import path from 'node:path' +import { pathToFileURL } from 'node:url' import FSExtra from 'fs-extra' /* @@ -27,7 +28,7 @@ export const generateForPlatform = async ( ), platform ) - const walk = (await import(importPath)).default.default + const walk = (await import(pathToFileURL(importPath).href)).default.default walk(src).forEach((absoluteSrc: string) => { const relativeFilePath = transformPath(path.relative(src, absoluteSrc)) .replace(/HelloWorld/g, appName) diff --git a/packages/vxrn/src/plugins/expoManifestRequestHandlerPlugin.ts b/packages/vxrn/src/plugins/expoManifestRequestHandlerPlugin.ts index 5166a314bc..d0bd53f9a2 100644 --- a/packages/vxrn/src/plugins/expoManifestRequestHandlerPlugin.ts +++ b/packages/vxrn/src/plugins/expoManifestRequestHandlerPlugin.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import module from 'node:module' import { TLSSocket } from 'node:tls' +import { pathToFileURL } from 'node:url' import type { Plugin } from 'vite' import colors from 'picocolors' @@ -48,8 +49,8 @@ export function expoManifestRequestHandlerPlugin( '@expo/cli/build/src/start/server/middleware/ExpoGoManifestHandlerMiddleware.js', { paths: [projectRoot] } ) - ExpoGoManifestHandlerMiddleware = (await import(importPath)).default - .ExpoGoManifestHandlerMiddleware + ExpoGoManifestHandlerMiddleware = (await import(pathToFileURL(importPath).href)) + .default.ExpoGoManifestHandlerMiddleware } catch (e) { expoGoManifestHandlerMiddlewareImportError = e } diff --git a/packages/vxrn/src/user-interface/index.ts b/packages/vxrn/src/user-interface/index.ts index d6823401bf..a946522dfc 100644 --- a/packages/vxrn/src/user-interface/index.ts +++ b/packages/vxrn/src/user-interface/index.ts @@ -1,5 +1,6 @@ import { exec } from 'node:child_process' import module from 'node:module' +import { pathToFileURL } from 'node:url' import type { ViteDevServer } from 'vite' import { filterViteServerResolvedUrls } from '../utils/filterViteServerResolvedUrls' @@ -313,7 +314,9 @@ async function openIos(ctx: Context) { paths: [projectRoot], } ) - const applePlatformManagerModule = await import(applePlatformManagerModuleImportPath) + const applePlatformManagerModule = await import( + pathToFileURL(applePlatformManagerModuleImportPath).href + ) const PlatformManager = applePlatformManagerModule.default.ApplePlatformManager // TODO: Support dev client @@ -347,7 +350,7 @@ async function openAndroid(ctx: Context) { } ) const androidPlatformManagerModule = await import( - androidPlatformManagerModuleImportPath + pathToFileURL(androidPlatformManagerModuleImportPath).href ) const PlatformManager = androidPlatformManagerModule.default.AndroidPlatformManager diff --git a/packages/vxrn/src/utils/createNativeDevEngine.ts b/packages/vxrn/src/utils/createNativeDevEngine.ts index caaae2d517..9f9bd3b04a 100644 --- a/packages/vxrn/src/utils/createNativeDevEngine.ts +++ b/packages/vxrn/src/utils/createNativeDevEngine.ts @@ -828,7 +828,8 @@ function assetPlugin(opts: { root: string; platform: string }): Plugin { const name = basename(id, `.${ext}`) const dir = dirname(id) const relativePath = relative(opts.root, id) - const httpLocation = '/assets/' + dirname(relativePath) + // On Windows, change backslashes to slashes to get proper URL path from file path. + const httpLocation = '/assets/' + dirname(relativePath).replace(/\\/g, '/') // simple asset registration (TODO: scale detection like rollipop) const assetData = { diff --git a/packages/vxrn/src/utils/expoRun.ts b/packages/vxrn/src/utils/expoRun.ts index f997df7edf..452a0ba1d4 100644 --- a/packages/vxrn/src/utils/expoRun.ts +++ b/packages/vxrn/src/utils/expoRun.ts @@ -1,4 +1,5 @@ import module from 'node:module' +import { pathToFileURL } from 'node:url' import { fillOptions } from '../config/getOptionsFilled' import { applyBuiltInPatches } from '../utils/patches' @@ -23,7 +24,7 @@ export async function expoRun({ const importPath = require.resolve(`@expo/cli/build/src/run/${platform}/index.js`, { paths: [root], }) - const expoRun = (await import(importPath)).default[ + const expoRun = (await import(pathToFileURL(importPath).href)).default[ `expoRun${platform.charAt(0).toUpperCase() + platform.slice(1)}` ] await expoRun([ diff --git a/packages/vxrn/src/utils/isWithin.ts b/packages/vxrn/src/utils/isWithin.ts index 1279e0cbcd..47186faf59 100644 --- a/packages/vxrn/src/utils/isWithin.ts +++ b/packages/vxrn/src/utils/isWithin.ts @@ -1,6 +1,6 @@ -import { relative } from 'node:path' +import { isAbsolute, relative } from 'node:path' export function isWithin(outer: string, inner: string) { const rel = relative(outer, inner) - return !rel.startsWith('../') && rel !== '..' + return !rel.startsWith('..') && !isAbsolute(rel) }