feat(vite): decouple vite plugin from compiler - #97
Conversation
This decouples vite plugin and compiler, eliminating the need for rolldown and, more importantly, enabling surimi files to benefit from the full vite ecosystem, like importing css files, better exports, HMR, etc. This does make some things simpler, but now we have to manage the secondary vite server for code execution etc. which is another thing to handle
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
surimi | 06426ba | Commit Preview URL Branch Preview URL |
Jun 22 2026, 05:17 AM |
| throw new Error(`transform returned no code for ${requestId}`); | ||
| } | ||
|
|
||
| const absoluteId = normalizeModuleId(path.join(fixture.root, moduleId), { root: fixture.root } as never); |
There was a problem hiding this comment.
Incorrect parameter type passed to normalizeModuleId. The function expects root to be a string | undefined, but an object { root: fixture.root } is being passed (cast with as never to bypass type checking). This will cause runtime errors when the function attempts string operations on the root parameter.
// Fix: Pass the root string directly
const absoluteId = normalizeModuleId(path.join(fixture.root, moduleId), fixture.root);| const absoluteId = normalizeModuleId(path.join(fixture.root, moduleId), { root: fixture.root } as never); | |
| const absoluteId = normalizeModuleId(path.join(fixture.root, moduleId), fixture.root); |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Pull request overview
This PR decouples vite-plugin-surimi from the rolldown-based compiler by introducing a Vite-owned evaluation server/module runner, enabling Surimi style files (and Vue <surimi> blocks) to participate in the broader Vite ecosystem (plain CSS imports, asset handling, HMR, etc.).
Changes:
- Added a
SurimiEvaluator(owned Vite server + module-runner) and migrated plugin/Vue block compilation to it. - Introduced canonical module-id normalization utilities and updated virtual CSS resolution/emission logic.
- Added a comprehensive Vitest suite for parity, module resolution (incl.
?raw), CSS emission, Vue blocks, exports, and errors.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/vite-plugin-surimi/test/vue.spec.ts | Adds Vue <surimi> block evaluation tests (virtual ids, imports, evaluator reuse). |
| packages/vite-plugin-surimi/test/parity.spec.ts | Adds parity tests comparing plugin output vs compiler fixtures. |
| packages/vite-plugin-surimi/test/module-resolution.spec.ts | Adds tests for CSS/TS/TSX/alias/?raw imports and dependency dedupe. |
| packages/vite-plugin-surimi/test/helpers/vite.ts | Adds Vite test harness (fixture FS, build/serve transform helpers, evaluator helpers). |
| packages/vite-plugin-surimi/test/exports.spec.ts | Adds tests for export serialization behavior. |
| packages/vite-plugin-surimi/test/errors.spec.ts | Adds error-path tests (invalid Surimi file, SSR inlineCss rejection). |
| packages/vite-plugin-surimi/test/css-emission.spec.ts | Adds tests for dev virtual CSS import, build CSS emission, SSR behavior. |
| packages/vite-plugin-surimi/src/vue.ts | Updates Vue integration to use shared evaluator compilation + import-path helpers. |
| packages/vite-plugin-surimi/src/types.ts | Extends shared plugin context with evaluator + compilation getter. |
| packages/vite-plugin-surimi/src/runner.ts | Introduces owned-server evaluator and dependency collection helpers. |
| packages/vite-plugin-surimi/src/plugin.ts | Migrates core plugin to evaluator-backed compilation + updated HMR/virtual CSS logic. |
| packages/vite-plugin-surimi/src/normalize-module-id.ts | Adds canonical module id + import-path/virtual-id helpers. |
| packages/compiler/src/index.ts | Re-exports compiler transform helpers and extraction utilities. |
| packages/compiler/src/index.node.ts | Re-exports shared compiler utilities from node entry. |
| packages/compiler/src/index.browser.ts | Re-exports shared compiler utilities from browser entry. |
| packages/compiler/src/extract.ts | Introduces shared extraction of CSS + serializable exports from evaluated modules. |
| packages/compiler/src/constants.ts | Adds shared compiler constants. |
| packages/compiler/src/compiler.ts | Refactors to use shared extract/constants and exports transform plugins/utilities. |
Comments suppressed due to low confidence (1)
packages/vite-plugin-surimi/src/plugin.ts:324
- If
dependenciescontains query-bearing ids, passing them directly toaddWatchFile()will register a non-existent path like/abs/file.css?raw. Strip queries before watching so file changes are detected reliably.
if (ctx.isDev && !options?.ssr) {
const addWatch = this.addWatchFile.bind(this);
addWatchFilesForDeps(dependencies, filesWatched, addWatch, (p: string) => path.isAbsolute(p));
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const cssTsCandidate = `${absoluteCss.slice(0, -4)}.ts`; | ||
| if (existsSync(cssTsCandidate)) return cssTsCandidate; | ||
|
|
||
| return null; |
| const cleanId = dependencyId.split('?')[0] ?? dependencyId; | ||
| if (cleanId === ownerId) return false; | ||
| if (isSurimiStyleFile(cleanId)) return false; | ||
| if (cleanId.endsWith('.surimi.css')) return false; | ||
| if (cleanId.includes('node_modules')) return false; | ||
|
|
||
| return ( | ||
| /\.(css|scss|sass|less)$/i.test(cleanId) || | ||
| dependencyId.includes('?raw') || | ||
| dependencyId.includes('?url') || | ||
| dependencyId.includes('?inline') | ||
| ); |
| const normalized = normalizeModuleId(moduleId.split('?')[0] ?? moduleId, root); | ||
| if (visited.has(normalized)) return; | ||
| visited.add(normalized); | ||
|
|
||
| const mod = moduleGraph.getModuleById(normalized) ?? moduleGraph.getModuleById(moduleId); | ||
| if (!mod) return; | ||
|
|
||
| for (const imported of mod.importedModules) { | ||
| const importedId = imported.id ?? imported.url; | ||
| if (!importedId) continue; | ||
|
|
||
| const cleanId = importedId.split('?')[0] ?? importedId; | ||
| if (cleanId.includes('node_modules')) continue; | ||
| if (isDevelopmentSurimiFile(cleanId)) continue; | ||
| if (cleanId.startsWith('\0')) continue; | ||
|
|
||
| const normalizedImport = normalizeModuleId(cleanId, root); | ||
| deps.add(normalizedImport); | ||
| visit(normalizedImport); | ||
| } | ||
| }; | ||
|
|
||
| visit(entryId); | ||
| deps.add(normalizeModuleId(entryId.split('?')[0] ?? entryId, root)); |
| const normalizeDependencyId = (dependencyId: string, ownerId: string): string => { | ||
| // Rolldown mixes relative, absolute, and virtual ("\0") ids. Normalizing avoids cache misses when Vite | ||
| // requests the same file under a different shape (e.g. absolute path during SSR versus relative in dev). | ||
| const cleanId = dependencyId.split('?')[0] ?? dependencyId; | ||
|
|
||
| if (cleanId.startsWith('\0')) return cleanId; | ||
| if (path.isAbsolute(cleanId)) return normalizePath(cleanId); | ||
| if (ctx.resolvedConfig) return getAbsoluteId(cleanId, ctx.resolvedConfig); | ||
| if (path.isAbsolute(cleanId)) { |
| export function toImportPath(dependencyId: string, ownerId: string, root?: string): string { | ||
| const absoluteDependency = normalizeModuleId(dependencyId, root); | ||
| const absoluteOwner = normalizeModuleId(ownerId, root); | ||
| const relative = path.relative(path.dirname(absoluteOwner), absoluteDependency); | ||
| const posixRelative = relative.split(path.sep).join(path.posix.sep); | ||
| return posixRelative.startsWith('.') ? posixRelative : `./${posixRelative}`; | ||
| } |
| const cacheEntry = ctx.compilationCache.get(cachedFile); | ||
| if (cacheEntry?.dependencies.includes(normalizedChangedFile)) { | ||
| ctx.compilationCache.delete(cachedFile); | ||
| ctx.evaluator?.invalidate(cachedFile); | ||
| modules.push(...collectModulesForInvalidation(cachedFile, moduleGraph, inlineCss)); | ||
| } |
| if (ctx.isDev && !options?.ssr) { | ||
| const addWatch = this.addWatchFile.bind(this); | ||
| addWatchFilesForDeps(resultToCache.dependencies, filesWatched, addWatch, (p: string) => path.isAbsolute(p)); | ||
| } |
| export class SurimiEvaluator { | ||
| private server: ViteDevServer | null = null; | ||
| private runner: ModuleRunner | null = null; | ||
| private readonly virtualSources = new Map<string, string>(); |
| private async ensureServer(): Promise<{ server: ViteDevServer; runner: ModuleRunner }> { | ||
| if (this.server && this.runner) { | ||
| return { server: this.server, runner: this.runner }; | ||
| } |
| this.server = await createServer({ | ||
| configFile: false, | ||
| root: this.normalizedRoot, | ||
| logLevel: 'silent', | ||
| ...(baseResolve ? { resolve: baseResolve } : {}), | ||
| plugins, | ||
| server: { middlewareMode: true, ws: false }, | ||
| }); | ||
|
|
||
| this.runner = createServerModuleRunner(this.server.environments.ssr, { hmr: false }); | ||
|
|
||
| return { server: this.server, runner: this.runner }; |

This decouples vite plugin and compiler, eliminating the need for rolldown and, more importantly,
enabling surimi files to benefit from the full vite ecosystem, like importing css files,
better exports, HMR, etc.
This does make some things simpler, but now we have to manage the secondary vite server
for code execution etc. which is another thing to handle