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