diff --git a/package.json b/package.json index ddb944e..137af90 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@nuxt/kit": "^4.4.8", + "magic-string": "^0.30.21", "rolldown": "1.1.0" }, "devDependencies": { @@ -49,9 +50,9 @@ "nuxt": "^4.4.8", "playwright-core": "^1.61.1", "typescript": "~6.0.3", + "vite": "^7.3.5", "vitest": "^4.1.8", - "vue-tsc": "^3.3.3", - "vite": "^7.3.5" + "vue-tsc": "^3.3.3" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 926a395..84f5479 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@nuxt/kit': specifier: ^4.4.8 version: 4.4.8(magicast@0.5.3) + magic-string: + specifier: ^0.30.21 + version: 0.30.21 rolldown: specifier: 1.1.0 version: 1.1.0 diff --git a/src/vite.ts b/src/vite.ts index d7cf813..2c3c97f 100644 --- a/src/vite.ts +++ b/src/vite.ts @@ -1,7 +1,10 @@ import { createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' +import MagicString from 'magic-string' import { rolldown } from 'rolldown' +import { parseAst } from 'rolldown/parseAst' import type { Plugin } from 'vite' +import type { SourceMap } from 'rolldown' import type { CosManifest } from './runtime/loader' export type { CosManifest } @@ -65,15 +68,74 @@ async function bundleLoader(entry: string): Promise { return output[0].code } -function rewriteSpecifier(code: string, from: string, to: string): string { - const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g') - const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g') - const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g') - return code - .replace(dynamic, `$1$2${to}$2$3`) - .replace(fromImport, `$1$2${to}$2`) - .replace(bareImport, `$1$2${to}$2`) +interface SourceLiteral { + value: string + start: number + end: number +} + +/** Collect every static and dynamic import/export source string literal. */ +function collectImportSources(code: string): SourceLiteral[] { + const sources: SourceLiteral[] = [] + const visit = (node: unknown): void => { + if (!node || typeof node !== 'object') { + return + } + if (Array.isArray(node)) { + for (const child of node) { + visit(child) + } + return + } + const record = node as Record & { type?: string } + if (record.type === 'ImportDeclaration' || record.type === 'ExportNamedDeclaration' + || record.type === 'ExportAllDeclaration' || record.type === 'ImportExpression') { + const source = record.source as { type?: string, value?: unknown, start?: number, end?: number } | undefined + if (source?.type === 'Literal' && typeof source.value === 'string' + && typeof source.start === 'number' && typeof source.end === 'number') { + sources.push({ value: source.value, start: source.start, end: source.end }) + } + } + for (const key in record) { + if (key !== 'type') { + visit(record[key]) + } + } + } + visit(parseAst(code)) + return sources +} + +/** + * Rewrite import/export specifiers by AST position rather than by pattern, so a + * managed specifier appearing in an ordinary string literal is never touched + * and dynamic imports are handled the same as static ones. Returns a sourcemap + * only when `withMap` is set (i.e. the source chunk already had one to keep + * valid); the standalone cos chunks have no downstream map and skip it. + */ +function rewriteSpecifiers( + code: string, + rewrites: Map, + fileName: string, + withMap: boolean, +): { code: string, map?: SourceMap } { + const sources = collectImportSources(code) + const edits = sources.filter(s => rewrites.has(s.value)) + if (!edits.length) { + return { code } + } + + const magic = new MagicString(code) + for (const { value, start, end } of edits) { + // start/end span the literal including its quotes; preserve the quote char. + const quote = code[start] + magic.overwrite(start, end, `${quote}${rewrites.get(value)!}${quote}`) + } + + return { + code: magic.toString(), + map: withMap ? magic.generateMap({ source: fileName, hires: 'boundary' }) as unknown as SourceMap : undefined, + } } function joinBase(base: string, assetsDir: string): string { @@ -203,10 +265,13 @@ export function cosPlugin(options: CosPluginOptions): Plugin { } const { code, deps } = raw.get(id)! - let resolved = code + // Resolve each dep's hash first (bottom-up), then rewrite in one pass. + const rewrites = new Map() for (const dep of deps) { - resolved = rewriteSpecifier(resolved, `cos-dep:${dep}`, contentSpecifier(visit(dep, [...stack, id]))) + rewrites.set(`cos-dep:${dep}`, contentSpecifier(visit(dep, [...stack, id]))) } + // Standalone cos chunks have no downstream sourcemap, so none is kept. + const { code: resolved } = rewriteSpecifiers(code, rewrites, '', false) const hash = createHash('sha256').update(resolved).digest('hex') const fileName = `${assetPrefix}${hash}.js` @@ -229,15 +294,24 @@ export function cosPlugin(options: CosPluginOptions): Plugin { visit(id, []) } + // App chunks only reference the packages the app imported directly, + // externalised as `cos-ext:` by this plugin's `resolveId`. + const appRewrites = new Map() + for (const id of collected) { + appRewrites.set(`cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) + } + let entry: CosManifest['entry'] | undefined for (const file of Object.values(bundle)) { if (file.type !== 'chunk') { continue } - // App chunks only reference the packages the app imported directly, - // externalised as `cos-ext:` by this plugin's `resolveId`. - for (const id of collected) { - file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) + // Keep the chunk's sourcemap valid when one exists (the consumer enabled + // `build.sourcemap`); otherwise skip map generation entirely. + const { code, map } = rewriteSpecifiers(file.code, appRewrites, file.fileName, !!file.map) + file.code = code + if (map) { + file.map = map } if (file.isEntry) { // The entry is app-specific and is re-rendered by Vite after this diff --git a/test/plugin.test.ts b/test/plugin.test.ts index aecf12b..3b85fb9 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -25,10 +25,17 @@ interface Built { assetsDir: string cosChunks: () => string[] specifiersOf: (file: string) => string[] + appChunks: () => string[] + read: (file: string) => string html: () => string } -async function buildApp(entry: string, packages: Array, alias: Alias[]): Promise { +async function buildApp( + entry: string, + packages: Array, + alias: Alias[], + options: { sourcemap?: boolean } = {}, +): Promise { mkdirSync(scratchRoot, { recursive: true }) const root = mkdtempSync(join(scratchRoot, 'app-')) const outDir = join(root, 'dist') @@ -45,17 +52,20 @@ async function buildApp(entry: string, packages: Array, alias: logLevel: 'error', resolve: { alias }, plugins: [cosPlugin({ packages })], - build: { outDir, emptyOutDir: true, rollupOptions: { input: join(root, 'index.html') } }, + build: { outDir, emptyOutDir: true, sourcemap: options.sourcemap ?? false, rollupOptions: { input: join(root, 'index.html') } }, }) + const read = (file: string): string => readFileSync(join(assetsDir, file), 'utf8') + const specifiers = (code: string): string[] => + [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] + return { outDir, assetsDir, cosChunks: () => readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f)), - specifiersOf: (file) => { - const code = readFileSync(join(assetsDir, file), 'utf8') - return [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] - }, + appChunks: () => readdirSync(assetsDir).filter(f => f.endsWith('.js') && !/^[a-f0-9]{64}\.js$/.test(f)), + read, + specifiersOf: file => specifiers(read(file)), html: () => readFileSync(join(outDir, 'index.html'), 'utf8'), } } @@ -149,3 +159,51 @@ describe('cosPlugin with a non-vue package graph (unhead + hookable)', () => { } }) }) + +describe('cosPlugin specifier rewriting', () => { + const vueAlias: Alias[] = [ + { find: /^vue$/, replacement: '' }, // replaced per-test below + ] + vueAlias[0]!.replacement = resolvePkg('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js') + + it('does not rewrite a managed specifier that appears in a string literal', async () => { + // The string "vue" is data here, not an import; AST-based rewriting must + // leave it alone while still rewriting the real import. + const app = await buildApp( + 'import { ref } from "vue"\nconst label = "vue"\ndocument.title = label + String(ref(0).value)\n', + [/^(?:vue$|@vue\/)/], + vueAlias, + ) + const entry = app.appChunks().map(f => app.read(f)).join('\n') + // The literal survives verbatim; the import is content-addressed. + expect(entry).toMatch(/["']vue["']/) + expect(entry).toMatch(/cos1:[a-f0-9]{64}/) + }, 120_000) + + it('rewrites a dynamic import of a managed package', async () => { + // Reference the dynamic import from a side effect so it is not tree-shaken. + const app = await buildApp( + 'window.addEventListener("click", () => { import("vue").then(m => { document.title = String(m.ref(0).value) }) })\n', + [/^(?:vue$|@vue\/)/], + vueAlias, + ) + const entry = app.appChunks().map(f => app.read(f)).join('\n') + expect(entry).toMatch(/import\(\s*["']cos1:[a-f0-9]{64}["']\s*\)/) + }, 120_000) + + it('keeps the chunk sourcemap valid when build.sourcemap is enabled', async () => { + const app = await buildApp( + 'import { ref } from "vue"\ndocument.title = String(ref(0).value)\n', + [/^(?:vue$|@vue\/)/], + vueAlias, + { sourcemap: true }, + ) + const rewritten = app.appChunks().find(f => app.read(f).includes('cos1:')) + expect(rewritten, 'expected a rewritten app chunk').toBeDefined() + + const map = JSON.parse(app.read(`${rewritten}.map`)) + expect(map.version).toBe(3) + expect(map.mappings.length).toBeGreaterThan(0) + expect(Array.isArray(map.sources)).toBe(true) + }, 120_000) +})