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