[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 const ids = [...collected] 126 if (!ids.length) { 127 return 128 } 129 const idSet = new Set(ids) 130 const base = options.base ?? joinBase(resolvedBase, assetsDir) 131 const assetPrefix = assetsDir ? `${assetsDir.replace(/^\/+|\/+$/g, '')}/` : '' 132 133 // Build each managed package once, externalising its siblings. The raw 134 // output keeps sibling imports as their resolved absolute ids, which 135 // double as the dependency edges between managed chunks. 136 const raw = new Map<string, { code: string, deps: string[] }>() 137 for (const input of ids) { 138 const builder = await rolldown({ 139 input, 140 platform: 'browser', 141 treeshake: false, 142 external: ids.filter(id => id !== input), 143 }) 144 // `minify` is part of the pinned recipe (see RECIPE): it both shrinks 145 // the chunk and strips rolldown's `//#region <path>` debug comments, 146 // which embed cwd-relative paths and would otherwise make the hash 147 // depend on the build location. 148 const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) 149 await builder.close() 150 151 const code = output[0].code 152 const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] 153 .filter(spec => idSet.has(spec)) 154 raw.set(input, { code, deps }) 155 } 156 157 // Hash bottom-up: a chunk's specifier for a dependency is that 158 // dependency's content hash, so a chunk can only be hashed once all of 159 // its dependencies have been. The npm graph for these packages is a DAG. 160 const hashes = new Map<string, string>() 161 const managed: CosManifest['chunks'] = {} 162 163 const visit = (id: string, stack: string[]): string => { 164 const existing = hashes.get(id) 165 if (existing) { 166 return existing 167 } 168 if (stack.includes(id)) { 169 throw new Error(`[cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`) 170 } 171 172 const { code, deps } = raw.get(id)! 173 let resolved = code 174 for (const dep of deps) { 175 resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id]))) 176 } 177 178 const hash = createHash('sha256').update(resolved).digest('hex') 179 const fileName = `${assetPrefix}${hash}.js` 180 hashes.set(id, hash) 181 managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash } 182 bundle[fileName] = { 183 type: 'asset', 184 fileName, 185 name: hash, 186 names: [hash], 187 originalFileName: null, 188 originalFileNames: [], 189 needsCodeReference: false, 190 source: resolved, 191 } 192 return hash 193 } 194 195 for (const id of ids) { 196 visit(id, []) 197 } 198 199 let entry: CosManifest['entry'] | undefined 200 for (const file of Object.values(bundle)) { 201 if (file.type !== 'chunk') { 202 continue 203 } 204 for (const id of ids) { 205 file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 206 } 207 if (file.isEntry) { 208 // The entry is app-specific and is re-rendered by Vite after this 209 // hook, so it cannot be content-addressed here; it loads from the 210 // network under a stable specifier instead. 211 entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(new RegExp(`^${assetPrefix}`), '') } 212 } 213 } 214 215 if (!entry) { 216 return 217 } 218 219 const manifest: CosManifest = { base, entry, chunks: managed } 220 loaderTemplate ??= bundleLoader(loaderEntry) 221 scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest)) 222 options.onGenerated?.(scriptContent) 223 }, 224 transformIndexHtml: { 225 order: 'post', 226 handler(html) { 227 if (options.onGenerated || !scriptContent) { 228 return html 229 } 230 return html 231 .replace(/<script type="module"[^>]*src="[^"]*"[^>]*><\/script>/g, '') 232 .replace('</head>', `<script id="cos-loader">${scriptContent}</script></head>`) 233 }, 234 }, 235 } 236} 237 238export default cosPlugin