[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 { defineNuxtModule, addServerPlugin, addVitePlugin, createResolver } from '@nuxt/kit' 3import { rolldown } from 'rolldown' 4import type { CosManifest } from './runtime/loader' 5 6const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__' 7 8/** 9 * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving 10 * `__COS_MANIFEST__` as a literal token for the caller to substitute. 11 */ 12async function bundleLoader(entry: string): Promise<string> { 13 const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true }) 14 const { output } = await builder.generate({ format: 'iife', minify: true }) 15 await builder.close() 16 return output[0].code 17} 18 19export interface ModuleOptions { 20 /** 21 * Packages to extract into standalone Cross-Origin Storage chunks. 22 * Each entry is matched against the imported module specifier; a plain 23 * string is treated as an exact match. 24 */ 25 packages: Array<string | RegExp> 26} 27 28/** 29 * Recipe version embedded in every content-addressed specifier, meant to be bumped 30 * whenever the build recipe (bundler version, options, define replacements) 31 * changes in a way that alters emitted bytes, so chunks built under different 32 * recipes cannot silently collide on the same SHA-256. 33 */ 34const RECIPE = 'cos1' 35 36function contentSpecifier(hash: string): string { 37 return `${RECIPE}:${hash}` 38} 39 40export default defineNuxtModule<ModuleOptions>({ 41 meta: { 42 name: 'nuxt-cos', 43 configKey: 'cos', 44 }, 45 defaults: { 46 packages: [/^(?:vue$|@vue\/)/], 47 }, 48 setup(options, nuxt) { 49 if (nuxt.options.dev) { 50 return 51 } 52 53 const resolver = createResolver(import.meta.url) 54 const packages = options.packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p) 55 56 const loaderEntry = resolver.resolve('./runtime/loader.entry') 57 let loaderTemplate: Promise<string> | undefined 58 let scriptContent = '' 59 60 nuxt.options.nitro.virtual ||= {} 61 nuxt.options.nitro.virtual['virtual:cos-loader'] = () => `export default ${JSON.stringify(scriptContent)}` 62 63 addServerPlugin(resolver.resolve('./runtime/server/plugins/inject')) 64 65 const collected = new Set<string>() 66 67 addVitePlugin(() => ({ 68 name: 'nuxt-cos', 69 enforce: 'pre', 70 resolveId: { 71 order: 'pre', 72 async handler(id, importer, resolveOptions) { 73 if (!packages.some(p => p.test(id))) { 74 return 75 } 76 77 const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true }) 78 if (!resolved) { 79 return 80 } 81 82 collected.add(resolved.id) 83 84 // Externalise under a synthetic specifier so it never clashes with the 85 // real module id elsewhere in the app graph. It is rewritten to a 86 // content-addressed specifier in `generateBundle`, once every managed 87 // chunk has been hashed bottom-up. 88 return { id: `cos-ext:${resolved.id}`, external: true } 89 }, 90 }, 91 async generateBundle(_outputOptions, bundle) { 92 const ids = [...collected] 93 const idSet = new Set(ids) 94 95 // Build each managed package once, externalising its siblings. The raw 96 // output keeps sibling imports as their resolved absolute ids, which 97 // double as the dependency edges between managed chunks. 98 const raw = new Map<string, { code: string, deps: string[] }>() 99 for (const input of ids) { 100 const builder = await rolldown({ 101 input, 102 platform: 'browser', 103 treeshake: false, 104 external: ids.filter(id => id !== input), 105 }) 106 const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) 107 await builder.close() 108 109 const code = output[0].code 110 const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] 111 .filter(spec => idSet.has(spec)) 112 raw.set(input, { code, deps }) 113 } 114 115 // Hash bottom-up: a chunk's specifier for a dependency is that 116 // dependency's content hash, so a chunk can only be hashed once all of 117 // its dependencies have been. The npm graph for these packages is a DAG. 118 const hashes = new Map<string, string>() 119 const managed: CosManifest['chunks'] = {} 120 121 const visit = (id: string, stack: string[]): string => { 122 const existing = hashes.get(id) 123 if (existing) { 124 return existing 125 } 126 if (stack.includes(id)) { 127 throw new Error(`[nuxt-cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`) 128 } 129 130 const { code, deps } = raw.get(id)! 131 let resolved = code 132 for (const dep of deps) { 133 resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id]))) 134 } 135 136 const hash = createHash('sha256').update(resolved).digest('hex') 137 const fileName = `_nuxt/${hash}.js` 138 hashes.set(id, hash) 139 managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash } 140 bundle[fileName] = { 141 type: 'asset', 142 fileName, 143 name: hash, 144 names: [hash], 145 originalFileName: null, 146 originalFileNames: [], 147 needsCodeReference: false, 148 source: resolved, 149 } 150 return hash 151 } 152 153 for (const id of ids) { 154 visit(id, []) 155 } 156 157 let entry: CosManifest['entry'] | undefined 158 for (const file of Object.values(bundle)) { 159 if (file.type !== 'chunk') { 160 continue 161 } 162 for (const id of ids) { 163 file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 164 } 165 if (file.isEntry) { 166 // the entry is app-specific and should not be content-addressed 167 entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(/^_nuxt\//, '') } 168 } 169 } 170 171 if (!entry) { 172 return 173 } 174 175 const manifest: CosManifest = { base: '/_nuxt/', entry, chunks: managed } 176 loaderTemplate ??= bundleLoader(loaderEntry) 177 scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest)) 178 }, 179 }), { client: true, server: false }) 180 }, 181}) 182 183function rewriteSpecifier(code: string, from: string, to: string): string { 184 const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 185 const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g') 186 const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g') 187 const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g') 188 return code 189 .replace(dynamic, `$1$2${to}$2$3`) 190 .replace(fromImport, `$1$2${to}$2`) 191 .replace(bareImport, `$1$2${to}$2`) 192}