[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 { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync, globSync } from 'node:fs'
3import { fileURLToPath } from 'node:url'
4import { join } from 'node:path'
5import { afterAll, beforeAll, describe, expect, it } from 'vitest'
6import { build } from 'vite'
7import { cosPlugin } from '../src/vite'
8import type { Alias } from 'vite'
9
10// Build inside the project tree so fixtures resolve packages from the project
11// node_modules rather than a detached temp dir.
12const scratchRoot = fileURLToPath(new URL('./.plugin-scratch', import.meta.url))
13const nodeModules = fileURLToPath(new URL('../node_modules', import.meta.url))
14
15function resolvePkg(glob: string): string {
16 const match = globSync(glob, { cwd: nodeModules })[0]
17 if (!match) {
18 throw new Error(`fixture dependency not found: ${glob}`)
19 }
20 return join(nodeModules, match)
21}
22
23interface Built {
24 outDir: string
25 assetsDir: string
26 cosChunks: () => string[]
27 specifiersOf: (file: string) => string[]
28 appChunks: () => string[]
29 read: (file: string) => string
30 html: () => string
31}
32
33async function buildApp(
34 entry: string,
35 packages: Array<string | RegExp>,
36 alias: Alias[],
37 options: { sourcemap?: boolean } = {},
38): Promise<Built> {
39 mkdirSync(scratchRoot, { recursive: true })
40 const root = mkdtempSync(join(scratchRoot, 'app-'))
41 const outDir = join(root, 'dist')
42 const assetsDir = join(outDir, 'assets')
43 mkdirSync(join(root, 'src'), { recursive: true })
44 writeFileSync(
45 join(root, 'index.html'),
46 '<!doctype html><html><head></head><body><script type="module" src="/src/main.js"></script></body></html>',
47 )
48 writeFileSync(join(root, 'src/main.js'), entry)
49
50 await build({
51 root,
52 logLevel: 'error',
53 resolve: { alias },
54 plugins: [cosPlugin({ packages })],
55 build: { outDir, emptyOutDir: true, sourcemap: options.sourcemap ?? false, rollupOptions: { input: join(root, 'index.html') } },
56 })
57
58 const read = (file: string): string => readFileSync(join(assetsDir, file), 'utf8')
59 const specifiers = (code: string): string[] =>
60 [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))]
61
62 return {
63 outDir,
64 assetsDir,
65 cosChunks: () => readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f)),
66 appChunks: () => readdirSync(assetsDir).filter(f => f.endsWith('.js') && !/^[a-f0-9]{64}\.js$/.test(f)),
67 read,
68 specifiersOf: file => specifiers(read(file)),
69 html: () => readFileSync(join(outDir, 'index.html'), 'utf8'),
70 }
71}
72
73afterAll(() => {
74 rmSync(scratchRoot, { recursive: true, force: true })
75})
76
77describe('cosPlugin with vue', () => {
78 let app: Built
79
80 beforeAll(async () => {
81 app = await buildApp(
82 'import { ref } from "vue"\ndocument.body.dataset.count = String(ref(0).value)\n',
83 [/^(?:vue$|@vue\/)/],
84 [{ find: 'vue', replacement: resolvePkg('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js') }],
85 )
86 }, 120_000)
87
88 it('emits content-addressed chunks whose names match their bytes', () => {
89 expect(app.cosChunks().length).toBeGreaterThanOrEqual(1)
90 for (const file of app.cosChunks()) {
91 const hash = createHash('sha256').update(readFileSync(join(app.assetsDir, file))).digest('hex')
92 expect(hash).toBe(file.replace('.js', ''))
93 }
94 })
95
96 it('rewrites managed imports to content-addressed specifiers', () => {
97 for (const file of app.cosChunks()) {
98 for (const specifier of app.specifiersOf(file)) {
99 expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/)
100 }
101 }
102 })
103
104 it('injects the loader into index.html and removes the default entry script', () => {
105 const html = app.html()
106 expect(html).toContain('<script id="cos-loader">')
107 expect(html).toMatch(/cos1:[a-f0-9]{64}/)
108 expect(html).not.toMatch(/<script type="module"[^>]*src="[^"]*\.js"/)
109 })
110
111 it('derives the base path from the vite config', () => {
112 expect(app.html()).toMatch(/"base":"\/assets\/"/)
113 })
114})
115
116describe('cosPlugin with a non-vue package graph (unhead + hookable)', () => {
117 let app: Built
118
119 beforeAll(async () => {
120 // unhead imports hookable transitively; managing only `unhead` should still
121 // externalise hookable into its own shared chunk via auto-collection,
122 // exactly as @vue/shared is for the vue graph. This proves the algorithm is
123 // package-agnostic, not vue-shaped, and that transitive deps are collected.
124 app = await buildApp(
125 'import { createHead } from "unhead/client"\ndocument.title = String(!!createHead)\n',
126 ['unhead/client'],
127 [
128 { find: /^unhead\/client$/, replacement: resolvePkg('.pnpm/unhead@*/node_modules/unhead/dist/client.mjs') },
129 { find: /^hookable$/, replacement: resolvePkg('.pnpm/hookable@*/node_modules/hookable/dist/index.mjs') },
130 ],
131 )
132 }, 120_000)
133
134 it('auto-collects transitive deps the app never imported directly', () => {
135 // The app imports only `unhead/client`, yet hookable (a transitive dep) and
136 // unhead's internal shared chunks each become their own managed chunk.
137 expect(app.cosChunks().length).toBeGreaterThan(1)
138 for (const file of app.cosChunks()) {
139 const hash = createHash('sha256').update(readFileSync(join(app.assetsDir, file))).digest('hex')
140 expect(hash).toBe(file.replace('.js', ''))
141 }
142 })
143
144 it('externalises shared deps into leaf chunks rather than inlining them', () => {
145 // A leaf imports no managed chunk; if deps were inlined there would be no
146 // leaves, and a chunk with deps depends on those leaves.
147 const leaves = app.cosChunks().filter(f => app.specifiersOf(f).length === 0)
148 expect(leaves.length).toBeGreaterThanOrEqual(1)
149
150 const dependants = app.cosChunks().filter(f => app.specifiersOf(f).length > 0)
151 expect(dependants.length).toBeGreaterThanOrEqual(1)
152 })
153
154 it('references dependencies only by content-addressed specifier', () => {
155 for (const file of app.cosChunks()) {
156 for (const specifier of app.specifiersOf(file)) {
157 expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/)
158 }
159 }
160 })
161})
162
163describe('cosPlugin specifier rewriting', () => {
164 const vueAlias: Alias[] = [
165 { find: /^vue$/, replacement: '' }, // replaced per-test below
166 ]
167 vueAlias[0]!.replacement = resolvePkg('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js')
168
169 it('does not rewrite a managed specifier that appears in a string literal', async () => {
170 // The string "vue" is data here, not an import; AST-based rewriting must
171 // leave it alone while still rewriting the real import.
172 const app = await buildApp(
173 'import { ref } from "vue"\nconst label = "vue"\ndocument.title = label + String(ref(0).value)\n',
174 [/^(?:vue$|@vue\/)/],
175 vueAlias,
176 )
177 const entry = app.appChunks().map(f => app.read(f)).join('\n')
178 // The literal survives verbatim; the import is content-addressed.
179 expect(entry).toMatch(/["']vue["']/)
180 expect(entry).toMatch(/cos1:[a-f0-9]{64}/)
181 }, 120_000)
182
183 it('rewrites a dynamic import of a managed package', async () => {
184 // Reference the dynamic import from a side effect so it is not tree-shaken.
185 const app = await buildApp(
186 'window.addEventListener("click", () => { import("vue").then(m => { document.title = String(m.ref(0).value) }) })\n',
187 [/^(?:vue$|@vue\/)/],
188 vueAlias,
189 )
190 const entry = app.appChunks().map(f => app.read(f)).join('\n')
191 expect(entry).toMatch(/import\(\s*["']cos1:[a-f0-9]{64}["']\s*\)/)
192 }, 120_000)
193
194 it('keeps the chunk sourcemap valid when build.sourcemap is enabled', async () => {
195 const app = await buildApp(
196 'import { ref } from "vue"\ndocument.title = String(ref(0).value)\n',
197 [/^(?:vue$|@vue\/)/],
198 vueAlias,
199 { sourcemap: true },
200 )
201 const rewritten = app.appChunks().find(f => app.read(f).includes('cos1:'))
202 expect(rewritten, 'expected a rewritten app chunk').toBeDefined()
203
204 const map = JSON.parse(app.read(`${rewritten}.map`))
205 expect(map.version).toBe(3)
206 expect(map.mappings.length).toBeGreaterThan(0)
207 expect(Array.isArray(map.sources)).toBe(true)
208 }, 120_000)
209})