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