[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
16interface CollectedPackage {
17 /** Bare specifiers this package is imported under (e.g. `vue`, `@vue/runtime-dom`). */
18 specifiers: Set<string>
19 /** Output chunk basename, e.g. `vue` -> emitted as `_nuxt/vue.js`. */
20 chunk: string
21}
22
23function bareSpecifier(chunk: string): string {
24 return `coschunk-${chunk}`
25}
26
27export default defineNuxtModule<ModuleOptions>({
28 meta: {
29 name: 'nuxt-cos',
30 configKey: 'cos',
31 },
32 defaults: {
33 packages: [/^(?:vue$|@vue\/)/],
34 },
35 setup(options, nuxt) {
36 if (nuxt.options.dev) {
37 return
38 }
39
40 const resolver = createResolver(import.meta.url)
41 const packages = options.packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p)
42
43 let scriptContent = ''
44
45 nuxt.options.nitro.virtual ||= {}
46 nuxt.options.nitro.virtual['virtual:cos-loader'] = () => `export default ${JSON.stringify(scriptContent)}`
47
48 addServerPlugin(resolver.resolve('./runtime/server/plugins/inject'))
49
50 const collected = new Map<string, CollectedPackage>()
51 const usedChunkNames = new Set<string>()
52
53 function chunkNameFor(specifier: string): string {
54 let index = 0
55 let name: string
56 do {
57 name = (specifier + (index ? `-${index}` : '')).replace(/[^a-z0-9]/gi, '-').replace(/(^-+)|(-+$)/g, '')
58 index++
59 } while (usedChunkNames.has(name))
60 usedChunkNames.add(name)
61 return name
62 }
63
64 addVitePlugin(() => ({
65 name: 'nuxt-cos',
66 enforce: 'pre',
67 resolveId: {
68 order: 'pre',
69 async handler(id, importer, resolveOptions) {
70 if (!packages.some(p => p.test(id))) {
71 return
72 }
73
74 const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true })
75 if (!resolved) {
76 return
77 }
78
79 let pkg = collected.get(resolved.id)
80 if (!pkg) {
81 pkg = { specifiers: new Set(), chunk: chunkNameFor(id) }
82 collected.set(resolved.id, pkg)
83 }
84 pkg.specifiers.add(id)
85
86 return { id: bareSpecifier(pkg.chunk), external: true }
87 },
88 },
89 async generateBundle(_outputOptions, bundle) {
90 const externalIds = [...collected.keys()]
91 // Map every bare specifier any managed package may emit to its chunk.
92 const specifierToChunk = new Map<string, string>()
93 for (const pkg of collected.values()) {
94 for (const specifier of pkg.specifiers) {
95 specifierToChunk.set(specifier, pkg.chunk)
96 }
97 }
98
99 const managed: CosManifest['chunks'] = {}
100
101 for (const [input, pkg] of collected) {
102 const builder = await rolldown({
103 input,
104 platform: 'browser',
105 treeshake: false,
106 external: externalIds.filter(id => id !== input),
107 })
108 const { output } = await builder.generate({ file: `${pkg.chunk}.js`, codeSplitting: false })
109 await builder.close()
110
111 let code = output[0].code
112 for (const [specifier, chunk] of specifierToChunk) {
113 code = rewriteSpecifier(code, specifier, bareSpecifier(chunk))
114 }
115 // Imports rolldown kept as resolved absolute paths to other managed packages.
116 for (const otherId of externalIds) {
117 const chunk = collected.get(otherId)!.chunk
118 code = rewriteSpecifier(code, otherId, bareSpecifier(chunk))
119 }
120
121 const fileName = `_nuxt/${pkg.chunk}.js`
122 const hash = createHash('sha256').update(code).digest('hex')
123 managed[bareSpecifier(pkg.chunk)] = { file: `${pkg.chunk}.js`, hash }
124 bundle[fileName] = {
125 type: 'asset',
126 fileName,
127 name: pkg.chunk,
128 names: [pkg.chunk],
129 originalFileName: null,
130 originalFileNames: [],
131 needsCodeReference: false,
132 source: code,
133 }
134 }
135
136 let entry: string | undefined
137 for (const file of Object.values(bundle)) {
138 if (file.type !== 'chunk') {
139 continue
140 }
141 for (const [specifier, chunk] of specifierToChunk) {
142 file.code = rewriteSpecifier(file.code, specifier, bareSpecifier(chunk))
143 }
144 if (file.isEntry) {
145 entry = bareSpecifier(file.fileName)
146 managed[bareSpecifier(file.fileName)] ??= {
147 file: file.fileName.replace(/^_nuxt\//, ''),
148 hash: createHash('sha256').update(file.code).digest('hex'),
149 }
150 }
151 }
152
153 if (!entry) {
154 return
155 }
156
157 const manifest: CosManifest = { base: '/_nuxt/', entry, chunks: managed }
158 scriptContent = `(${runCosLoader.toString()})(${JSON.stringify(manifest)})`
159 },
160 }), { client: true, server: false })
161 },
162})
163
164function rewriteSpecifier(code: string, from: string, to: string): string {
165 const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
166 const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g')
167 const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g')
168 const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g')
169 return code
170 .replace(dynamic, `$1$2${to}$2$3`)
171 .replace(fromImport, `$1$2${to}$2`)
172 .replace(bareImport, `$1$2${to}$2`)
173}