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

feat: content-address cos chunks with bottom-up hashing

+133 -93
+66 -62
src/module.ts
··· 13 13 packages: Array<string | RegExp> 14 14 } 15 15 16 - interface CollectedPackage { 17 - /** Bare specifiers this package is imported under (e.g. `vue`, `@vue/runtime-dom`). */ 18 - specifiers: Set<string> 19 - /** Output chunk basename, e.g. `vue` -> emitted as `_nuxt/vue.js`. */ 20 - chunk: string 21 - } 16 + /** 17 + * Recipe version embedded in every content-addressed specifier, meant to be bumped 18 + * whenever the build recipe (bundler version, options, define replacements) 19 + * changes in a way that alters emitted bytes, so chunks built under different 20 + * recipes cannot silently collide on the same SHA-256. 21 + */ 22 + const RECIPE = 'cos1' 22 23 23 - function bareSpecifier(chunk: string): string { 24 - return `coschunk-${chunk}` 24 + function contentSpecifier(hash: string): string { 25 + return `${RECIPE}:${hash}` 25 26 } 26 27 27 28 export default defineNuxtModule<ModuleOptions>({ ··· 47 48 48 49 addServerPlugin(resolver.resolve('./runtime/server/plugins/inject')) 49 50 50 - const collected = new Map<string, CollectedPackage>() 51 - const usedChunkNames = new Set<string>() 52 - 53 - function chunkNameFor(specifier: string): string { 54 - let index = 0 55 - let name: string 56 - do { 57 - name = (specifier + (index ? `-${index}` : '')).replace(/[^a-z0-9]/gi, '-').replace(/(^-+)|(-+$)/g, '') 58 - index++ 59 - } while (usedChunkNames.has(name)) 60 - usedChunkNames.add(name) 61 - return name 62 - } 51 + const collected = new Set<string>() 63 52 64 53 addVitePlugin(() => ({ 65 54 name: 'nuxt-cos', ··· 76 65 return 77 66 } 78 67 79 - let pkg = collected.get(resolved.id) 80 - if (!pkg) { 81 - pkg = { specifiers: new Set(), chunk: chunkNameFor(id) } 82 - collected.set(resolved.id, pkg) 83 - } 84 - pkg.specifiers.add(id) 68 + collected.add(resolved.id) 85 69 86 - return { id: bareSpecifier(pkg.chunk), external: true } 70 + // Externalise under a synthetic specifier so it never clashes with the 71 + // real module id elsewhere in the app graph. It is rewritten to a 72 + // content-addressed specifier in `generateBundle`, once every managed 73 + // chunk has been hashed bottom-up. 74 + return { id: `cos-ext:${resolved.id}`, external: true } 87 75 }, 88 76 }, 89 77 async generateBundle(_outputOptions, bundle) { 90 - const externalIds = [...collected.keys()] 91 - // Map every bare specifier any managed package may emit to its chunk. 92 - const specifierToChunk = new Map<string, string>() 93 - for (const pkg of collected.values()) { 94 - for (const specifier of pkg.specifiers) { 95 - specifierToChunk.set(specifier, pkg.chunk) 96 - } 97 - } 98 - 99 - const managed: CosManifest['chunks'] = {} 78 + const ids = [...collected] 79 + const idSet = new Set(ids) 100 80 101 - for (const [input, pkg] of collected) { 81 + // Build each managed package once, externalising its siblings. The raw 82 + // output keeps sibling imports as their resolved absolute ids, which 83 + // double as the dependency edges between managed chunks. 84 + const raw = new Map<string, { code: string, deps: string[] }>() 85 + for (const input of ids) { 102 86 const builder = await rolldown({ 103 87 input, 104 88 platform: 'browser', 105 89 treeshake: false, 106 - external: externalIds.filter(id => id !== input), 90 + external: ids.filter(id => id !== input), 107 91 }) 108 - const { output } = await builder.generate({ file: `${pkg.chunk}.js`, codeSplitting: false }) 92 + const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) 109 93 await builder.close() 110 94 111 - let code = output[0].code 112 - for (const [specifier, chunk] of specifierToChunk) { 113 - code = rewriteSpecifier(code, specifier, bareSpecifier(chunk)) 95 + const code = output[0].code 96 + const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] 97 + .filter(spec => idSet.has(spec)) 98 + raw.set(input, { code, deps }) 99 + } 100 + 101 + // Hash bottom-up: a chunk's specifier for a dependency is that 102 + // dependency's content hash, so a chunk can only be hashed once all of 103 + // its dependencies have been. The npm graph for these packages is a DAG. 104 + const hashes = new Map<string, string>() 105 + const managed: CosManifest['chunks'] = {} 106 + 107 + const visit = (id: string, stack: string[]): string => { 108 + const existing = hashes.get(id) 109 + if (existing) { 110 + return existing 114 111 } 115 - // Imports rolldown kept as resolved absolute paths to other managed packages. 116 - for (const otherId of externalIds) { 117 - const chunk = collected.get(otherId)!.chunk 118 - code = rewriteSpecifier(code, otherId, bareSpecifier(chunk)) 112 + if (stack.includes(id)) { 113 + throw new Error(`[nuxt-cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`) 119 114 } 120 115 121 - const fileName = `_nuxt/${pkg.chunk}.js` 122 - const hash = createHash('sha256').update(code).digest('hex') 123 - managed[bareSpecifier(pkg.chunk)] = { file: `${pkg.chunk}.js`, hash } 116 + const { code, deps } = raw.get(id)! 117 + let resolved = code 118 + for (const dep of deps) { 119 + resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id]))) 120 + } 121 + 122 + const hash = createHash('sha256').update(resolved).digest('hex') 123 + const fileName = `_nuxt/${hash}.js` 124 + hashes.set(id, hash) 125 + managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash } 124 126 bundle[fileName] = { 125 127 type: 'asset', 126 128 fileName, 127 - name: pkg.chunk, 128 - names: [pkg.chunk], 129 + name: hash, 130 + names: [hash], 129 131 originalFileName: null, 130 132 originalFileNames: [], 131 133 needsCodeReference: false, 132 - source: code, 134 + source: resolved, 133 135 } 136 + return hash 134 137 } 135 138 136 - let entry: string | undefined 139 + for (const id of ids) { 140 + visit(id, []) 141 + } 142 + 143 + let entry: CosManifest['entry'] | undefined 137 144 for (const file of Object.values(bundle)) { 138 145 if (file.type !== 'chunk') { 139 146 continue 140 147 } 141 - for (const [specifier, chunk] of specifierToChunk) { 142 - file.code = rewriteSpecifier(file.code, specifier, bareSpecifier(chunk)) 148 + for (const id of ids) { 149 + file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 143 150 } 144 151 if (file.isEntry) { 145 - entry = bareSpecifier(file.fileName) 146 - managed[bareSpecifier(file.fileName)] ??= { 147 - file: file.fileName.replace(/^_nuxt\//, ''), 148 - hash: createHash('sha256').update(file.code).digest('hex'), 149 - } 152 + // the entry is app-specific and should not be content-addressed 153 + entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(/^_nuxt\//, '') } 150 154 } 151 155 } 152 156
+9 -4
src/runtime/loader.ts
··· 18 18 export interface CosManifest { 19 19 /** Public base path that managed chunks are served from, e.g. `/_nuxt/`. */ 20 20 base: string 21 - /** Bare specifier of the entry chunk to import once the import map is ready. */ 22 - entry: string 23 - /** Map of bare specifier to `{ file, hash }` for every managed chunk. */ 21 + /** 22 + * The entry chunk to import once the import map is ready. It is app-specific, so it is 23 + * loaded straight from the network rather than stored in COS by a content hash. 24 + */ 25 + entry: { specifier: string, file: string } 26 + /** Map of content-addressed specifier to `{ file, hash }` for every COS-managed chunk. */ 24 27 chunks: Record<string, { file: string, hash: string }> 25 28 } 26 29 ··· 70 73 }), 71 74 ) 72 75 76 + imports[manifest.entry.specifier] = new URL(manifest.base + manifest.entry.file, location.origin).href 77 + 73 78 const script = document.createElement('script') 74 79 script.type = 'importmap' 75 80 script.textContent = JSON.stringify({ imports }) 76 81 document.head.appendChild(script) 77 82 78 83 await new Promise(resolve => setTimeout(resolve, 0)) 79 - await import(/* @vite-ignore */ manifest.entry) 84 + await import(/* @vite-ignore */ manifest.entry.specifier) 80 85 }
+55 -24
test/build.test.ts
··· 1 1 import { execSync } from 'node:child_process' 2 + import { createHash } from 'node:crypto' 2 3 import { readFileSync, readdirSync, rmSync } from 'node:fs' 3 4 import { fileURLToPath } from 'node:url' 4 5 import { join } from 'node:path' ··· 7 8 const fixtureDir = fileURLToPath(new URL('./fixtures/basic', import.meta.url)) 8 9 const publicNuxt = join(fixtureDir, '.output/public/_nuxt') 9 10 10 - function importsOf(file: string): string[] { 11 + function build(): void { 12 + rmSync(join(fixtureDir, '.output'), { recursive: true, force: true }) 13 + rmSync(join(fixtureDir, '.nuxt'), { recursive: true, force: true }) 14 + execSync('npx nuxi build', { cwd: fixtureDir, stdio: 'inherit' }) 15 + } 16 + 17 + function cosChunks(): string[] { 18 + return readdirSync(publicNuxt).filter(f => /^[a-f0-9]{64}\.js$/.test(f)) 19 + } 20 + 21 + function specifiersOf(file: string): string[] { 11 22 const code = readFileSync(join(publicNuxt, file), 'utf8') 12 23 const specifiers = [...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!) 13 24 return [...new Set(specifiers)] 14 25 } 15 26 16 27 describe('cos build output', () => { 17 - beforeAll(() => { 18 - rmSync(join(fixtureDir, '.output'), { recursive: true, force: true }) 19 - rmSync(join(fixtureDir, '.nuxt'), { recursive: true, force: true }) 20 - execSync('npx nuxi build', { cwd: fixtureDir, stdio: 'inherit' }) 21 - }, 240_000) 28 + beforeAll(build, 240_000) 22 29 23 - it('emits a standalone chunk for every managed vue package', () => { 24 - const files = readdirSync(publicNuxt) 25 - for (const name of ['vue', 'vue-runtime-dom', 'vue-runtime-core', 'vue-reactivity', 'vue-shared']) { 26 - expect(files, `missing ${name}.js`).toContain(`${name}.js`) 30 + it('emits one content-addressed chunk per managed vue package', () => { 31 + // vue + runtime-dom + runtime-core + reactivity + shared 32 + expect(cosChunks()).toHaveLength(5) 33 + }) 34 + 35 + it('names every chunk after the sha-256 of its bytes', () => { 36 + for (const file of cosChunks()) { 37 + const hash = file.replace('.js', '') 38 + const actual = createHash('sha256').update(readFileSync(join(publicNuxt, file))).digest('hex') 39 + expect(actual).toBe(hash) 40 + } 41 + }) 42 + 43 + it('references dependencies only by content-addressed specifier', () => { 44 + for (const file of cosChunks()) { 45 + for (const specifier of specifiersOf(file)) { 46 + expect(specifier, `${file} imports non-content-addressed ${specifier}`).toMatch(/^cos1:[a-f0-9]{64}$/) 47 + } 27 48 } 28 49 }) 29 50 30 - it('externalises managed packages instead of inlining them (no duplication)', () => { 31 - // If vue were self-contained it would be ~300KB; externalised it is tiny. 32 - const vue = readFileSync(join(publicNuxt, 'vue.js'), 'utf8') 33 - expect(vue.length).toBeLessThan(5_000) 34 - expect(importsOf('vue.js')).toEqual(['coschunk-vue-runtime-dom']) 51 + it('externalises shared dependencies instead of inlining them (no duplication)', () => { 52 + const sizes = cosChunks().map(f => readFileSync(join(publicNuxt, f)).length) 53 + // A self-contained vue would be ~150KB minified; externalised it is tiny. 54 + expect(Math.min(...sizes)).toBeLessThan(1_000) 35 55 }) 36 56 37 57 it('keeps the reactivity singleton as a single shared leaf chunk', () => { 38 - // @vue/shared is imported by every other vue chunk and imports nothing. 39 - expect(importsOf('vue-shared.js')).toEqual([]) 40 - for (const dependant of ['vue-runtime-dom', 'vue-runtime-core', 'vue-reactivity']) { 41 - expect(importsOf(`${dependant}.js`)).toContain('coschunk-vue-shared') 42 - } 58 + const chunks = cosChunks() 59 + const leaves = chunks.filter(f => specifiersOf(f).length === 0) 60 + // @vue/shared is the only package that imports nothing; if it were 61 + // duplicated or inlined there would be zero or several leaves. 62 + expect(leaves, 'expected exactly one dependency-free leaf (@vue/shared)').toHaveLength(1) 63 + 64 + const leafSpecifier = `cos1:${leaves[0]!.replace('.js', '')}` 65 + const directDependants = chunks.filter(f => specifiersOf(f).includes(leafSpecifier)) 66 + // runtime-dom, runtime-core and reactivity all import @vue/shared directly. 67 + expect(directDependants.length).toBeGreaterThanOrEqual(3) 43 68 }) 44 69 45 - it('leaves no dangling bare vue specifiers in any chunk', () => { 46 - for (const file of readdirSync(publicNuxt).filter(f => f.endsWith('.js'))) { 47 - const bare = importsOf(file).filter(s => /^(?:vue|@vue\/)/.test(s)) 48 - expect(bare, `${file} still imports ${bare.join(', ')}`).toEqual([]) 70 + it('leaves no machine-specific paths in any chunk', () => { 71 + for (const file of cosChunks()) { 72 + const code = readFileSync(join(publicNuxt, file), 'utf8') 73 + expect(code, `${file} leaks a path`).not.toMatch(/node_modules|cos-ext:|#region/) 49 74 } 50 75 }) 76 + 77 + it('produces identical hashes when rebuilt (deterministic)', () => { 78 + const first = cosChunks().sort() 79 + build() 80 + expect(cosChunks().sort()).toEqual(first) 81 + }, 240_000) 51 82 })
+3 -3
test/ssr.test.ts
··· 16 16 it('injects the cos loader and removes the default entry script', async () => { 17 17 const html = await $fetch('/') 18 18 expect(html).toContain('<script id="cos-loader">') 19 - expect(html).toContain('coschunk-vue') 19 + expect(html).toMatch(/cos1:[a-f0-9]{64}/) 20 20 expect(html).not.toMatch(/<script type="module"[^>]*src="\/_nuxt\/[^"]*"/) 21 21 }) 22 22 23 - it('inlines a manifest entry that resolves to a real chunk', async () => { 23 + it('inlines a manifest whose entry resolves to a managed chunk', async () => { 24 24 const html = await $fetch('/') 25 - const entry = html.match(/"entry":"(coschunk-[^"]+)"/)?.[1] 25 + const entry = html.match(/"entry":"(cos1:[a-f0-9]{64})"/)?.[1] 26 26 expect(entry).toBeDefined() 27 27 const chunks = html.match(/"chunks":\{(.+?)\}\}\)/)?.[1] ?? '' 28 28 expect(chunks).toContain(`"${entry}":`)