// thanks to hi-ogawa https://github.com/hi-ogawa/vite-plugins/tree/main/packages/ssr-css import type { Plugin, ViteDevServer } from 'vite' const VIRTUAL_ENTRY = 'virtual:ssr-css.css' export function vitePluginSsrCss(pluginOpts: { entries: string[] }): Plugin { let server: ViteDevServer // TODO: base const virtualHref = '/@id/__x00__' + VIRTUAL_ENTRY return { name: `vite-plugin-ssr-css`, apply: 'serve', configureServer(server_) { server = server_ // invalidate virtual modules for each direct request server.middlewares.use(async (req, res, next) => { if (req.url === virtualHref) { invalidateModule(server, '\0' + VIRTUAL_ENTRY + '?direct') let code = await collectStyle(server, pluginOpts.entries) res.setHeader('Content-Type', 'text/css') res.write(code) res.end() return } next() }) }, // virtual module // (use `startsWith` since Vite adds `?direct` for raw css request) resolveId(source, _importer, _options) { return source.startsWith(VIRTUAL_ENTRY) ? '\0' + source : undefined }, async load(id, _options) { if (id.startsWith('\0' + VIRTUAL_ENTRY)) { const url = new URL(id.slice(1), 'https://test.local') let code = await collectStyle(server, pluginOpts.entries) if (!url.searchParams.has('direct')) { code = `export default ${JSON.stringify(code)}` } return code } return }, // also expose via transformIndexHtml transformIndexHtml: { handler: async () => { return [ { tag: 'link', injectTo: 'head', attrs: { rel: 'stylesheet', href: virtualHref, 'data-ssr-css': true, }, }, { tag: 'script', injectTo: 'head', attrs: { type: 'module' }, children: /* js */ ` import { createHotContext } from "/@vite/client"; const hot = createHotContext("/__clear_ssr_css"); hot.on("vite:afterUpdate", () => { document .querySelectorAll("[data-ssr-css]") .forEach(node => node.remove()); }); `, }, ] }, }, } satisfies Plugin } function invalidateModule(server: ViteDevServer, id: string) { const mod = server.moduleGraph.getModuleById(id) if (mod) { server.moduleGraph.invalidateModule(mod) } } // style collection // https://github.com/remix-run/remix/blob/1a8a5216106bd8c3073cc3e5e5399a32c981db74/packages/remix-dev/vite/styles.ts // https://github.com/vikejs/vike/blob/f9a91f3c47cab9c2871526ef714cc0f87a41fda0/vike/node/runtime/renderPage/getPageAssets/retrieveAssetsDev.ts export async function collectStyle(server: ViteDevServer, entries: string[]) { const urls = await collectStyleUrls(server, entries) const codes = await Promise.all( urls.map(async (url) => { const res = await server.transformRequest(url + '?direct') return [`/* [collectStyle] ${url} */`, res?.code] }) ) return codes.flat().filter(Boolean).join('\n\n') } async function collectStyleUrls(server: ViteDevServer, entries: string[]): Promise { const visited = new Set() async function traverse(url: string) { const [, id] = await server.moduleGraph.resolveUrl(url) if (visited.has(id)) { return } visited.add(id) const mod = server.moduleGraph.getModuleById(id) if (!mod) { return } await Promise.all([...mod.importedModules].map((childMod) => traverse(childMod.url))) } // ensure vite's import analysis is ready _only_ for top entries to not go too aggresive await Promise.all(entries.map((e) => server.transformRequest(e))) // traverse await Promise.all(entries.map((url) => traverse(url))) // filter return [...visited].filter((url) => url.match(CSS_LANGS_RE)) } // cf. https://github.com/vitejs/vite/blob/d6bde8b03d433778aaed62afc2be0630c8131908/packages/vite/src/node/constants.ts#L49C23-L50 const CSS_LANGS_RE = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/