[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 { rolldown } from 'rolldown'
4import type { Plugin } from 'vite'
5import type { CosManifest } from './runtime/loader'
6
7export type { CosManifest }
8
9const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__'
10
11/**
12 * Recipe version embedded in every content-addressed specifier. Bump this
13 * whenever the build recipe (bundler version, options, define replacements)
14 * changes in a way that alters emitted bytes, so chunks built under different
15 * recipes cannot silently collide on the same SHA-256.
16 */
17const RECIPE = 'cos1'
18
19const DEFAULT_LOADER_ENTRY = fileURLToPath(new URL('./runtime/loader.entry.js', import.meta.url))
20
21export interface CosPluginOptions {
22 /**
23 * Packages to extract into standalone Cross-Origin Storage chunks. Each entry
24 * is matched against the imported module specifier; a plain string is treated
25 * as an exact match.
26 */
27 packages: Array<string | RegExp>
28 /**
29 * Public base path the managed chunks are served from. Defaults to Vite's
30 * resolved `base` joined with `build.assetsDir`.
31 */
32 base?: string
33 /**
34 * Path to the runtime loader entry to bundle into the injected `<script>`.
35 * Defaults to the bundled loader. Override only to swap the loader runtime.
36 */
37 loaderEntry?: string
38 /**
39 * Called once the managed chunks are emitted, with the loader `<script>` body
40 * (loader IIFE + inlined manifest). SSR frameworks should inject this into
41 * their rendered HTML themselves. When omitted, the plugin injects it into
42 * `index.html` via `transformIndexHtml` for plain client builds.
43 */
44 onGenerated?: (scriptContent: string) => void
45}
46
47function contentSpecifier(hash: string): string {
48 return `${RECIPE}:${hash}`
49}
50
51function toMatchers(packages: Array<string | RegExp>): RegExp[] {
52 return packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p)
53}
54
55/**
56 * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving
57 * `__COS_MANIFEST__` as a literal token for the caller to substitute. Bundling
58 * from source keeps the loader correct regardless of how the host build loaded
59 * this plugin.
60 */
61async function bundleLoader(entry: string): Promise<string> {
62 const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true })
63 const { output } = await builder.generate({ format: 'iife', minify: true })
64 await builder.close()
65 return output[0].code
66}
67
68function 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`)
77}
78
79function joinBase(base: string, assetsDir: string): string {
80 const prefix = base.endsWith('/') ? base : `${base}/`
81 const dir = assetsDir.replace(/^\/+|\/+$/g, '')
82 return dir ? `${prefix}${dir}/` : prefix
83}
84
85export function cosPlugin(options: CosPluginOptions): Plugin {
86 const packages = toMatchers(options.packages)
87 const loaderEntry = options.loaderEntry ?? DEFAULT_LOADER_ENTRY
88
89 const collected = new Set<string>()
90 let assetsDir = 'assets'
91 let resolvedBase = '/'
92 let loaderTemplate: Promise<string> | undefined
93 let scriptContent = ''
94
95 return {
96 name: 'vite-plugin-cos',
97 enforce: 'pre',
98 apply: 'build',
99 configResolved(config) {
100 assetsDir = config.build.assetsDir
101 resolvedBase = config.base
102 },
103 resolveId: {
104 order: 'pre',
105 async handler(id, importer, resolveOptions) {
106 if (!packages.some(p => p.test(id))) {
107 return
108 }
109
110 const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true })
111 if (!resolved) {
112 return
113 }
114
115 collected.add(resolved.id)
116
117 // Externalise under a synthetic specifier so it never clashes with the
118 // real module id elsewhere in the app graph. It is rewritten to a
119 // content-addressed specifier in `generateBundle`, once every managed
120 // chunk has been hashed bottom-up.
121 return { id: `cos-ext:${resolved.id}`, external: true }
122 },
123 },
124 async generateBundle(_outputOptions, bundle) {
125 if (!collected.size) {
126 return
127 }
128 const base = options.base ?? joinBase(resolvedBase, assetsDir)
129 const assetPrefix = assetsDir ? `${assetsDir.replace(/^\/+|\/+$/g, '')}/` : ''
130
131 // Build each managed package standalone, externalising every dependency
132 // by its resolved absolute id. Transitive dependencies are discovered and
133 // queued here, so managing a package implicitly manages its whole import
134 // subgraph (e.g. `vue` pulls in `@vue/*`) without the app having to list
135 // them. The externalised ids double as the edges between managed chunks.
136 const raw = new Map<string, { code: string, deps: string[] }>()
137 const queue = [...collected]
138 while (queue.length) {
139 const input = queue.shift()!
140 if (raw.has(input)) {
141 continue
142 }
143
144 const deps = new Set<string>()
145 let code: string
146 try {
147 const builder = await rolldown({
148 input,
149 platform: 'browser',
150 treeshake: false,
151 plugins: [{
152 name: 'cos-externalise-deps',
153 async resolveId(id, importer) {
154 if (!importer) {
155 return null
156 }
157 const dep = await this.resolve(id, importer, { skipSelf: true })
158 if (!dep) {
159 return null
160 }
161 deps.add(dep.id)
162 // Externalise under a synthetic specifier keyed by the resolved
163 // id, so the emitted import is a literal token we rewrite later.
164 // Source specifiers may be relative (`./shared/x.mjs`); the
165 // token makes the rewrite independent of how they were written.
166 return { id: `cos-dep:${dep.id}`, external: true }
167 },
168 }],
169 })
170 // `minify` is part of the pinned recipe (see RECIPE): it both shrinks
171 // the chunk and strips rolldown's `//#region <path>` debug comments,
172 // which embed cwd-relative paths and would otherwise make the hash
173 // depend on the build location.
174 const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true })
175 await builder.close()
176 code = output[0].code
177 }
178 catch (error) {
179 throw new Error(
180 `[cos] cannot bundle managed package as a standalone chunk:\n ${input}\n`
181 + `It likely imports build-time virtuals (e.g. \`#build/*\`, \`#imports\`) that only `
182 + `resolve inside the host build, so it is not a self-contained, shareable artifact. `
183 + `Only depend on packages whose source resolves from disk on its own.\n\n`
184 + `Underlying error: ${(error as Error).message}`,
185 { cause: error },
186 )
187 }
188
189 raw.set(input, { code, deps: [...deps] })
190 queue.push(...deps)
191 }
192
193 // Hash bottom-up: a chunk's specifier for a dependency is that
194 // dependency's content hash, so a chunk can only be hashed once all of
195 // its dependencies have been. The npm graph for these packages is a DAG.
196 const hashes = new Map<string, string>()
197 const managed: CosManifest['chunks'] = {}
198
199 const visit = (id: string, stack: string[]): string => {
200 const existing = hashes.get(id)
201 if (existing) {
202 return existing
203 }
204 if (stack.includes(id)) {
205 throw new Error(`[cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`)
206 }
207
208 const { code, deps } = raw.get(id)!
209 let resolved = code
210 for (const dep of deps) {
211 resolved = rewriteSpecifier(resolved, `cos-dep:${dep}`, contentSpecifier(visit(dep, [...stack, id])))
212 }
213
214 const hash = createHash('sha256').update(resolved).digest('hex')
215 const fileName = `${assetPrefix}${hash}.js`
216 hashes.set(id, hash)
217 managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash }
218 bundle[fileName] = {
219 type: 'asset',
220 fileName,
221 name: hash,
222 names: [hash],
223 originalFileName: null,
224 originalFileNames: [],
225 needsCodeReference: false,
226 source: resolved,
227 }
228 return hash
229 }
230
231 for (const id of raw.keys()) {
232 visit(id, [])
233 }
234
235 let entry: CosManifest['entry'] | undefined
236 for (const file of Object.values(bundle)) {
237 if (file.type !== 'chunk') {
238 continue
239 }
240 // App chunks only reference the packages the app imported directly,
241 // externalised as `cos-ext:<id>` by this plugin's `resolveId`.
242 for (const id of collected) {
243 file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!))
244 }
245 if (file.isEntry) {
246 // The entry is app-specific and is re-rendered by Vite after this
247 // hook, so it cannot be content-addressed here; it loads from the
248 // network under a stable specifier instead.
249 entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(new RegExp(`^${assetPrefix}`), '') }
250 }
251 }
252
253 if (!entry) {
254 return
255 }
256
257 const manifest: CosManifest = { base, entry, chunks: managed }
258 loaderTemplate ??= bundleLoader(loaderEntry)
259 scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest))
260 options.onGenerated?.(scriptContent)
261 },
262 transformIndexHtml: {
263 order: 'post',
264 handler(html) {
265 if (options.onGenerated || !scriptContent) {
266 return html
267 }
268 return html
269 .replace(/<script type="module"[^>]*src="[^"]*"[^>]*><\/script>/g, '')
270 .replace('</head>', `<script id="cos-loader">${scriptContent}</script></head>`)
271 },
272 },
273 }
274}
275
276export default cosPlugin