UNPKG

one

Version:

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

588 lines (586 loc) 24.6 kB
import path from "node:path"; import { Readable } from "node:stream"; import { debounce } from "perfect-debounce"; import colors from "picocolors"; import { createServerModuleRunner } from "vite"; import { getSpaHeaderElements } from "../../constants.mjs"; import { createHandleRequest } from "../../createHandleRequest.mjs"; import { getPageExport } from "../../utils/getPageExport.mjs"; import { getRouterRootFromOneOptions } from "../../utils/getRouterRootFromOneOptions.mjs"; import { isResponse } from "../../utils/isResponse.mjs"; import { isStatusRedirect } from "../../utils/isStatus.mjs"; import { promiseWithResolvers } from "../../utils/promiseWithResolvers.mjs"; import { trackLoaderDependencies } from "../../utils/trackLoaderDependencies.mjs"; import { LoaderDataCache } from "../../vite/constants.mjs"; import { replaceLoader } from "../../vite/replaceLoader.mjs"; import { setServerContext } from "../one-server-only.mjs"; import { virtalEntryIdClient, virtualEntryId } from "./virtualEntryConstants.mjs"; const debugRouter = process.env.ONE_DEBUG_ROUTER; const debugLoaderDeps = process.env.ONE_DEBUG_LOADER_DEPS; const routeTypeColors = { ssg: colors.green, ssr: colors.blue, spa: colors.yellow, api: colors.magenta }; const USE_SERVER_ENV = false; //!!process.env.USE_SERVER_ENV function createFileSystemRouterPlugin(options) { const preloads = ["/@vite/client", virtalEntryIdClient]; let runner; let server; const loaderFileDependencies = /* @__PURE__ */new Map(); let handleRequest = createRequestHandler(); let renderPromise = null; function createRequestHandler() { const routerRoot = getRouterRootFromOneOptions(options); async function findNearestNotFoundPath(routeFile2) { const routeDir = routeFile2.replace(/\/[^/]+$/, ""); let searchDir = routeDir; while (true) { for (const ext of [".tsx", ".ts", ".jsx", ".js"]) { const candidate = path.join(routerRoot, searchDir, `+not-found${ext}`); try { const mod = await runner.import(candidate); if (mod?.default) { return searchDir ? `/${searchDir}/+not-found` : "/+not-found"; } } catch {} } if (!searchDir) break; const parent = searchDir.replace(/\/[^/]+$/, ""); if (parent === searchDir) { searchDir = ""; } else { searchDir = parent; } } return "/+not-found"; } return createHandleRequest({ async handlePage({ route, url, loaderProps }) { if (options.server?.loggingEnabled !== false) { const colorType = routeTypeColors[route.type] || colors.white; const pathname = typeof url === "string" ? new URL(url).pathname : url.pathname; const file = route.isNotFound ? colors.red("404") : colors.dim(`app/${route.file.slice(2)}`); console.info(` \u24F5 ${colorType(`[${route.type}]`)} ${pathname} ${colors.dim("\u2192")} ${file}`); } const layouts = route.layouts || []; const isSpaShell = route.type === "spa" && layouts.some(layout => layout.layoutRenderMode === "ssg" || layout.layoutRenderMode === "ssr"); if (route.type === "spa" && !isSpaShell) { return `<!DOCTYPE html><html><head> ${getSpaHeaderElements({ serverContext: { mode: "spa" } })} <script type="module" src="/@one/dev.js"></script> <script type="module" src="/@vite/client" async=""></script> <script type="module" src="/@id/__x00__virtual:one-entry" async=""></script> </head></html>`; } if (renderPromise) { await renderPromise; } const { promise, resolve: resolveRender } = promiseWithResolvers(); renderPromise = promise; try { const isGeneratedNotFound = route.file === ""; const routeFile = isGeneratedNotFound ? "" : path.join(routerRoot, route.file); runner.clearCache(); globalThis["__vxrnresetState"]?.(); const exported = isGeneratedNotFound ? {} : await runner.import(routeFile); async function runLoaderWithTracking(routeNode, loaderFn) { const routeId = routeNode.contextKey; if (!loaderFn) { return { loaderData: void 0, routeId }; } try { const tracked = await trackLoaderDependencies(() => loaderFn(loaderProps)); const routePath = loaderProps?.path || "/"; for (const dep of tracked.dependencies) { const absoluteDep = path.resolve(dep); if (!loaderFileDependencies.has(absoluteDep)) { loaderFileDependencies.set(absoluteDep, /* @__PURE__ */new Set()); server?.watcher.add(absoluteDep); if (debugLoaderDeps) { console.info(` \u24F5 [loader-dep] watching: ${absoluteDep}`); } } loaderFileDependencies.get(absoluteDep).add(routePath); } return { loaderData: tracked.result, routeId }; } catch (err) { if (isResponse(err)) { throw err; } if (err?.code === "ENOENT") { return { loaderData: void 0, routeId, isEnoent: true }; } console.error(`[one] Error running loader for ${routeId}:`, err); return { loaderData: void 0, routeId }; } } let loaderData; let matches; let pageResult; if (isSpaShell) { const layoutLoaderPromises = layouts.map(async layout => { const layoutFile = path.join(routerRoot, layout.contextKey); const layoutExported = await runner.import(layoutFile); return runLoaderWithTracking(layout, layoutExported.loader); }); const layoutResults = await Promise.all(layoutLoaderPromises); matches = layoutResults.map(result => ({ routeId: result.routeId, pathname: loaderProps?.path || "/", params: loaderProps?.params || {}, loaderData: result.loaderData })); loaderData = void 0; } else { const layoutRoutes = route.layouts || []; const pageRoute = { contextKey: route.file, file: route.file }; const layoutLoaderPromises = layoutRoutes.map(async layout => { const layoutFile = path.join(routerRoot, layout.contextKey); const layoutExported = await runner.import(layoutFile); return runLoaderWithTracking(layout, layoutExported.loader); }); const pageLoaderPromise = runLoaderWithTracking(pageRoute, exported.loader); let layoutResults; [layoutResults, pageResult] = await Promise.all([Promise.all(layoutLoaderPromises), pageLoaderPromise]); matches = [...layoutResults.map(result => ({ routeId: result.routeId, pathname: loaderProps?.path || "/", params: loaderProps?.params || {}, loaderData: result.loaderData })), { routeId: pageResult.routeId, pathname: loaderProps?.path || "/", params: loaderProps?.params || {}, loaderData: pageResult.loaderData }]; loaderData = pageResult.loaderData; } eval(`process.env.TAMAGUI_IS_SERVER = '1'`); const entry = await runner.import(virtualEntryId); const render = entry.default.render; setServerContext({ loaderData, loaderProps, matches }); LoaderDataCache[route.file] = loaderData; const isDynamicRoute = Object.keys(route.routeKeys || {}).length > 0; let isMissingSsgSlug = false; if (route.type === "ssg" && isDynamicRoute && exported.generateStaticParams) { const staticParams = await exported.generateStaticParams({ params: loaderProps?.params }); const currentParams = loaderProps?.params || {}; isMissingSsgSlug = !staticParams.some(sp => Object.keys(sp).every(key => sp[key] === currentParams[key])); } const isLoaderEnoent = !isSpaShell && pageResult?.isEnoent; const is404 = route.isNotFound || !getPageExport(exported) || isMissingSsgSlug || isLoaderEnoent; let notFoundRoutePath = null; if (isMissingSsgSlug || isLoaderEnoent) { const routeDir = route.file.replace(/^\.\//, "").replace(/\/[^/]+$/, ""); let searchDir = routeDir; while (true) { for (const ext of [".tsx", ".ts", ".jsx", ".js"]) { const candidate = path.join(routerRoot, searchDir, `+not-found${ext}`); try { const notFoundExported = await runner.import(candidate); if (notFoundExported?.default) { notFoundRoutePath = searchDir ? `/${searchDir}/+not-found` : "/+not-found"; break; } } catch {} } if (notFoundRoutePath || !searchDir) break; const parent = searchDir.replace(/\/[^/]+$/, ""); if (parent === searchDir) { searchDir = ""; } else { searchDir = parent; } } if (!notFoundRoutePath) { return new Response("<html><body><h1>404 - Not Found</h1></body></html>", { status: 404, headers: { "Content-Type": "text/html" } }); } } const renderPath = notFoundRoutePath || loaderProps?.path || "/"; let html = await render({ mode: isSpaShell ? "spa-shell" : route.type === "ssg" ? "ssg" : route.type === "ssr" ? "ssr" : "spa", loaderData, loaderProps, path: renderPath, preloads, matches }); if (is404) { if (notFoundRoutePath) { const originalPath = loaderProps?.path || "/"; const notFoundMarker = `<script>window.__one404={originalPath:${JSON.stringify(originalPath)},notFoundPath:${JSON.stringify(notFoundRoutePath)}}</script>`; html = html.includes("</head>") ? html.replace("</head>", `${notFoundMarker}</head>`) : html.replace("<body", `${notFoundMarker}<body`); } return new Response(html, { status: 404, headers: { "Content-Type": "text/html" } }); } return html; } catch (err) { if (isResponse(err)) { return err; } console.error(`SSR error while loading file ${route.file} from URL ${url.href} `, err); const title = `Error rendering ${url.pathname} on server`; const message = err instanceof Error ? err.message : `${err}`; const stack = err instanceof Error ? err.stack || "" : ""; const isDuplicateReactError = /at (useEffect|useState|useReducer|useContext|useLayoutEffect)\s*\(.*?react\.development\.js/g.test(stack); const subMessage = isDuplicateReactError ? ` <h2>Duplicate React Error</h2> <p style="font-size: 18px; line-height: 24px; max-width: 850px;">Note: These types of errors happen during SSR because One needs all dependencies that use React to be optimized. Find the dependency on the line after the react.development.js line below to find the failing dependency. So long as that dependency has "react" as a sub-dependency, you can add it to your package.json and One will optimize it automatically. If it doesn't list it properly, you can fix this manually by changing your vite.config.ts One plugin to add "one({ deps: { depName: true })" so One optimizes depName.</p> ` : ``; console.error(`${title} ${message} ${stack} `); return ` <html> <body style="background: #000; color: #fff; padding: 5%; font-family: monospace; line-height: 2rem;"> <h1 style="display: inline-flex; background: red; color: white; padding: 5px; margin: -5px;">${title}</h1> <h2>${message}</h2> ${subMessage} ${stack ? `<pre style="font-size: 15px; line-height: 24px; white-space: pre;"> ${stack} </pre>` : ``} </body> </html> `; } finally { resolveRender(); } }, async handleLoader({ route: route2, url: url2, loaderProps: loaderProps2 }) { const routeFile2 = path.join(routerRoot, route2.file); let transformedJS = (await server.transformRequest(routeFile2))?.code; if (!transformedJS) { throw new Error(`No transformed js returned`); } if (!/export function loader\(\)/.test(transformedJS)) { return transformedJS; } const exported2 = await runner.import(routeFile2); const isDynamicRoute2 = Object.keys(route2.routeKeys || {}).length > 0; if (route2.type === "ssg" && isDynamicRoute2 && exported2.generateStaticParams) { const staticParams = await exported2.generateStaticParams({ params: loaderProps2?.params }); const currentParams = loaderProps2?.params || {}; const isValidSlug = staticParams.some(sp => Object.keys(sp).every(key => sp[key] === currentParams[key])); if (!isValidSlug) { const nfPath = await findNearestNotFoundPath(route2.file); return `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`; } } let loaderData2; if (exported2.loader) { try { const tracked = await trackLoaderDependencies(() => exported2.loader(loaderProps2)); loaderData2 = tracked.result; if (isResponse(loaderData2)) { throw loaderData2; } const routePath = loaderProps2?.path || "/"; for (const dep of tracked.dependencies) { const absoluteDep = path.resolve(dep); if (!loaderFileDependencies.has(absoluteDep)) { loaderFileDependencies.set(absoluteDep, /* @__PURE__ */new Set()); server?.watcher.add(absoluteDep); if (debugLoaderDeps) { console.info(` \u24F5 [loader-dep] watching: ${absoluteDep}`); } } loaderFileDependencies.get(absoluteDep).add(routePath); } } catch (err) { if (isResponse(err)) { throw err; } if (err?.code === "ENOENT") { const nfPath = await findNearestNotFoundPath(route2.file); return `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`; } throw err; } } if (loaderData2) { transformedJS = replaceLoader({ code: transformedJS, loaderData: loaderData2 }); } const platform = url2.searchParams.get("platform"); if (platform === "ios" || platform === "android" || platform === "native") { const environment = server.environments[platform === "native" ? "ios" : platform || ""]; if (!environment) { throw new Error(`[handleLoader] No Vite environment found for platform '${platform}'`); } const nativeTransformedJS = `exports.loader = () => (${JSON.stringify(loaderData2)});`; return nativeTransformedJS; } return transformedJS; }, async handleAPI({ route: route2 }) { return await runner.import(path.join(routerRoot, route2.file)); }, async loadMiddleware(route2) { return await runner.import(path.join(routerRoot, route2.contextKey)); } }, { routerRoot, ignoredRouteFiles: options.router?.ignoredRouteFiles }); } return { name: `one-router-fs`, enforce: "post", apply: "serve", async config() { const setting = options.optimization?.autoEntriesScanning ?? "flat"; if (setting === false) { return; } if (handleRequest.manifest.pageRoutes) { const routesAndLayouts = [...new Set(handleRequest.manifest.pageRoutes.flatMap(route2 => { if (route2.isNotFound) return []; if (!route2.file) return []; if (setting === "flat" && route2.file.split("/").filter(x => !x.startsWith("(")).length > 3) { return []; } return [path.join("./app", route2.file), ...(route2.layouts?.flatMap(layout => { if (!layout.contextKey) return []; return [path.join("./app", layout.contextKey)]; }) || [])]; }))]; return { optimizeDeps: { /** * This adds all our routes and layouts as entries which fixes initial load making * optimizeDeps be triggered which causes hard refreshes (also on initial navigations) * * see: https://vitejs.dev/config/dep-optimization-options.html#optimizedeps-entries * and: https://github.com/remix-run/remix/pull/9921 */ entries: routesAndLayouts } }; } }, configureServer(serverIn) { server = serverIn; runner = createServerModuleRunner(USE_SERVER_ENV ? server.environments.server : server.environments.ssr); const appDir = path.join(process.cwd(), getRouterRootFromOneOptions(options)); const fileWatcherChangeListener = debounce(async (type, changedPath) => { if (type === "add" || type === "delete") { const absolutePath = path.resolve(changedPath); if (absolutePath.startsWith(appDir)) { handleRequest = createRequestHandler(); } } }, 100); server.watcher.addListener("all", fileWatcherChangeListener); const loaderDepChangeListener = debounce(changedPath => { const absolutePath = path.resolve(changedPath); const routePaths = loaderFileDependencies.get(absolutePath); if (routePaths && routePaths.size > 0) { if (debugLoaderDeps) { console.info(` \u24F5 [loader-dep] changed: ${absolutePath}, triggering loader refetch for routes:`, [...routePaths]); } server.hot.send({ type: "custom", event: "one:loader-data-update", data: { routePaths: [...routePaths] } }); } }, 100); server.watcher.on("change", loaderDepChangeListener); return () => { server.middlewares.use((req, res, next) => { if (req.url === "/status" || req.url?.startsWith("/status?")) { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("packager-status:running"); return; } next(); }); server.middlewares.use(async (req, res, next) => { res.setHeader("Cache-Control", "no-store"); try { const redirects = options.web?.redirects; if (redirects) { const url2 = new URL(req.url || "", `http://${req.headers.host}`); for (const redirect of redirects) { const regexStr = `^${redirect.source.replace(/:\w+/g, "([^/]+)")}$`; const match = url2.pathname.match(new RegExp(regexStr)); if (match) { let destination = redirect.destination; const params = redirect.source.match(/:\w+/g); if (params) { params.forEach((param, index) => { destination = destination.replace(param, match[index + 1] || ""); }); } if (debugRouter) { console.info(`[one] \u21AA redirect ${url2.pathname} \u2192 ${destination}`); } res.writeHead(redirect.permanent ? 301 : 302, { Location: destination }); res.end(); return; } } } const reply = await handleRequest.handler(convertIncomingMessageToRequest(req)); if (!reply) { return next(); } if (typeof reply !== "string" && isResponse(reply)) { if (debugRouter) { const headers = {}; reply.headers.forEach((v, k) => { headers[k] = v; }); console.info(`[one] \u{1F4E4} response ${reply.status}`, headers); } reply.headers.forEach((value, key) => { if (key === "set-cookie") { const cookies = value.split(", "); for (const cookie of cookies) { res.appendHeader("Set-Cookie", cookie); } } else { res.setHeader(key, value); } }); if (isStatusRedirect(reply.status)) { const location = `${reply.headers.get("location") || ""}`; if (debugRouter) { console.info(`[one] \u21AA response redirect \u2192 ${location}`); } if (location) { res.writeHead(reply.status, { Location: location }); res.end(); return; } console.error(`No location provided to redirected status reply`, reply); } res.statusCode = reply.status; res.statusMessage = reply.statusText; if (reply.body) { if (reply.body.locked) { console.warn(`Body is locked??`, req.url); res.end(); return; } try { Readable.fromWeb(reply.body).pipe(res); } catch (err) { console.warn("Error piping reply body to response:", err); res.end(); } return; } res.end(); return; } if (reply && typeof reply === "object") { res.setHeader("Content-Type", "application/json"); res.write(JSON.stringify(reply)); res.end(); return; } res.write(reply); res.end(); return; } catch (error) { console.error(`[one] routing error ${req.url}: ${error}`); next(error); } console.warn(`SSR handler didn't send a response for url: ${req.url}`); }); }; } }; } const convertIncomingMessageToRequest = req => { if (!req.originalUrl) { throw new Error(`Can't convert: originalUrl is missing`); } const urlBase = `http://${req.headers.host}`; const urlString = req.originalUrl; const url2 = new URL(urlString, urlBase); const headers = new Headers(); for (const key in req.headers) { if (req.headers[key]) { headers.append(key, req.headers[key]); } } const hasBody = ["POST", "PUT", "PATCH", "DELETE"].includes(req.method || ""); const body = hasBody ? Readable.toWeb(req) : null; return new Request(url2, { method: req.method, headers, body, // Required for streaming bodies in Node's experimental fetch: duplex: "half" }); }; export { createFileSystemRouterPlugin }; //# sourceMappingURL=fileSystemRouterPlugin.mjs.map