diff --git a/src/module.ts b/src/module.ts index 45a7131..ac7237a 100644 --- a/src/module.ts +++ b/src/module.ts @@ -13,15 +13,16 @@ export interface ModuleOptions { packages: Array } -interface CollectedPackage { - /** Bare specifiers this package is imported under (e.g. `vue`, `@vue/runtime-dom`). */ - specifiers: Set - /** Output chunk basename, e.g. `vue` -> emitted as `_nuxt/vue.js`. */ - chunk: string -} - -function bareSpecifier(chunk: string): string { - return `coschunk-${chunk}` +/** + * Recipe version embedded in every content-addressed specifier, meant to be bumped + * whenever the build recipe (bundler version, options, define replacements) + * changes in a way that alters emitted bytes, so chunks built under different + * recipes cannot silently collide on the same SHA-256. + */ +const RECIPE = 'cos1' + +function contentSpecifier(hash: string): string { + return `${RECIPE}:${hash}` } export default defineNuxtModule({ @@ -47,19 +48,7 @@ export default defineNuxtModule({ addServerPlugin(resolver.resolve('./runtime/server/plugins/inject')) - const collected = new Map() - const usedChunkNames = new Set() - - function chunkNameFor(specifier: string): string { - let index = 0 - let name: string - do { - name = (specifier + (index ? `-${index}` : '')).replace(/[^a-z0-9]/gi, '-').replace(/(^-+)|(-+$)/g, '') - index++ - } while (usedChunkNames.has(name)) - usedChunkNames.add(name) - return name - } + const collected = new Set() addVitePlugin(() => ({ name: 'nuxt-cos', @@ -76,77 +65,92 @@ export default defineNuxtModule({ return } - let pkg = collected.get(resolved.id) - if (!pkg) { - pkg = { specifiers: new Set(), chunk: chunkNameFor(id) } - collected.set(resolved.id, pkg) - } - pkg.specifiers.add(id) + collected.add(resolved.id) - return { id: bareSpecifier(pkg.chunk), external: true } + // Externalise under a synthetic specifier so it never clashes with the + // real module id elsewhere in the app graph. It is rewritten to a + // content-addressed specifier in `generateBundle`, once every managed + // chunk has been hashed bottom-up. + return { id: `cos-ext:${resolved.id}`, external: true } }, }, async generateBundle(_outputOptions, bundle) { - const externalIds = [...collected.keys()] - // Map every bare specifier any managed package may emit to its chunk. - const specifierToChunk = new Map() - for (const pkg of collected.values()) { - for (const specifier of pkg.specifiers) { - specifierToChunk.set(specifier, pkg.chunk) - } - } - - const managed: CosManifest['chunks'] = {} - - for (const [input, pkg] of collected) { + const ids = [...collected] + const idSet = new Set(ids) + + // Build each managed package once, externalising its siblings. The raw + // output keeps sibling imports as their resolved absolute ids, which + // double as the dependency edges between managed chunks. + const raw = new Map() + for (const input of ids) { const builder = await rolldown({ input, platform: 'browser', treeshake: false, - external: externalIds.filter(id => id !== input), + external: ids.filter(id => id !== input), }) - const { output } = await builder.generate({ file: `${pkg.chunk}.js`, codeSplitting: false }) + const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) await builder.close() - let code = output[0].code - for (const [specifier, chunk] of specifierToChunk) { - code = rewriteSpecifier(code, specifier, bareSpecifier(chunk)) + const code = output[0].code + const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] + .filter(spec => idSet.has(spec)) + raw.set(input, { code, deps }) + } + + // Hash bottom-up: a chunk's specifier for a dependency is that + // dependency's content hash, so a chunk can only be hashed once all of + // its dependencies have been. The npm graph for these packages is a DAG. + const hashes = new Map() + const managed: CosManifest['chunks'] = {} + + const visit = (id: string, stack: string[]): string => { + const existing = hashes.get(id) + if (existing) { + return existing } - // Imports rolldown kept as resolved absolute paths to other managed packages. - for (const otherId of externalIds) { - const chunk = collected.get(otherId)!.chunk - code = rewriteSpecifier(code, otherId, bareSpecifier(chunk)) + if (stack.includes(id)) { + throw new Error(`[nuxt-cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`) } - const fileName = `_nuxt/${pkg.chunk}.js` - const hash = createHash('sha256').update(code).digest('hex') - managed[bareSpecifier(pkg.chunk)] = { file: `${pkg.chunk}.js`, hash } + const { code, deps } = raw.get(id)! + let resolved = code + for (const dep of deps) { + resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id]))) + } + + const hash = createHash('sha256').update(resolved).digest('hex') + const fileName = `_nuxt/${hash}.js` + hashes.set(id, hash) + managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash } bundle[fileName] = { type: 'asset', fileName, - name: pkg.chunk, - names: [pkg.chunk], + name: hash, + names: [hash], originalFileName: null, originalFileNames: [], needsCodeReference: false, - source: code, + source: resolved, } + return hash + } + + for (const id of ids) { + visit(id, []) } - let entry: string | undefined + let entry: CosManifest['entry'] | undefined for (const file of Object.values(bundle)) { if (file.type !== 'chunk') { continue } - for (const [specifier, chunk] of specifierToChunk) { - file.code = rewriteSpecifier(file.code, specifier, bareSpecifier(chunk)) + for (const id of ids) { + file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) } if (file.isEntry) { - entry = bareSpecifier(file.fileName) - managed[bareSpecifier(file.fileName)] ??= { - file: file.fileName.replace(/^_nuxt\//, ''), - hash: createHash('sha256').update(file.code).digest('hex'), - } + // the entry is app-specific and should not be content-addressed + entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(/^_nuxt\//, '') } } } diff --git a/src/runtime/loader.ts b/src/runtime/loader.ts index d068125..62d5cab 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -18,9 +18,12 @@ declare global { export interface CosManifest { /** Public base path that managed chunks are served from, e.g. `/_nuxt/`. */ base: string - /** Bare specifier of the entry chunk to import once the import map is ready. */ - entry: string - /** Map of bare specifier to `{ file, hash }` for every managed chunk. */ + /** + * The entry chunk to import once the import map is ready. It is app-specific, so it is + * loaded straight from the network rather than stored in COS by a content hash. + */ + entry: { specifier: string, file: string } + /** Map of content-addressed specifier to `{ file, hash }` for every COS-managed chunk. */ chunks: Record } @@ -70,11 +73,13 @@ export async function runCosLoader(manifest: CosManifest): Promise { }), ) + imports[manifest.entry.specifier] = new URL(manifest.base + manifest.entry.file, location.origin).href + const script = document.createElement('script') script.type = 'importmap' script.textContent = JSON.stringify({ imports }) document.head.appendChild(script) await new Promise(resolve => setTimeout(resolve, 0)) - await import(/* @vite-ignore */ manifest.entry) + await import(/* @vite-ignore */ manifest.entry.specifier) } diff --git a/test/build.test.ts b/test/build.test.ts index d5fb6a9..317e08e 100644 --- a/test/build.test.ts +++ b/test/build.test.ts @@ -1,4 +1,5 @@ import { execSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { readFileSync, readdirSync, rmSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -7,45 +8,75 @@ import { beforeAll, describe, expect, it } from 'vitest' const fixtureDir = fileURLToPath(new URL('./fixtures/basic', import.meta.url)) const publicNuxt = join(fixtureDir, '.output/public/_nuxt') -function importsOf(file: string): string[] { +function build(): void { + rmSync(join(fixtureDir, '.output'), { recursive: true, force: true }) + rmSync(join(fixtureDir, '.nuxt'), { recursive: true, force: true }) + execSync('npx nuxi build', { cwd: fixtureDir, stdio: 'inherit' }) +} + +function cosChunks(): string[] { + return readdirSync(publicNuxt).filter(f => /^[a-f0-9]{64}\.js$/.test(f)) +} + +function specifiersOf(file: string): string[] { const code = readFileSync(join(publicNuxt, file), 'utf8') const specifiers = [...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!) return [...new Set(specifiers)] } describe('cos build output', () => { - beforeAll(() => { - rmSync(join(fixtureDir, '.output'), { recursive: true, force: true }) - rmSync(join(fixtureDir, '.nuxt'), { recursive: true, force: true }) - execSync('npx nuxi build', { cwd: fixtureDir, stdio: 'inherit' }) - }, 240_000) + beforeAll(build, 240_000) + + it('emits one content-addressed chunk per managed vue package', () => { + // vue + runtime-dom + runtime-core + reactivity + shared + expect(cosChunks()).toHaveLength(5) + }) - it('emits a standalone chunk for every managed vue package', () => { - const files = readdirSync(publicNuxt) - for (const name of ['vue', 'vue-runtime-dom', 'vue-runtime-core', 'vue-reactivity', 'vue-shared']) { - expect(files, `missing ${name}.js`).toContain(`${name}.js`) + it('names every chunk after the sha-256 of its bytes', () => { + for (const file of cosChunks()) { + const hash = file.replace('.js', '') + const actual = createHash('sha256').update(readFileSync(join(publicNuxt, file))).digest('hex') + expect(actual).toBe(hash) } }) - it('externalises managed packages instead of inlining them (no duplication)', () => { - // If vue were self-contained it would be ~300KB; externalised it is tiny. - const vue = readFileSync(join(publicNuxt, 'vue.js'), 'utf8') - expect(vue.length).toBeLessThan(5_000) - expect(importsOf('vue.js')).toEqual(['coschunk-vue-runtime-dom']) + it('references dependencies only by content-addressed specifier', () => { + for (const file of cosChunks()) { + for (const specifier of specifiersOf(file)) { + expect(specifier, `${file} imports non-content-addressed ${specifier}`).toMatch(/^cos1:[a-f0-9]{64}$/) + } + } + }) + + it('externalises shared dependencies instead of inlining them (no duplication)', () => { + const sizes = cosChunks().map(f => readFileSync(join(publicNuxt, f)).length) + // A self-contained vue would be ~150KB minified; externalised it is tiny. + expect(Math.min(...sizes)).toBeLessThan(1_000) }) it('keeps the reactivity singleton as a single shared leaf chunk', () => { - // @vue/shared is imported by every other vue chunk and imports nothing. - expect(importsOf('vue-shared.js')).toEqual([]) - for (const dependant of ['vue-runtime-dom', 'vue-runtime-core', 'vue-reactivity']) { - expect(importsOf(`${dependant}.js`)).toContain('coschunk-vue-shared') - } + const chunks = cosChunks() + const leaves = chunks.filter(f => specifiersOf(f).length === 0) + // @vue/shared is the only package that imports nothing; if it were + // duplicated or inlined there would be zero or several leaves. + expect(leaves, 'expected exactly one dependency-free leaf (@vue/shared)').toHaveLength(1) + + const leafSpecifier = `cos1:${leaves[0]!.replace('.js', '')}` + const directDependants = chunks.filter(f => specifiersOf(f).includes(leafSpecifier)) + // runtime-dom, runtime-core and reactivity all import @vue/shared directly. + expect(directDependants.length).toBeGreaterThanOrEqual(3) }) - it('leaves no dangling bare vue specifiers in any chunk', () => { - for (const file of readdirSync(publicNuxt).filter(f => f.endsWith('.js'))) { - const bare = importsOf(file).filter(s => /^(?:vue|@vue\/)/.test(s)) - expect(bare, `${file} still imports ${bare.join(', ')}`).toEqual([]) + it('leaves no machine-specific paths in any chunk', () => { + for (const file of cosChunks()) { + const code = readFileSync(join(publicNuxt, file), 'utf8') + expect(code, `${file} leaks a path`).not.toMatch(/node_modules|cos-ext:|#region/) } }) + + it('produces identical hashes when rebuilt (deterministic)', () => { + const first = cosChunks().sort() + build() + expect(cosChunks().sort()).toEqual(first) + }, 240_000) }) diff --git a/test/ssr.test.ts b/test/ssr.test.ts index 950e15d..349c5f4 100644 --- a/test/ssr.test.ts +++ b/test/ssr.test.ts @@ -16,13 +16,13 @@ describe('ssr', async () => { it('injects the cos loader and removes the default entry script', async () => { const html = await $fetch('/') expect(html).toContain('