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