[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.

1import { createHash } from 'node:crypto' 2import { existsSync } from 'node:fs' 3import { fileURLToPath } from 'node:url' 4import MagicString from 'magic-string' 5import { rolldown } from 'rolldown' 6import { parseAst } from 'rolldown/parseAst' 7import type { Plugin } from 'vite' 8import type { SourceMap } from 'rolldown' 9import type { CosManifest } from './loader' 10 11export type { CosManifest } 12 13const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__' 14 15/** 16 * Recipe version embedded in every content-addressed specifier. Bump this 17 * whenever the build recipe (bundler version, options, define replacements) 18 * changes in a way that alters emitted bytes, so chunks built under different 19 * recipes cannot silently collide on the same SHA-256. 20 */ 21const RECIPE = 'cos1' 22 23// Resolve the loader entry next to this module: `.mjs` when built (dist), 24// `.ts` when run from source (tests). The plugin rolldown-bundles whichever 25// exists into the injected `<script>`. 26function defaultLoaderEntry(): string { 27 for (const ext of ['mjs', 'ts']) { 28 const candidate = fileURLToPath(new URL(`./loader.entry.${ext}`, import.meta.url)) 29 if (existsSync(candidate)) { 30 return candidate 31 } 32 } 33 throw new Error('[cos] could not locate the runtime loader entry') 34} 35 36export interface CosPluginOptions { 37 /** 38 * Packages to extract into standalone Cross-Origin Storage chunks. Each entry 39 * is matched against the imported module specifier; a plain string is treated 40 * as an exact match. 41 */ 42 packages: Array<string | RegExp> 43 /** 44 * Public base path the managed chunks are served from. Defaults to Vite's 45 * resolved `base` joined with `build.assetsDir`. 46 */ 47 base?: string 48 /** 49 * Path to the runtime loader entry to bundle into the injected `<script>`. 50 * Defaults to the bundled loader. Override only to swap the loader runtime. 51 */ 52 loaderEntry?: string 53 /** 54 * Called once the managed chunks are emitted, with the loader `<script>` body 55 * (loader IIFE + inlined manifest). SSR frameworks should inject this into 56 * their rendered HTML themselves. When omitted, the plugin injects it into 57 * `index.html` via `transformIndexHtml` for plain client builds. 58 */ 59 onGenerated?: (scriptContent: string) => void 60} 61 62function contentSpecifier(hash: string): string { 63 return `${RECIPE}:${hash}` 64} 65 66function toMatchers(packages: Array<string | RegExp>): RegExp[] { 67 return packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p) 68} 69 70/** 71 * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving 72 * `__COS_MANIFEST__` as a literal token for the caller to substitute. Bundling 73 * from source keeps the loader correct regardless of how the host build loaded 74 * this plugin. 75 */ 76async function bundleLoader(entry: string): Promise<string> { 77 const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true }) 78 const { output } = await builder.generate({ format: 'iife', minify: true }) 79 await builder.close() 80 return output[0].code 81} 82 83interface SourceLiteral { 84 value: string 85 start: number 86 end: number 87} 88 89/** Collect every static and dynamic import/export source string literal. */ 90function collectImportSources(code: string): SourceLiteral[] { 91 const sources: SourceLiteral[] = [] 92 const visit = (node: unknown): void => { 93 if (!node || typeof node !== 'object') { 94 return 95 } 96 if (Array.isArray(node)) { 97 for (const child of node) { 98 visit(child) 99 } 100 return 101 } 102 const record = node as Record<string, unknown> & { type?: string } 103 if (record.type === 'ImportDeclaration' || record.type === 'ExportNamedDeclaration' 104 || record.type === 'ExportAllDeclaration' || record.type === 'ImportExpression') { 105 const source = record.source as { type?: string, value?: unknown, start?: number, end?: number } | undefined 106 if (source?.type === 'Literal' && typeof source.value === 'string' 107 && typeof source.start === 'number' && typeof source.end === 'number') { 108 sources.push({ value: source.value, start: source.start, end: source.end }) 109 } 110 } 111 for (const key in record) { 112 if (key !== 'type') { 113 visit(record[key]) 114 } 115 } 116 } 117 visit(parseAst(code)) 118 return sources 119} 120 121/** 122 * Rewrite import/export specifiers by AST position rather than by pattern, so a 123 * managed specifier appearing in an ordinary string literal is never touched 124 * and dynamic imports are handled the same as static ones. Returns a sourcemap 125 * only when `withMap` is set (i.e. the source chunk already had one to keep 126 * valid); the standalone cos chunks have no downstream map and skip it. 127 */ 128function rewriteSpecifiers( 129 code: string, 130 rewrites: Map<string, string>, 131 fileName: string, 132 withMap: boolean, 133): { code: string, map?: SourceMap } { 134 const sources = collectImportSources(code) 135 const edits = sources.filter(s => rewrites.has(s.value)) 136 if (!edits.length) { 137 return { code } 138 } 139 140 const magic = new MagicString(code) 141 for (const { value, start, end } of edits) { 142 // start/end span the literal including its quotes; preserve the quote char. 143 const quote = code[start] 144 magic.overwrite(start, end, `${quote}${rewrites.get(value)!}${quote}`) 145 } 146 147 return { 148 code: magic.toString(), 149 map: withMap ? magic.generateMap({ source: fileName, hires: 'boundary' }) as unknown as SourceMap : undefined, 150 } 151} 152 153function joinBase(base: string, assetsDir: string): string { 154 const prefix = base.endsWith('/') ? base : `${base}/` 155 const dir = assetsDir.replace(/^\/+|\/+$/g, '') 156 return dir ? `${prefix}${dir}/` : prefix 157} 158 159export function cosPlugin(options: CosPluginOptions): Plugin { 160 const packages = toMatchers(options.packages) 161 const loaderEntry = options.loaderEntry ?? defaultLoaderEntry() 162 163 const collected = new Set<string>() 164 let assetsDir = 'assets' 165 let resolvedBase = '/' 166 let loaderTemplate: Promise<string> | undefined 167 let scriptContent = '' 168 169 return { 170 name: 'vite-plugin-cos', 171 enforce: 'pre', 172 apply: 'build', 173 configResolved(config) { 174 assetsDir = config.build.assetsDir 175 resolvedBase = config.base 176 }, 177 resolveId: { 178 order: 'pre', 179 filter: { id: packages }, 180 async handler(id, importer, resolveOptions) { 181 const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true }) 182 if (!resolved) { 183 return 184 } 185 186 collected.add(resolved.id) 187 188 // Externalise under a synthetic specifier so it never clashes with the 189 // real module id elsewhere in the app graph. It is rewritten to a 190 // content-addressed specifier in `generateBundle`, once every managed 191 // chunk has been hashed bottom-up. 192 return { id: `cos-ext:${resolved.id}`, external: true } 193 }, 194 }, 195 async generateBundle(_outputOptions, bundle) { 196 if (!collected.size) { 197 return 198 } 199 const base = options.base ?? joinBase(resolvedBase, assetsDir) 200 const assetPrefix = assetsDir ? `${assetsDir.replace(/^\/+|\/+$/g, '')}/` : '' 201 202 // Build each managed package standalone, externalising every dependency 203 // by its resolved absolute id. Transitive dependencies are discovered and 204 // queued here, so managing a package implicitly manages its whole import 205 // subgraph (e.g. `vue` pulls in `@vue/*`) without the app having to list 206 // them. The externalised ids double as the edges between managed chunks. 207 const raw = new Map<string, { code: string, deps: string[] }>() 208 const queue = [...collected] 209 while (queue.length) { 210 const input = queue.shift()! 211 if (raw.has(input)) { 212 continue 213 } 214 215 const deps = new Set<string>() 216 let code: string 217 try { 218 const builder = await rolldown({ 219 input, 220 platform: 'browser', 221 treeshake: false, 222 plugins: [{ 223 name: 'cos-externalise-deps', 224 async resolveId(id, importer) { 225 if (!importer) { 226 return null 227 } 228 const dep = await this.resolve(id, importer, { skipSelf: true }) 229 if (!dep) { 230 return null 231 } 232 deps.add(dep.id) 233 // Externalise under a synthetic specifier keyed by the resolved 234 // id, so the emitted import is a literal token we rewrite later. 235 // Source specifiers may be relative (`./shared/x.mjs`); the 236 // token makes the rewrite independent of how they were written. 237 return { id: `cos-dep:${dep.id}`, external: true } 238 }, 239 }], 240 }) 241 // `minify` is part of the pinned recipe (see RECIPE): it both shrinks 242 // the chunk and strips rolldown's `//#region <path>` debug comments, 243 // which embed cwd-relative paths and would otherwise make the hash 244 // depend on the build location. 245 const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) 246 await builder.close() 247 code = output[0].code 248 } 249 catch (error) { 250 throw new Error( 251 `[cos] cannot bundle managed package as a standalone chunk:\n ${input}\n` 252 + `It likely imports build-time virtuals (e.g. \`#build/*\`, \`#imports\`) that only ` 253 + `resolve inside the host build, so it is not a self-contained, shareable artifact. ` 254 + `Only depend on packages whose source resolves from disk on its own.\n\n` 255 + `Underlying error: ${(error as Error).message}`, 256 { cause: error }, 257 ) 258 } 259 260 raw.set(input, { code, deps: [...deps] }) 261 queue.push(...deps) 262 } 263 264 // Hash bottom-up: a chunk's specifier for a dependency is that 265 // dependency's content hash, so a chunk can only be hashed once all of 266 // its dependencies have been. The npm graph for these packages is a DAG. 267 const hashes = new Map<string, string>() 268 const managed: CosManifest['chunks'] = {} 269 270 const visit = (id: string, stack: string[]): string => { 271 const existing = hashes.get(id) 272 if (existing) { 273 return existing 274 } 275 if (stack.includes(id)) { 276 throw new Error(`[cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`) 277 } 278 279 const { code, deps } = raw.get(id)! 280 // Resolve each dep's hash first (bottom-up), then rewrite in one pass. 281 const rewrites = new Map<string, string>() 282 for (const dep of deps) { 283 rewrites.set(`cos-dep:${dep}`, contentSpecifier(visit(dep, [...stack, id]))) 284 } 285 // Standalone cos chunks have no downstream sourcemap, so none is kept. 286 const { code: resolved } = rewriteSpecifiers(code, rewrites, '', false) 287 288 const hash = createHash('sha256').update(resolved).digest('hex') 289 const fileName = `${assetPrefix}${hash}.js` 290 hashes.set(id, hash) 291 managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash } 292 bundle[fileName] = { 293 type: 'asset', 294 fileName, 295 name: hash, 296 names: [hash], 297 originalFileName: null, 298 originalFileNames: [], 299 needsCodeReference: false, 300 source: resolved, 301 } 302 return hash 303 } 304 305 for (const id of raw.keys()) { 306 visit(id, []) 307 } 308 309 // App chunks only reference the packages the app imported directly, 310 // externalised as `cos-ext:<id>` by this plugin's `resolveId`. 311 const appRewrites = new Map<string, string>() 312 for (const id of collected) { 313 appRewrites.set(`cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 314 } 315 316 let entry: CosManifest['entry'] | undefined 317 for (const file of Object.values(bundle)) { 318 if (file.type !== 'chunk') { 319 continue 320 } 321 // Keep the chunk's sourcemap valid when one exists (the consumer enabled 322 // `build.sourcemap`); otherwise skip map generation entirely. 323 const { code, map } = rewriteSpecifiers(file.code, appRewrites, file.fileName, !!file.map) 324 file.code = code 325 if (map) { 326 file.map = map 327 } 328 if (file.isEntry) { 329 // The entry is app-specific and is re-rendered by Vite after this 330 // hook, so it cannot be content-addressed here; it loads from the 331 // network under a stable specifier instead. 332 entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(new RegExp(`^${assetPrefix}`), '') } 333 } 334 } 335 336 if (!entry) { 337 return 338 } 339 340 const manifest: CosManifest = { base, entry, chunks: managed } 341 loaderTemplate ??= bundleLoader(loaderEntry) 342 scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest)) 343 options.onGenerated?.(scriptContent) 344 }, 345 transformIndexHtml: { 346 order: 'post', 347 handler(html) { 348 if (options.onGenerated || !scriptContent) { 349 return html 350 } 351 return html 352 .replace(/<script type="module"[^>]*src="[^"]*"[^>]*><\/script>/g, '') 353 .replace('</head>', `<script id="cos-loader">${scriptContent}</script></head>`) 354 }, 355 }, 356 } 357} 358 359export default cosPlugin