[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
0

Configure Feed

Select the types of activity you want to include in your feed.

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})