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