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