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