UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

224 lines (223 loc) 13.1 kB
import debug from "debug"; import MagicString from "magic-string"; import { INTERMEDIATE_SSR_BRIDGE_PATH } from "../lib/constants.mjs"; import { findSsrImportCallSites } from "./findSsrSpecifiers.mjs"; const log = debug("rwsdk:vite:ssr-bridge-plugin"); export const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:"; export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => { let devServer; let isDev = false; const ssrBridgePlugin = { name: "rwsdk:ssr-bridge", enforce: "pre", configureServer(server) { devServer = server; const ssrHot = server.environments.ssr.hot; const originalSsrHotSend = ssrHot.send; // Chain the SSR's full reload behaviour to the worker ssrHot.send = (...args) => { if (typeof args[0] === "object" && args[0].type === "full-reload") { for (const envName of ["worker", "ssr"]) { const moduleGraph = server.environments[envName].moduleGraph; moduleGraph.invalidateAll(); } log("SSR full-reload detected, propagating to worker"); // context(justinvdm, 21 Oct 2025): By sending the full-reload event // to the worker, we ensure that the worker's module runner cache is // invalidated, as it would have been if this were a full-reload event // from the worker. server.environments.worker.hot.send.apply(server.environments.worker.hot, args); } return originalSsrHotSend.apply(ssrHot, args); }; log("Configured dev server"); const originalRun = devServer.environments.ssr.depsOptimizer?.run; devServer.environments.ssr.depsOptimizer.run = async () => { originalRun(); devServer.environments.worker.depsOptimizer.run(); }; }, config(_, { command, isPreview }) { isDev = !isPreview && command === "serve"; log("Config: command=%s, isPreview=%s, isDev=%s", command, isPreview, isDev); }, configEnvironment(env, config) { log("Configuring environment: env=%s", env); if (env === "worker") { // Configure esbuild to mark rwsdk/__ssr paths as external for worker environment log("Configuring esbuild options for worker environment"); config.optimizeDeps ??= {}; config.optimizeDeps.esbuildOptions ??= {}; config.optimizeDeps.esbuildOptions.plugins ??= []; config.optimizeDeps.include ??= []; config.optimizeDeps.esbuildOptions.plugins.push({ name: "rwsdk-ssr-external", setup(build) { log("Setting up esbuild plugin to mark rwsdk/__ssr paths as external for worker"); build.onResolve({ filter: /.*$/ }, (args) => { process.env.VERBOSE && log("Esbuild onResolve called for path=%s, args=%O", args.path, args); if (args.path === "rwsdk/__ssr_bridge" || args.path.startsWith(VIRTUAL_SSR_PREFIX)) { log("Marking as external: %s", args.path); return { path: args.path, external: true, }; } }); }, }); log("Worker environment esbuild configuration complete"); } }, async resolveId(id, importer) { // Skip during directive scanning to avoid performance issues if (process.env.RWSDK_DIRECTIVE_SCAN_ACTIVE) { return; } if (isDev) { // context(justinvdm, 27 May 2025): In dev, we need to dynamically load // SSR modules, so we return the virtual id so that the dynamic loading // can happen in load() if (id.startsWith(VIRTUAL_SSR_PREFIX)) { if (id.endsWith(".css")) { const newId = id + ".js"; log("Virtual CSS module, adding .js suffix. old: %s, new: %s", id, newId); return newId; } log("Returning virtual SSR id for dev: %s", id); return id; } // context(justinvdm, 28 May 2025): The SSR bridge module is a special case - // it is the entry point for all SSR modules, so to trigger the // same dynamic loading logic as other SSR modules (as the case above), // we return a virtual id if (id === "rwsdk/__ssr_bridge" && this.environment.name === "worker") { const virtualId = `${VIRTUAL_SSR_PREFIX}${id}`; log("Bridge module case (dev): id=%s matches rwsdk/__ssr_bridge in worker environment, returning virtual id=%s", id, virtualId); return virtualId; } } else { // In build mode, the behavior depends on the build pass if (id.startsWith(VIRTUAL_SSR_PREFIX)) { if (this.environment.name === "worker") { log("Virtual SSR module case (build-worker pass): resolving to external"); return { id, external: true }; } } if (id === "rwsdk/__ssr_bridge" && this.environment.name === "worker") { if (process.env.RWSDK_BUILD_PASS === "worker") { // First pass: resolve to a temporary, external path log("Bridge module case (build-worker pass): resolving to external path"); return { id: INTERMEDIATE_SSR_BRIDGE_PATH, external: true }; } else if (process.env.RWSDK_BUILD_PASS === "linker") { // Second pass (linker): resolve to the real intermediate build // artifact so it can be bundled in. log("Bridge module case (build-linker pass): resolving to bundleable path"); return { id: INTERMEDIATE_SSR_BRIDGE_PATH, external: false }; } } } }, async load(id) { // Skip during directive scanning to avoid performance issues if (process.env.RWSDK_DIRECTIVE_SCAN_ACTIVE) { return; } if (id.startsWith(VIRTUAL_SSR_PREFIX) && this.environment.name === "worker") { const realId = id.slice(VIRTUAL_SSR_PREFIX.length); let idForFetch = realId.endsWith(".css.js") ? realId.slice(0, -3) : realId; log("Virtual SSR module load: id=%s, realId=%s, idForFetch=%s", id, realId, idForFetch); if (isDev) { // from the SSR environment, which is crucial for things like server // components. try { const ssrOptimizer = devServer.environments.ssr.depsOptimizer; // context(justinvdm, 20 Oct 2025): This is the fix for the stale // dependency issue. The root cause is the "unhashed-to-hashed" // transition. Our worker code imports a clean ID // (`rwsdk/__ssr_bridge`), but we expect to fetch the hashed, // optimized version from the SSR environment. When a re-optimization // happens, Vite's `fetchModule` (running in the SSR env) finds a // "ghost node" in its module graph for the clean ID and incorrectly // re-uses its stale, hashed `id` property. // // To fix this, we manually resolve the hashed path here, before // asking the SSR env to process the module. We look into the SSR // optimizer's metadata to find the correct, up-to-date hash and // construct the path ourselves. This ensures the SSR env is // always working with the correct, versioned ID, bypassing the // faulty ghost node lookup. if (ssrOptimizer && Object.prototype.hasOwnProperty.call(ssrOptimizer.metadata.optimized, realId)) { const depInfo = ssrOptimizer.metadata.optimized[realId]; idForFetch = ssrOptimizer.getOptimizedDepId(depInfo); log("Manually resolved %s to hashed path for fetchModule: %s", realId, idForFetch); } log("Virtual SSR module load: id=%s, realId=%s, idForFetch=%s", id, realId, idForFetch); log("Dev mode: fetching SSR module for realPath=%s", idForFetch); // We use `fetchModule` with `cached: false` as a safeguard. Since // we're in a `load` hook, we know the worker-side cache for this // virtual module is stale. `cached: false` ensures that we also // bypass any potentially stale transform result in the SSR // environment's cache, guaranteeing we get the freshest possible // code. const result = await devServer.environments.ssr.fetchModule(idForFetch, undefined, { cached: false }); if ("code" in result) { log("Fetched SSR module code length: %d", result.code?.length || 0); const code = result.code; if (idForFetch.endsWith(".css") && !idForFetch.endsWith(".module.css")) { process.env.VERBOSE && log("Plain CSS file, returning empty module for %s", idForFetch); return "export default {};"; } const s = new MagicString(code || ""); const callsites = findSsrImportCallSites(idForFetch, code || "", log); for (const site of callsites) { const normalized = site.specifier.startsWith("/@id/") ? site.specifier.slice("/@id/".length) : site.specifier; // context(justinvdm, 11 Aug 2025): // - We replace __vite_ssr_import__ and __vite_ssr_dynamic_import__ // with import() calls so that the module graph can be built // correctly (vite looks for imports and import()s to build module // graph) // - We prepend /@id/$VIRTUAL_SSR_PREFIX to the specifier so that we // can stay within the SSR subgraph of the worker module graph const replacement = `import("/@id/${VIRTUAL_SSR_PREFIX}${normalized}")`; s.overwrite(site.start, site.end, replacement); } const out = s.toString(); process.env.VERBOSE && log("Transformed SSR module code for realId=%s: %s", realId, out); return { code: out, map: null, // Sourcemaps are handled by fetchModule's inlining }; } else { // This case can be hit if the module is already cached. We may // need to handle this more gracefully, but for now we'll just // return an empty module. log("SSR module %s was already cached. Returning empty.", idForFetch); return "export default {}"; } } catch (e) { log("Error fetching SSR module for realPath=%s: %s", id, e); throw e; } } } return; }, }; return ssrBridgePlugin; };