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