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