[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 { createServer } from 'node:http' 2import { createReadStream, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, globSync } from 'node:fs' 3import { tmpdir } from 'node:os' 4import { extname, join } from 'node:path' 5import { fileURLToPath } from 'node:url' 6import { afterAll, beforeAll, describe, expect, it } from 'vitest' 7import { build } from 'vite' 8import type { Server } from 'node:http' 9import type { Page } from 'playwright-core' 10import { assertExtensionRunnable, launchPlainBrowser, launchWithExtension, skipExtensionTest } from './utils/browser' 11 12const nodeModules = fileURLToPath(new URL('../../../node_modules', import.meta.url)) 13const vueEntry = join(nodeModules, globSync('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js', { cwd: nodeModules })[0]!) 14const cosChunkPattern = /\/assets\/[a-f0-9]{64}\.js$/ 15// Build inside the package tree so the app resolves `vue` and Vite emits the 16// HTML under a relative fileName. 17const scratchRoot = fileURLToPath(new URL('./.browser-scratch', import.meta.url)) 18 19const MIME: Record<string, string> = { 20 '.html': 'text/html', 21 '.js': 'text/javascript', 22 '.css': 'text/css', 23 '.map': 'application/json', 24} 25 26function serve(dir: string): Promise<{ origin: string, close: () => Promise<void> }> { 27 return new Promise((resolve) => { 28 const server: Server = createServer((req, res) => { 29 const path = new URL(req.url!, 'http://localhost').pathname 30 const file = join(dir, path === '/' ? 'index.html' : path) 31 res.setHeader('Content-Type', MIME[extname(file)] ?? 'application/octet-stream') 32 createReadStream(file).on('error', () => { 33 res.statusCode = 404 34 res.end('not found') 35 }).pipe(res) 36 }) 37 server.listen(0, '127.0.0.1', () => { 38 const { port } = server.address() as { port: number } 39 resolve({ 40 origin: `http://localhost:${port}`, 41 close: () => new Promise(r => server.close(() => r())), 42 }) 43 }) 44 }) 45} 46 47describe('browser (pure vite build)', () => { 48 let outDir: string 49 let server: { origin: string, close: () => Promise<void> } 50 51 beforeAll(async () => { 52 mkdirSync(scratchRoot, { recursive: true }) 53 const root = mkdtempSync(join(scratchRoot, 'app-')) 54 outDir = join(root, 'dist') 55 mkdirSync(join(root, 'src'), { recursive: true }) 56 writeFileSync( 57 join(root, 'index.html'), 58 '<!doctype html><html><head></head><body><p id="app">count: pending</p>' 59 + '<script type="module" src="/src/main.js"></script></body></html>', 60 ) 61 // Hydration-equivalent: mount a counter so the test can prove the cos chunk 62 // graph actually executed in the browser, not just that it loaded. 63 writeFileSync( 64 join(root, 'src/main.js'), 65 'import { ref, watchEffect } from "vue"\n' 66 + 'const count = ref(0)\n' 67 + 'const el = document.querySelector("#app")\n' 68 + 'watchEffect(() => { el.textContent = `count: ${count.value}` })\n' 69 + 'document.querySelector("body").addEventListener("click", () => count.value++)\n', 70 ) 71 72 const { cosPlugin } = await import('../src/index') 73 await build({ 74 root, 75 logLevel: 'error', 76 resolve: { alias: { vue: vueEntry } }, 77 plugins: [cosPlugin({ packages: [/^(?:vue$|@vue\/)/] })], 78 build: { outDir, emptyOutDir: true }, 79 }) 80 server = await serve(outDir) 81 }, 120_000) 82 83 afterAll(async () => { 84 await server?.close() 85 rmSync(scratchRoot, { recursive: true, force: true }) 86 }) 87 88 async function runApp(page: Page): Promise<void> { 89 await page.goto(server.origin, { waitUntil: 'networkidle' }) 90 const importMap = await page.locator('script[type="importmap"]').textContent() 91 expect(importMap).toMatch(/cos1:[a-f0-9]{64}/) 92 // The counter only updates if the whole cos chunk graph resolved and ran. 93 await page.locator('body').click() 94 await page.locator('#app', { hasText: 'count: 1' }).waitFor({ timeout: 5000 }) 95 expect(await page.locator('#app').textContent()).toBe('count: 1') 96 } 97 98 describe('without the cos extension (network fallback)', () => { 99 it('runs the app and loads every managed chunk over the network', async () => { 100 const browser = await launchPlainBrowser() 101 try { 102 const page = await browser.newPage() 103 const cosChunks = new Set<string>() 104 const errors: string[] = [] 105 page.on('console', msg => msg.type() === 'error' && errors.push(msg.text())) 106 page.on('response', (res) => { 107 if (cosChunkPattern.test(new URL(res.url()).pathname)) { 108 expect(res.status()).toBe(200) 109 cosChunks.add(new URL(res.url()).pathname) 110 } 111 }) 112 113 await runApp(page) 114 115 expect(await page.evaluate(() => 'crossOriginStorage' in navigator)).toBe(false) 116 expect(cosChunks.size).toBeGreaterThanOrEqual(5) 117 expect(errors, `console errors: ${errors.join(', ')}`).toEqual([]) 118 } 119 finally { 120 await browser.close() 121 } 122 }) 123 }) 124 125 describe.skipIf(skipExtensionTest())('with the cos extension', () => { 126 let userDataDir: string 127 128 beforeAll(() => { 129 assertExtensionRunnable() 130 userDataDir = mkdtempSync(join(tmpdir(), 'cos-ext-')) 131 }) 132 133 afterAll(() => { 134 rmSync(userDataDir, { recursive: true, force: true }) 135 }) 136 137 it('stores chunks in cos on first load, then serves them from cos without the network', async () => { 138 const context = await launchWithExtension(userDataDir) 139 try { 140 const page = await context.newPage() 141 const cosErrors: string[] = [] 142 page.on('console', (msg) => { 143 if (msg.type() === 'error' && msg.text().includes('[cos]')) cosErrors.push(msg.text()) 144 }) 145 146 await page.goto(server.origin, { waitUntil: 'networkidle' }) 147 expect(await page.evaluate(() => 'crossOriginStorage' in navigator)).toBe(true) 148 await page.waitForTimeout(500) 149 expect(cosErrors, `cos errors on first load: ${cosErrors.join(' | ')}`).toEqual([]) 150 151 const networkCosChunks: string[] = [] 152 page.on('response', (res) => { 153 if (cosChunkPattern.test(new URL(res.url()).pathname)) { 154 networkCosChunks.push(res.url()) 155 } 156 }) 157 await page.reload({ waitUntil: 'networkidle' }) 158 159 await page.locator('body').click() 160 await page.locator('#app', { hasText: 'count: 1' }).waitFor({ timeout: 5000 }) 161 expect(await page.locator('#app').textContent()).toBe('count: 1') 162 163 expect(networkCosChunks, `chunks fetched from network instead of COS: ${networkCosChunks.join(', ')}`).toEqual([]) 164 } 165 finally { 166 await context.close() 167 } 168 }) 169 }) 170 171 it('keeps the unused exports so the chunk is shareable regardless of usage', () => { 172 // The app only imports `ref`/`watchEffect`, but the vue chunk must contain 173 // the full public surface so two sites sharing it get identical bytes. 174 const cos = globSync('assets/*.js', { cwd: outDir }).filter(f => /[a-f0-9]{64}\.js$/.test(f)) 175 const total = cos.reduce((sum, f) => sum + readFileSync(join(outDir, f)).length, 0) 176 expect(total).toBeGreaterThan(100_000) 177 }) 178})