UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

348 lines (321 loc) 12.9 kB
import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { configuration } from '@vxrn/compiler' import micromatch from 'micromatch' import type { Plugin } from 'vite' import { isNativeEnvironment } from 'vxrn' import { API_ROUTE_GLOB_PATTERN, ROUTE_GLOB_PATTERN, ROUTE_NATIVE_EXCLUSION_GLOB_PATTERNS, ROUTE_WEB_EXCLUSION_GLOB_PATTERNS, } from '../../router/glob-patterns' import { matchDirectoryRenderMode, matchFileRenderMode } from '../../router/matchers' import type { RouteIndex } from '../../utils/routeIndex' import type { One } from '../types' import { resolvedVirtualEntryId, resolvedVirtualEntryIdNative, virtualEntryId, virtualEntryIdNative, } from './virtualEntryConstants' // rust-side hook filters, derived from the ids so they can't drift const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const VIRTUAL_ENTRY_SOURCE_RE = new RegExp( `^(?:${escapeRe(virtualEntryId)}|${escapeRe(virtualEntryIdNative)})$` ) const VIRTUAL_ENTRY_RESOLVED_RE = new RegExp( `^(?:${escapeRe(resolvedVirtualEntryId)}|${escapeRe(resolvedVirtualEntryIdNative)})$` ) type NormalizedSetupFiles = { client?: string server?: string ios?: string android?: string } function normalizeSetupFile( setupFile: One.PluginOptions['setupFile'] ): NormalizedSetupFiles { if (!setupFile) return {} if (typeof setupFile === 'string') { return { client: setupFile, server: setupFile, ios: setupFile, android: setupFile, } } if ('native' in setupFile) { return { client: setupFile.client, server: setupFile.server, ios: setupFile.native, android: setupFile.native, } } const sf = setupFile as { client?: string server?: string ios?: string android?: string } return { client: sf.client, server: sf.server, ios: sf.ios, android: sf.android, } } type SetupImportResult = { /** Import statement to prepend (for native static imports) */ importStatement: string /** Variable declaration for setup promise (for web dynamic imports) */ promiseDeclaration: string /** The variable name to pass to createApp, or empty if no setup */ promiseVarName: string } function getSetupFileImport( environmentName: string, setupFiles: NormalizedSetupFiles, useStaticImport: boolean, root?: string ): SetupImportResult { const envMap: Record<string, keyof NormalizedSetupFiles> = { client: 'client', ssr: 'server', ios: 'ios', android: 'android', } const key = envMap[environmentName] if (!key) return { importStatement: '', promiseDeclaration: '', promiseVarName: '' } const setupFile = setupFiles[key] if (!setupFile) return { importStatement: '', promiseDeclaration: '', promiseVarName: '' } // file:// URL is canonical; bare Windows absolute path has backslashes (mirrors createNativeDevEngine.ts) const resolvedSetupFile = root ? pathToFileURL(resolve(root, setupFile)).href : setupFile // For native, use static import since dynamic import doesn't work if (useStaticImport) { return { importStatement: `import ${JSON.stringify(resolvedSetupFile)}`, promiseDeclaration: '', promiseVarName: '', } } // For web, use a lazy function that returns the dynamic import promise. // This ensures the import only executes at runtime when createApp calls it, // not during build when the module is evaluated. return { importStatement: '', promiseDeclaration: `const __oneGetSetupPromise = () => import(${JSON.stringify(resolvedSetupFile)})`, promiseVarName: '__oneGetSetupPromise', } } export function createVirtualEntry(options: { root: string router?: One.PluginOptions['router'] flags: One.Flags setupFile?: One.PluginOptions['setupFile'] routeIndex: RouteIndex }): Plugin { const routeGlobs = [ `/${options.root}/${ROUTE_GLOB_PATTERN}`, ...(options.router?.ignoredRouteFiles?.map( (pattern) => `!/${options.root}/${pattern}` ) || []), ] const apiRouteGlobs = `/${options.root}/${API_ROUTE_GLOB_PATTERN}` const setupFiles = normalizeSetupFile(options.setupFile) let viteRoot = '' let isDevMode = false return { name: 'one-virtual-entry', enforce: 'pre', configResolved(config) { viteRoot = config.root isDevMode = config.command === 'serve' }, // only the two virtual entry ids reach js; every other import in the graph // is rejected rust-side resolveId: { filter: { id: VIRTUAL_ENTRY_SOURCE_RE }, handler(id) { if (id === virtualEntryId) { return resolvedVirtualEntryId } if (id === virtualEntryIdNative) { return resolvedVirtualEntryIdNative } }, }, load: { filter: { id: VIRTUAL_ENTRY_RESOLVED_RE }, handler(id) { if (id === resolvedVirtualEntryId) { const isNative = isNativeEnvironment(this.environment) const isSSR = this.environment.name === 'ssr' const serverSpaRouteFiles = isSSR ? options.routeIndex .getPaths() .map((file) => file.replace(/^\.\//, '')) .filter((file) => /\.tsx?$/.test(file)) .filter( (file) => !micromatch.isMatch(file, ROUTE_WEB_EXCLUSION_GLOB_PATTERNS) ) .filter((file) => { const parts = file.split('/') const filename = parts.at(-1) || '' if ( filename.startsWith('_layout') || filename.startsWith('_middleware') ) { return false } const fileMode = matchFileRenderMode(filename) if (fileMode) return fileMode === 'spa' let parentMode: One.RouteRenderMode | 'api' | undefined for (const directory of parts.slice(0, -1)) { parentMode = matchDirectoryRenderMode(directory)?.renderMode ?? parentMode } return parentMode === 'spa' }) : [] const serverSpaRouteStubs = serverSpaRouteFiles .map( (file) => `${JSON.stringify(`/${options.root}/${file}`)}: () => Promise.resolve({})` ) .join(',\n ') const serverSpaBuildGlobs = serverSpaRouteFiles.map( (file) => `/${options.root}/${file}` ) const serverSpaBuildRoutes = serverSpaBuildGlobs.length ? `{ loader: import.meta.glob(${JSON.stringify(serverSpaBuildGlobs)}, { exhaustive: true, import: 'loader', query: '?one-spa-build' }), generateStaticParams: import.meta.glob(${JSON.stringify(serverSpaBuildGlobs)}, { exhaustive: true, import: 'generateStaticParams', query: '?one-spa-build' }), sitemap: import.meta.glob(${JSON.stringify(serverSpaBuildGlobs)}, { exhaustive: true, import: 'sitemap', query: '?one-spa-build' }) }` : '{}' const webRouteGlobs = [ ...routeGlobs, ...ROUTE_WEB_EXCLUSION_GLOB_PATTERNS.map((pattern) => `!${pattern}`), ...serverSpaRouteFiles.map((file) => `!/${options.root}/${file}`), ] // native always needs static import. SSR in dev mode uses static import // so Vite crawls the setupFile's dep tree (including react) and pre-bundles // them before the first request. SSR in build mode uses lazy import so the // setupFile doesn't execute during the build phase. const useStaticImport = isNative || (isSSR && isDevMode) const setupResult = getSetupFileImport( this.environment.name, setupFiles, useStaticImport, viteRoot ) // When nativewind is enabled, import the components module to register Text, View, etc. with cssInterop const nativewindImport = configuration.enableNativewind ? `import 'react-native-css-interop/dist/runtime/components'` : '' // For web/SSR, pass getSetupPromise to createApp so it can call it at runtime const setupPromiseArg = setupResult.promiseVarName ? `getSetupPromise: ${setupResult.promiseVarName},` : '' const linkingArg = options.router?.linking ? `linking: ${JSON.stringify(options.router.linking)},` : '' // the react-refresh preamble has to be installed before the first // compiler-wrapped module evaluates, or that module throws "React refresh // preamble was not loaded" and client init aborts. /@one/dev.js installs // it, but it cannot be relied on to win: it is a DEFERRED module script, // so it waits for the document to finish parsing, while the client entry // is async and runs the moment it arrives. that race is decided by // document size. a SPA shell is a few hundred bytes and dev.js wins; a // prerendered +ssg/ssr page is hundreds of KB and the entry regularly // wins, throws on the first route module, and leaves the page unhydrated // and unthemed on the very same server. bundled dev has no working // /@one/dev.js at all, since it imports /@vite/client, which does not // exist there. // so install it from the entry, whose body is the one point guaranteed to // run before the lazily-globbed route modules in every dev client. the // !$RefreshReg$ guard makes this a no-op when dev.js already won, and // dev.js keeps its own install because injectIntoGlobalHook wants to run // before react evaluates. ('one' and /@react-refresh are node_modules / // the runtime, so neither is refresh-wrapped, and on the client the setup // file is imported lazily rather than statically.) // this must stay gated to a dev CLIENT. `isBundled` is true for EVERY // environment during build, so it cannot gate this on its own, and a prod // build emitting an import of /@react-refresh would reference a module // that only exists while serving. const isDevClient = this.environment.mode === 'dev' && this.environment.name === 'client' const refreshPreambleImport = isDevClient ? `import { injectIntoGlobalHook as __oneInjectRefresh } from '/@react-refresh'` : '' const refreshPreambleSetup = isDevClient ? `if (typeof window !== 'undefined' && !window.$RefreshReg$) { __oneInjectRefresh(window) window.$RefreshReg$ = () => {} window.$RefreshSig$ = () => (type) => type }` : '' return ` ${setupResult.promiseDeclaration} ${nativewindImport} ${refreshPreambleImport} import { createApp, registerPreloadedRoute as _registerPreloadedRoute } from 'one' ${setupResult.importStatement} ${refreshPreambleSetup} // Export registerPreloadedRoute so preload files can import it from this bundle // Named export that wraps the original function export function registerPreloadedRoute(key, module) { return _registerPreloadedRoute(key, module) } // Also expose on window for debugging and to prevent tree-shaking if (typeof window !== 'undefined') { window.__oneRegisterPreloadedRoute = registerPreloadedRoute } // build-only spa exports generate route artifacts without exposing the page // component to the runtime server route map. export const oneBuildOnlySpaRoutes = ${serverSpaBuildRoutes} // globbing ${JSON.stringify(webRouteGlobs)} export default createApp({ ${setupPromiseArg} routes: { ...import.meta.glob(${JSON.stringify(webRouteGlobs)}, { exhaustive: true }), ${serverSpaRouteStubs} }, routerRoot: ${JSON.stringify(options.root)}, flags: ${JSON.stringify(options.flags)}, ${linkingArg} }) ` } if (id === resolvedVirtualEntryIdNative) { const isNative = isNativeEnvironment(this.environment) const setupResult = getSetupFileImport( this.environment.name, setupFiles, isNative, viteRoot ) const linkingArg = options.router?.linking ? `linking: ${JSON.stringify(options.router.linking)},` : '' return ` import { createApp } from 'one' ${setupResult.importStatement} // globbing ${JSON.stringify(routeGlobs)} export default createApp({ routes: import.meta.glob(${JSON.stringify([...routeGlobs, ...ROUTE_NATIVE_EXCLUSION_GLOB_PATTERNS.map((p) => `!${p}`), `!${apiRouteGlobs}`])}, { exhaustive: true }), routerRoot: ${JSON.stringify(options.root)}, flags: ${JSON.stringify(options.flags)}, ${linkingArg} }) ` } }, }, } }