This document explains the internal architecture and implementation details of @hiogawa/vite-plugin-fullstack.
- Overview
- Query Import System
- Virtual Module System
- Dev Mode: CSS Collection
- Build Mode: Asset Manifest
- Hot Module Replacement
The plugin provides a ?assets query import API for accessing build assets information:
import assets from "./page.js?assets";
import clientAssets from "./client.js?assets=client";
import ssrAssets from "./server.js?assets=ssr";The implementation differs between dev and build modes:
- Dev mode: Dynamic CSS collection via module graph traversal
- Build mode: Static asset manifest generated during build
The plugin supports three variations:
// Universal route (both client and current environment)
import assets from "./page.js?assets";
// Client assets only (with entry flag for server-accessing-client case)
import assets from "./page.js?assets=client";
// Specific environment assets
import assets from "./page.js?assets=ssr";The fullstack:assets-query plugin intercepts ?assets imports:
-
Server environments: Generates code based on query value:
?assets=client→ Single environment import?assets=ssr→ Single environment import?assets→ Merged client + current environment
-
Client environment: Returns empty assets (client doesn't need this info)
return `\0virtual:fullstack/empty-assets`;
When you import page.js?assets, the plugin resolves it to a virtual module that loads the assets information. In dev mode, it returns the CSS collected via module graph traversal. In build mode, it returns a reference to the static assets manifest.
This core function has different behavior in dev vs build:
Dev Mode:
{
entry: "/src/entry.client.tsx", // Only for client environment
js: [], // Always empty in dev
css: [ // Collected via module graph on server. Empty for `?assets=client`
{ href: "/src/styles.css", "data-vite-dev-id": "..." }
]
}Build Mode:
Returns a reference to the static manifest entry (see Build Mode: Asset Manifest for how the manifest is generated):
__assets_manifest["ssr"]["/src/entry.client.tsx"];
// {
// entry: "/assets/index-abc123.js", // Entry chunk file name
// js: [ // Preload chunks
// { href: "/assets/chunk-def456.js" }
// ],
// css: [ // CSS files
// { href: "/assets/style-789xyz.css" }
// ]
// }In dev mode, the plugin traverses the module graph to find all CSS dependencies of a given entry module. It recursively visits all imported modules, collecting any CSS imports it encounters. To ensure all imports are discovered, modules are eagerly transformed if not already processed. The traversal skips special CSS queries like ?url, ?inline, and ?raw, as well as virtual modules and ?assets queries themselves to avoid circular dependencies.
-
Tracking Phase (during server build):
// Server code references client assets import clientAssets from "./entry.client.js?assets=client"; // Plugin tracks this in importAssetsMetaMap: importAssetsMetaMap["client"]["/entry.client.js"] = { id: "/entry.client.js", key: "entry.client.js", // Relative path for machine-independent builds importerEnvironment: "ssr", isEntry: true, };
-
Dynamic Entry Injection (
buildStarthook):- When building the client environment, the plugin emits tracked modules as entry chunks:
if (environment.name === "client") { for (const meta of importAssetsMetaMap["client"]) { if (meta.isEntry) { this.emitFile({ type: "chunk", id: meta.id, preserveSignature: "exports-only", }); } } }
-
Dependency Collection (
buildApphook):- After all environments are built, collect dependencies for each tracked module:
function collectAssetDeps(bundle) { // For each chunk, recursively collect: // - All imported chunks (JS dependencies) // - All imported CSS files (viteMetadata.importedCss) return { js: [...], css: [...] }; }
-
Manifest Generation:
// __fullstack_assets_manifest.js export default { client: { "entry.client.js": { entry: "/assets/entry.client-abc123.js", js: [{ href: "/assets/chunk-def456.js" }], css: [{ href: "/assets/style-789xyz.css" }], }, }, ssr: { "page.js": { js: [], css: [{ href: "/assets/page-style-abc.css" }], }, }, };
-
Asset Copying:
- CSS assets generated by server builds are copied to the client output directory
- This ensures all CSS is served from the client's public directory
Server code accesses the manifest via external import:
// Generated virtual module
import __assets_manifest from "virtual:fullstack/assets-manifest";
export default __assets_manifest["ssr"]["page.js"];During renderChunk, the plugin rewrites the import to a relative path:
"virtual:fullstack/assets-manifest" → "./__fullstack_assets_manifest.js"Vite's HMR assumes all CSS is injected via import, not via SSR-rendered <link> tags. The plugin patches Vite's client-side HMR to track SSR-injected CSS links and prevent duplicate style injection. When CSS updates, it removes the old <link> tags with matching data-vite-dev-id attributes instead of trying to update them as <style> tags.
When a file changes, the plugin manually invalidates all related ?assets virtual modules for that file and its dependents. This ensures that when CSS imports change, the assets information is recalculated on the next request.
The plugin works by:
- Query import system: Providing
?assetsquery imports that resolve to virtual modules - Dev mode: Dynamically collecting CSS via module graph traversal at request time
- Build mode:
- Tracking asset imports during server build
- Dynamically injecting client entries
- Generating a static manifest after all environments are built
- Rewriting virtual imports to point to the manifest file
- HMR: Patching Vite's client to handle SSR-injected CSS and invalidating virtual modules on changes
This design provides a framework-agnostic primitive for solving FOUC and asset preloading in SSR applications.