[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 html: () => string
29}
30
31async function buildApp(entry: string, packages: Array<string | RegExp>, alias: Alias[]): Promise<Built> {
32 mkdirSync(scratchRoot, { recursive: true })
33 const root = mkdtempSync(join(scratchRoot, 'app-'))
34 const outDir = join(root, 'dist')
35 const assetsDir = join(outDir, 'assets')
36 mkdirSync(join(root, 'src'), { recursive: true })
37 writeFileSync(
38 join(root, 'index.html'),
39 '<!doctype html><html><head></head><body><script type="module" src="/src/main.js"></script></body></html>',
40 )
41 writeFileSync(join(root, 'src/main.js'), entry)
42
43 await build({
44 root,
45 logLevel: 'error',
46 resolve: { alias },
47 plugins: [cosPlugin({ packages })],
48 build: { outDir, emptyOutDir: true, rollupOptions: { input: join(root, 'index.html') } },
49 })
50
51 return {
52 outDir,
53 assetsDir,
54 cosChunks: () => readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f)),
55 specifiersOf: (file) => {
56 const code = readFileSync(join(assetsDir, file), 'utf8')
57 return [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))]
58 },
59 html: () => readFileSync(join(outDir, 'index.html'), 'utf8'),
60 }
61}
62
63afterAll(() => {
64 rmSync(scratchRoot, { recursive: true, force: true })
65})
66
67describe('cosPlugin with vue', () => {
68 let app: Built
69
70 beforeAll(async () => {
71 app = await buildApp(
72 'import { ref } from "vue"\ndocument.body.dataset.count = String(ref(0).value)\n',
73 [/^(?:vue$|@vue\/)/],
74 [{ find: 'vue', replacement: resolvePkg('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js') }],
75 )
76 }, 120_000)
77
78 it('emits content-addressed chunks whose names match their bytes', () => {
79 expect(app.cosChunks().length).toBeGreaterThanOrEqual(1)
80 for (const file of app.cosChunks()) {
81 const hash = createHash('sha256').update(readFileSync(join(app.assetsDir, file))).digest('hex')
82 expect(hash).toBe(file.replace('.js', ''))
83 }
84 })
85
86 it('rewrites managed imports to content-addressed specifiers', () => {
87 for (const file of app.cosChunks()) {
88 for (const specifier of app.specifiersOf(file)) {
89 expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/)
90 }
91 }
92 })
93
94 it('injects the loader into index.html and removes the default entry script', () => {
95 const html = app.html()
96 expect(html).toContain('<script id="cos-loader">')
97 expect(html).toMatch(/cos1:[a-f0-9]{64}/)
98 expect(html).not.toMatch(/<script type="module"[^>]*src="[^"]*\.js"/)
99 })
100
101 it('derives the base path from the vite config', () => {
102 expect(app.html()).toMatch(/"base":"\/assets\/"/)
103 })
104})
105
106describe('cosPlugin with a non-vue package graph (unhead + hookable)', () => {
107 let app: Built
108
109 beforeAll(async () => {
110 // unhead imports hookable transitively; managing only `unhead` should still
111 // externalise hookable into its own shared chunk via auto-collection,
112 // exactly as @vue/shared is for the vue graph. This proves the algorithm is
113 // package-agnostic, not vue-shaped, and that transitive deps are collected.
114 app = await buildApp(
115 'import { createHead } from "unhead/client"\ndocument.title = String(!!createHead)\n',
116 ['unhead/client'],
117 [
118 { find: /^unhead\/client$/, replacement: resolvePkg('.pnpm/unhead@*/node_modules/unhead/dist/client.mjs') },
119 { find: /^hookable$/, replacement: resolvePkg('.pnpm/hookable@*/node_modules/hookable/dist/index.mjs') },
120 ],
121 )
122 }, 120_000)
123
124 it('auto-collects transitive deps the app never imported directly', () => {
125 // The app imports only `unhead/client`, yet hookable (a transitive dep) and
126 // unhead's internal shared chunks each become their own managed chunk.
127 expect(app.cosChunks().length).toBeGreaterThan(1)
128 for (const file of app.cosChunks()) {
129 const hash = createHash('sha256').update(readFileSync(join(app.assetsDir, file))).digest('hex')
130 expect(hash).toBe(file.replace('.js', ''))
131 }
132 })
133
134 it('externalises shared deps into leaf chunks rather than inlining them', () => {
135 // A leaf imports no managed chunk; if deps were inlined there would be no
136 // leaves, and a chunk with deps depends on those leaves.
137 const leaves = app.cosChunks().filter(f => app.specifiersOf(f).length === 0)
138 expect(leaves.length).toBeGreaterThanOrEqual(1)
139
140 const dependants = app.cosChunks().filter(f => app.specifiersOf(f).length > 0)
141 expect(dependants.length).toBeGreaterThanOrEqual(1)
142 })
143
144 it('references dependencies only by content-addressed specifier', () => {
145 for (const file of app.cosChunks()) {
146 for (const specifier of app.specifiersOf(file)) {
147 expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/)
148 }
149 }
150 })
151})