···1313 packages: Array<string | RegExp>
1414}
15151616-interface CollectedPackage {
1717- /** Bare specifiers this package is imported under (e.g. `vue`, `@vue/runtime-dom`). */
1818- specifiers: Set<string>
1919- /** Output chunk basename, e.g. `vue` -> emitted as `_nuxt/vue.js`. */
2020- chunk: string
2121-}
1616+/**
1717+ * Recipe version embedded in every content-addressed specifier, meant to be bumped
1818+ * whenever the build recipe (bundler version, options, define replacements)
1919+ * changes in a way that alters emitted bytes, so chunks built under different
2020+ * recipes cannot silently collide on the same SHA-256.
2121+ */
2222+const RECIPE = 'cos1'
22232323-function bareSpecifier(chunk: string): string {
2424- return `coschunk-${chunk}`
2424+function contentSpecifier(hash: string): string {
2525+ return `${RECIPE}:${hash}`
2526}
26272728export default defineNuxtModule<ModuleOptions>({
···47484849 addServerPlugin(resolver.resolve('./runtime/server/plugins/inject'))
49505050- const collected = new Map<string, CollectedPackage>()
5151- const usedChunkNames = new Set<string>()
5252-5353- function chunkNameFor(specifier: string): string {
5454- let index = 0
5555- let name: string
5656- do {
5757- name = (specifier + (index ? `-${index}` : '')).replace(/[^a-z0-9]/gi, '-').replace(/(^-+)|(-+$)/g, '')
5858- index++
5959- } while (usedChunkNames.has(name))
6060- usedChunkNames.add(name)
6161- return name
6262- }
5151+ const collected = new Set<string>()
63526453 addVitePlugin(() => ({
6554 name: 'nuxt-cos',
···7665 return
7766 }
78677979- let pkg = collected.get(resolved.id)
8080- if (!pkg) {
8181- pkg = { specifiers: new Set(), chunk: chunkNameFor(id) }
8282- collected.set(resolved.id, pkg)
8383- }
8484- pkg.specifiers.add(id)
6868+ collected.add(resolved.id)
85698686- return { id: bareSpecifier(pkg.chunk), external: true }
7070+ // Externalise under a synthetic specifier so it never clashes with the
7171+ // real module id elsewhere in the app graph. It is rewritten to a
7272+ // content-addressed specifier in `generateBundle`, once every managed
7373+ // chunk has been hashed bottom-up.
7474+ return { id: `cos-ext:${resolved.id}`, external: true }
8775 },
8876 },
8977 async generateBundle(_outputOptions, bundle) {
9090- const externalIds = [...collected.keys()]
9191- // Map every bare specifier any managed package may emit to its chunk.
9292- const specifierToChunk = new Map<string, string>()
9393- for (const pkg of collected.values()) {
9494- for (const specifier of pkg.specifiers) {
9595- specifierToChunk.set(specifier, pkg.chunk)
9696- }
9797- }
9898-9999- const managed: CosManifest['chunks'] = {}
7878+ const ids = [...collected]
7979+ const idSet = new Set(ids)
10080101101- for (const [input, pkg] of collected) {
8181+ // Build each managed package once, externalising its siblings. The raw
8282+ // output keeps sibling imports as their resolved absolute ids, which
8383+ // double as the dependency edges between managed chunks.
8484+ const raw = new Map<string, { code: string, deps: string[] }>()
8585+ for (const input of ids) {
10286 const builder = await rolldown({
10387 input,
10488 platform: 'browser',
10589 treeshake: false,
106106- external: externalIds.filter(id => id !== input),
9090+ external: ids.filter(id => id !== input),
10791 })
108108- const { output } = await builder.generate({ file: `${pkg.chunk}.js`, codeSplitting: false })
9292+ const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true })
10993 await builder.close()
11094111111- let code = output[0].code
112112- for (const [specifier, chunk] of specifierToChunk) {
113113- code = rewriteSpecifier(code, specifier, bareSpecifier(chunk))
9595+ const code = output[0].code
9696+ const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))]
9797+ .filter(spec => idSet.has(spec))
9898+ raw.set(input, { code, deps })
9999+ }
100100+101101+ // Hash bottom-up: a chunk's specifier for a dependency is that
102102+ // dependency's content hash, so a chunk can only be hashed once all of
103103+ // its dependencies have been. The npm graph for these packages is a DAG.
104104+ const hashes = new Map<string, string>()
105105+ const managed: CosManifest['chunks'] = {}
106106+107107+ const visit = (id: string, stack: string[]): string => {
108108+ const existing = hashes.get(id)
109109+ if (existing) {
110110+ return existing
114111 }
115115- // Imports rolldown kept as resolved absolute paths to other managed packages.
116116- for (const otherId of externalIds) {
117117- const chunk = collected.get(otherId)!.chunk
118118- code = rewriteSpecifier(code, otherId, bareSpecifier(chunk))
112112+ if (stack.includes(id)) {
113113+ throw new Error(`[nuxt-cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`)
119114 }
120115121121- const fileName = `_nuxt/${pkg.chunk}.js`
122122- const hash = createHash('sha256').update(code).digest('hex')
123123- managed[bareSpecifier(pkg.chunk)] = { file: `${pkg.chunk}.js`, hash }
116116+ const { code, deps } = raw.get(id)!
117117+ let resolved = code
118118+ for (const dep of deps) {
119119+ resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id])))
120120+ }
121121+122122+ const hash = createHash('sha256').update(resolved).digest('hex')
123123+ const fileName = `_nuxt/${hash}.js`
124124+ hashes.set(id, hash)
125125+ managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash }
124126 bundle[fileName] = {
125127 type: 'asset',
126128 fileName,
127127- name: pkg.chunk,
128128- names: [pkg.chunk],
129129+ name: hash,
130130+ names: [hash],
129131 originalFileName: null,
130132 originalFileNames: [],
131133 needsCodeReference: false,
132132- source: code,
134134+ source: resolved,
133135 }
136136+ return hash
134137 }
135138136136- let entry: string | undefined
139139+ for (const id of ids) {
140140+ visit(id, [])
141141+ }
142142+143143+ let entry: CosManifest['entry'] | undefined
137144 for (const file of Object.values(bundle)) {
138145 if (file.type !== 'chunk') {
139146 continue
140147 }
141141- for (const [specifier, chunk] of specifierToChunk) {
142142- file.code = rewriteSpecifier(file.code, specifier, bareSpecifier(chunk))
148148+ for (const id of ids) {
149149+ file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!))
143150 }
144151 if (file.isEntry) {
145145- entry = bareSpecifier(file.fileName)
146146- managed[bareSpecifier(file.fileName)] ??= {
147147- file: file.fileName.replace(/^_nuxt\//, ''),
148148- hash: createHash('sha256').update(file.code).digest('hex'),
149149- }
152152+ // the entry is app-specific and should not be content-addressed
153153+ entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(/^_nuxt\//, '') }
150154 }
151155 }
152156
+9-4
src/runtime/loader.ts
···1818export interface CosManifest {
1919 /** Public base path that managed chunks are served from, e.g. `/_nuxt/`. */
2020 base: string
2121- /** Bare specifier of the entry chunk to import once the import map is ready. */
2222- entry: string
2323- /** Map of bare specifier to `{ file, hash }` for every managed chunk. */
2121+ /**
2222+ * The entry chunk to import once the import map is ready. It is app-specific, so it is
2323+ * loaded straight from the network rather than stored in COS by a content hash.
2424+ */
2525+ entry: { specifier: string, file: string }
2626+ /** Map of content-addressed specifier to `{ file, hash }` for every COS-managed chunk. */
2427 chunks: Record<string, { file: string, hash: string }>
2528}
2629···7073 }),
7174 )
72757676+ imports[manifest.entry.specifier] = new URL(manifest.base + manifest.entry.file, location.origin).href
7777+7378 const script = document.createElement('script')
7479 script.type = 'importmap'
7580 script.textContent = JSON.stringify({ imports })
7681 document.head.appendChild(script)
77827883 await new Promise(resolve => setTimeout(resolve, 0))
7979- await import(/* @vite-ignore */ manifest.entry)
8484+ await import(/* @vite-ignore */ manifest.entry.specifier)
8085}
+55-24
test/build.test.ts
···11import { execSync } from 'node:child_process'
22+import { createHash } from 'node:crypto'
23import { readFileSync, readdirSync, rmSync } from 'node:fs'
34import { fileURLToPath } from 'node:url'
45import { join } from 'node:path'
···78const fixtureDir = fileURLToPath(new URL('./fixtures/basic', import.meta.url))
89const publicNuxt = join(fixtureDir, '.output/public/_nuxt')
9101010-function importsOf(file: string): string[] {
1111+function build(): void {
1212+ rmSync(join(fixtureDir, '.output'), { recursive: true, force: true })
1313+ rmSync(join(fixtureDir, '.nuxt'), { recursive: true, force: true })
1414+ execSync('npx nuxi build', { cwd: fixtureDir, stdio: 'inherit' })
1515+}
1616+1717+function cosChunks(): string[] {
1818+ return readdirSync(publicNuxt).filter(f => /^[a-f0-9]{64}\.js$/.test(f))
1919+}
2020+2121+function specifiersOf(file: string): string[] {
1122 const code = readFileSync(join(publicNuxt, file), 'utf8')
1223 const specifiers = [...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!)
1324 return [...new Set(specifiers)]
1425}
15261627describe('cos build output', () => {
1717- beforeAll(() => {
1818- rmSync(join(fixtureDir, '.output'), { recursive: true, force: true })
1919- rmSync(join(fixtureDir, '.nuxt'), { recursive: true, force: true })
2020- execSync('npx nuxi build', { cwd: fixtureDir, stdio: 'inherit' })
2121- }, 240_000)
2828+ beforeAll(build, 240_000)
22292323- it('emits a standalone chunk for every managed vue package', () => {
2424- const files = readdirSync(publicNuxt)
2525- for (const name of ['vue', 'vue-runtime-dom', 'vue-runtime-core', 'vue-reactivity', 'vue-shared']) {
2626- expect(files, `missing ${name}.js`).toContain(`${name}.js`)
3030+ it('emits one content-addressed chunk per managed vue package', () => {
3131+ // vue + runtime-dom + runtime-core + reactivity + shared
3232+ expect(cosChunks()).toHaveLength(5)
3333+ })
3434+3535+ it('names every chunk after the sha-256 of its bytes', () => {
3636+ for (const file of cosChunks()) {
3737+ const hash = file.replace('.js', '')
3838+ const actual = createHash('sha256').update(readFileSync(join(publicNuxt, file))).digest('hex')
3939+ expect(actual).toBe(hash)
4040+ }
4141+ })
4242+4343+ it('references dependencies only by content-addressed specifier', () => {
4444+ for (const file of cosChunks()) {
4545+ for (const specifier of specifiersOf(file)) {
4646+ expect(specifier, `${file} imports non-content-addressed ${specifier}`).toMatch(/^cos1:[a-f0-9]{64}$/)
4747+ }
2748 }
2849 })
29503030- it('externalises managed packages instead of inlining them (no duplication)', () => {
3131- // If vue were self-contained it would be ~300KB; externalised it is tiny.
3232- const vue = readFileSync(join(publicNuxt, 'vue.js'), 'utf8')
3333- expect(vue.length).toBeLessThan(5_000)
3434- expect(importsOf('vue.js')).toEqual(['coschunk-vue-runtime-dom'])
5151+ it('externalises shared dependencies instead of inlining them (no duplication)', () => {
5252+ const sizes = cosChunks().map(f => readFileSync(join(publicNuxt, f)).length)
5353+ // A self-contained vue would be ~150KB minified; externalised it is tiny.
5454+ expect(Math.min(...sizes)).toBeLessThan(1_000)
3555 })
36563757 it('keeps the reactivity singleton as a single shared leaf chunk', () => {
3838- // @vue/shared is imported by every other vue chunk and imports nothing.
3939- expect(importsOf('vue-shared.js')).toEqual([])
4040- for (const dependant of ['vue-runtime-dom', 'vue-runtime-core', 'vue-reactivity']) {
4141- expect(importsOf(`${dependant}.js`)).toContain('coschunk-vue-shared')
4242- }
5858+ const chunks = cosChunks()
5959+ const leaves = chunks.filter(f => specifiersOf(f).length === 0)
6060+ // @vue/shared is the only package that imports nothing; if it were
6161+ // duplicated or inlined there would be zero or several leaves.
6262+ expect(leaves, 'expected exactly one dependency-free leaf (@vue/shared)').toHaveLength(1)
6363+6464+ const leafSpecifier = `cos1:${leaves[0]!.replace('.js', '')}`
6565+ const directDependants = chunks.filter(f => specifiersOf(f).includes(leafSpecifier))
6666+ // runtime-dom, runtime-core and reactivity all import @vue/shared directly.
6767+ expect(directDependants.length).toBeGreaterThanOrEqual(3)
4368 })
44694545- it('leaves no dangling bare vue specifiers in any chunk', () => {
4646- for (const file of readdirSync(publicNuxt).filter(f => f.endsWith('.js'))) {
4747- const bare = importsOf(file).filter(s => /^(?:vue|@vue\/)/.test(s))
4848- expect(bare, `${file} still imports ${bare.join(', ')}`).toEqual([])
7070+ it('leaves no machine-specific paths in any chunk', () => {
7171+ for (const file of cosChunks()) {
7272+ const code = readFileSync(join(publicNuxt, file), 'utf8')
7373+ expect(code, `${file} leaks a path`).not.toMatch(/node_modules|cos-ext:|#region/)
4974 }
5075 })
7676+7777+ it('produces identical hashes when rebuilt (deterministic)', () => {
7878+ const first = cosChunks().sort()
7979+ build()
8080+ expect(cosChunks().sort()).toEqual(first)
8181+ }, 240_000)
5182})
+3-3
test/ssr.test.ts
···1616 it('injects the cos loader and removes the default entry script', async () => {
1717 const html = await $fetch('/')
1818 expect(html).toContain('<script id="cos-loader">')
1919- expect(html).toContain('coschunk-vue')
1919+ expect(html).toMatch(/cos1:[a-f0-9]{64}/)
2020 expect(html).not.toMatch(/<script type="module"[^>]*src="\/_nuxt\/[^"]*"/)
2121 })
22222323- it('inlines a manifest entry that resolves to a real chunk', async () => {
2323+ it('inlines a manifest whose entry resolves to a managed chunk', async () => {
2424 const html = await $fetch('/')
2525- const entry = html.match(/"entry":"(coschunk-[^"]+)"/)?.[1]
2525+ const entry = html.match(/"entry":"(cos1:[a-f0-9]{64})"/)?.[1]
2626 expect(entry).toBeDefined()
2727 const chunks = html.match(/"chunks":\{(.+?)\}\}\)/)?.[1] ?? ''
2828 expect(chunks).toContain(`"${entry}":`)