[READ-ONLY] Mirror of https://github.com/danielroe/cross-origin-storage. Load shared dependencies from Cross-Origin Storage (COS).
cross-origin-storage experimental nuxt vite vite-plugin
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: rewrite specifiers via AST with conditional sourcemap

+159 -23
+3 -2
package.json
··· 35 35 }, 36 36 "dependencies": { 37 37 "@nuxt/kit": "^4.4.8", 38 + "magic-string": "^0.30.21", 38 39 "rolldown": "1.1.0" 39 40 }, 40 41 "devDependencies": { ··· 49 50 "nuxt": "^4.4.8", 50 51 "playwright-core": "^1.61.1", 51 52 "typescript": "~6.0.3", 53 + "vite": "^7.3.5", 52 54 "vitest": "^4.1.8", 53 - "vue-tsc": "^3.3.3", 54 - "vite": "^7.3.5" 55 + "vue-tsc": "^3.3.3" 55 56 }, 56 57 "peerDependencies": { 57 58 "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
+3
pnpm-lock.yaml
··· 14 14 '@nuxt/kit': 15 15 specifier: ^4.4.8 16 16 version: 4.4.8(magicast@0.5.3) 17 + magic-string: 18 + specifier: ^0.30.21 19 + version: 0.30.21 17 20 rolldown: 18 21 specifier: 1.1.0 19 22 version: 1.1.0
+89 -15
src/vite.ts
··· 1 1 import { createHash } from 'node:crypto' 2 2 import { fileURLToPath } from 'node:url' 3 + import MagicString from 'magic-string' 3 4 import { rolldown } from 'rolldown' 5 + import { parseAst } from 'rolldown/parseAst' 4 6 import type { Plugin } from 'vite' 7 + import type { SourceMap } from 'rolldown' 5 8 import type { CosManifest } from './runtime/loader' 6 9 7 10 export type { CosManifest } ··· 65 68 return output[0].code 66 69 } 67 70 68 - function rewriteSpecifier(code: string, from: string, to: string): string { 69 - const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 70 - const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g') 71 - const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g') 72 - const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g') 73 - return code 74 - .replace(dynamic, `$1$2${to}$2$3`) 75 - .replace(fromImport, `$1$2${to}$2`) 76 - .replace(bareImport, `$1$2${to}$2`) 71 + interface SourceLiteral { 72 + value: string 73 + start: number 74 + end: number 75 + } 76 + 77 + /** Collect every static and dynamic import/export source string literal. */ 78 + function collectImportSources(code: string): SourceLiteral[] { 79 + const sources: SourceLiteral[] = [] 80 + const visit = (node: unknown): void => { 81 + if (!node || typeof node !== 'object') { 82 + return 83 + } 84 + if (Array.isArray(node)) { 85 + for (const child of node) { 86 + visit(child) 87 + } 88 + return 89 + } 90 + const record = node as Record<string, unknown> & { type?: string } 91 + if (record.type === 'ImportDeclaration' || record.type === 'ExportNamedDeclaration' 92 + || record.type === 'ExportAllDeclaration' || record.type === 'ImportExpression') { 93 + const source = record.source as { type?: string, value?: unknown, start?: number, end?: number } | undefined 94 + if (source?.type === 'Literal' && typeof source.value === 'string' 95 + && typeof source.start === 'number' && typeof source.end === 'number') { 96 + sources.push({ value: source.value, start: source.start, end: source.end }) 97 + } 98 + } 99 + for (const key in record) { 100 + if (key !== 'type') { 101 + visit(record[key]) 102 + } 103 + } 104 + } 105 + visit(parseAst(code)) 106 + return sources 107 + } 108 + 109 + /** 110 + * Rewrite import/export specifiers by AST position rather than by pattern, so a 111 + * managed specifier appearing in an ordinary string literal is never touched 112 + * and dynamic imports are handled the same as static ones. Returns a sourcemap 113 + * only when `withMap` is set (i.e. the source chunk already had one to keep 114 + * valid); the standalone cos chunks have no downstream map and skip it. 115 + */ 116 + function rewriteSpecifiers( 117 + code: string, 118 + rewrites: Map<string, string>, 119 + fileName: string, 120 + withMap: boolean, 121 + ): { code: string, map?: SourceMap } { 122 + const sources = collectImportSources(code) 123 + const edits = sources.filter(s => rewrites.has(s.value)) 124 + if (!edits.length) { 125 + return { code } 126 + } 127 + 128 + const magic = new MagicString(code) 129 + for (const { value, start, end } of edits) { 130 + // start/end span the literal including its quotes; preserve the quote char. 131 + const quote = code[start] 132 + magic.overwrite(start, end, `${quote}${rewrites.get(value)!}${quote}`) 133 + } 134 + 135 + return { 136 + code: magic.toString(), 137 + map: withMap ? magic.generateMap({ source: fileName, hires: 'boundary' }) as unknown as SourceMap : undefined, 138 + } 77 139 } 78 140 79 141 function joinBase(base: string, assetsDir: string): string { ··· 203 265 } 204 266 205 267 const { code, deps } = raw.get(id)! 206 - let resolved = code 268 + // Resolve each dep's hash first (bottom-up), then rewrite in one pass. 269 + const rewrites = new Map<string, string>() 207 270 for (const dep of deps) { 208 - resolved = rewriteSpecifier(resolved, `cos-dep:${dep}`, contentSpecifier(visit(dep, [...stack, id]))) 271 + rewrites.set(`cos-dep:${dep}`, contentSpecifier(visit(dep, [...stack, id]))) 209 272 } 273 + // Standalone cos chunks have no downstream sourcemap, so none is kept. 274 + const { code: resolved } = rewriteSpecifiers(code, rewrites, '', false) 210 275 211 276 const hash = createHash('sha256').update(resolved).digest('hex') 212 277 const fileName = `${assetPrefix}${hash}.js` ··· 229 294 visit(id, []) 230 295 } 231 296 297 + // App chunks only reference the packages the app imported directly, 298 + // externalised as `cos-ext:<id>` by this plugin's `resolveId`. 299 + const appRewrites = new Map<string, string>() 300 + for (const id of collected) { 301 + appRewrites.set(`cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 302 + } 303 + 232 304 let entry: CosManifest['entry'] | undefined 233 305 for (const file of Object.values(bundle)) { 234 306 if (file.type !== 'chunk') { 235 307 continue 236 308 } 237 - // App chunks only reference the packages the app imported directly, 238 - // externalised as `cos-ext:<id>` by this plugin's `resolveId`. 239 - for (const id of collected) { 240 - file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 309 + // Keep the chunk's sourcemap valid when one exists (the consumer enabled 310 + // `build.sourcemap`); otherwise skip map generation entirely. 311 + const { code, map } = rewriteSpecifiers(file.code, appRewrites, file.fileName, !!file.map) 312 + file.code = code 313 + if (map) { 314 + file.map = map 241 315 } 242 316 if (file.isEntry) { 243 317 // The entry is app-specific and is re-rendered by Vite after this
+64 -6
test/plugin.test.ts
··· 25 25 assetsDir: string 26 26 cosChunks: () => string[] 27 27 specifiersOf: (file: string) => string[] 28 + appChunks: () => string[] 29 + read: (file: string) => string 28 30 html: () => string 29 31 } 30 32 31 - async function buildApp(entry: string, packages: Array<string | RegExp>, alias: Alias[]): Promise<Built> { 33 + async function buildApp( 34 + entry: string, 35 + packages: Array<string | RegExp>, 36 + alias: Alias[], 37 + options: { sourcemap?: boolean } = {}, 38 + ): Promise<Built> { 32 39 mkdirSync(scratchRoot, { recursive: true }) 33 40 const root = mkdtempSync(join(scratchRoot, 'app-')) 34 41 const outDir = join(root, 'dist') ··· 45 52 logLevel: 'error', 46 53 resolve: { alias }, 47 54 plugins: [cosPlugin({ packages })], 48 - build: { outDir, emptyOutDir: true, rollupOptions: { input: join(root, 'index.html') } }, 55 + build: { outDir, emptyOutDir: true, sourcemap: options.sourcemap ?? false, rollupOptions: { input: join(root, 'index.html') } }, 49 56 }) 50 57 58 + const read = (file: string): string => readFileSync(join(assetsDir, file), 'utf8') 59 + const specifiers = (code: string): string[] => 60 + [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] 61 + 51 62 return { 52 63 outDir, 53 64 assetsDir, 54 65 cosChunks: () => readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f)), 55 - specifiersOf: (file) => { 56 - const code = readFileSync(join(assetsDir, file), 'utf8') 57 - return [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] 58 - }, 66 + appChunks: () => readdirSync(assetsDir).filter(f => f.endsWith('.js') && !/^[a-f0-9]{64}\.js$/.test(f)), 67 + read, 68 + specifiersOf: file => specifiers(read(file)), 59 69 html: () => readFileSync(join(outDir, 'index.html'), 'utf8'), 60 70 } 61 71 } ··· 149 159 } 150 160 }) 151 161 }) 162 + 163 + describe('cosPlugin specifier rewriting', () => { 164 + const vueAlias: Alias[] = [ 165 + { find: /^vue$/, replacement: '' }, // replaced per-test below 166 + ] 167 + vueAlias[0]!.replacement = resolvePkg('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js') 168 + 169 + it('does not rewrite a managed specifier that appears in a string literal', async () => { 170 + // The string "vue" is data here, not an import; AST-based rewriting must 171 + // leave it alone while still rewriting the real import. 172 + const app = await buildApp( 173 + 'import { ref } from "vue"\nconst label = "vue"\ndocument.title = label + String(ref(0).value)\n', 174 + [/^(?:vue$|@vue\/)/], 175 + vueAlias, 176 + ) 177 + const entry = app.appChunks().map(f => app.read(f)).join('\n') 178 + // The literal survives verbatim; the import is content-addressed. 179 + expect(entry).toMatch(/["']vue["']/) 180 + expect(entry).toMatch(/cos1:[a-f0-9]{64}/) 181 + }, 120_000) 182 + 183 + it('rewrites a dynamic import of a managed package', async () => { 184 + // Reference the dynamic import from a side effect so it is not tree-shaken. 185 + const app = await buildApp( 186 + 'window.addEventListener("click", () => { import("vue").then(m => { document.title = String(m.ref(0).value) }) })\n', 187 + [/^(?:vue$|@vue\/)/], 188 + vueAlias, 189 + ) 190 + const entry = app.appChunks().map(f => app.read(f)).join('\n') 191 + expect(entry).toMatch(/import\(\s*["']cos1:[a-f0-9]{64}["']\s*\)/) 192 + }, 120_000) 193 + 194 + it('keeps the chunk sourcemap valid when build.sourcemap is enabled', async () => { 195 + const app = await buildApp( 196 + 'import { ref } from "vue"\ndocument.title = String(ref(0).value)\n', 197 + [/^(?:vue$|@vue\/)/], 198 + vueAlias, 199 + { sourcemap: true }, 200 + ) 201 + const rewritten = app.appChunks().find(f => app.read(f).includes('cos1:')) 202 + expect(rewritten, 'expected a rewritten app chunk').toBeDefined() 203 + 204 + const map = JSON.parse(app.read(`${rewritten}.map`)) 205 + expect(map.version).toBe(3) 206 + expect(map.mappings.length).toBeGreaterThan(0) 207 + expect(Array.isArray(map.sources)).toBe(true) 208 + }, 120_000) 209 + })