[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: auto-collect transitive deps of managed packages

+240 -66
+64 -26
src/vite.ts
··· 122 122 }, 123 123 }, 124 124 async generateBundle(_outputOptions, bundle) { 125 - const ids = [...collected] 126 - if (!ids.length) { 125 + if (!collected.size) { 127 126 return 128 127 } 129 - const idSet = new Set(ids) 130 128 const base = options.base ?? joinBase(resolvedBase, assetsDir) 131 129 const assetPrefix = assetsDir ? `${assetsDir.replace(/^\/+|\/+$/g, '')}/` : '' 132 130 133 - // Build each managed package once, externalising its siblings. The raw 134 - // output keeps sibling imports as their resolved absolute ids, which 135 - // double as the dependency edges between managed chunks. 131 + // Build each managed package standalone, externalising every dependency 132 + // by its resolved absolute id. Transitive dependencies are discovered and 133 + // queued here, so managing a package implicitly manages its whole import 134 + // subgraph (e.g. `vue` pulls in `@vue/*`) without the app having to list 135 + // them. The externalised ids double as the edges between managed chunks. 136 136 const raw = new Map<string, { code: string, deps: string[] }>() 137 - for (const input of ids) { 138 - const builder = await rolldown({ 139 - input, 140 - platform: 'browser', 141 - treeshake: false, 142 - external: ids.filter(id => id !== input), 143 - }) 144 - // `minify` is part of the pinned recipe (see RECIPE): it both shrinks 145 - // the chunk and strips rolldown's `//#region <path>` debug comments, 146 - // which embed cwd-relative paths and would otherwise make the hash 147 - // depend on the build location. 148 - const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) 149 - await builder.close() 137 + const queue = [...collected] 138 + while (queue.length) { 139 + const input = queue.shift()! 140 + if (raw.has(input)) { 141 + continue 142 + } 150 143 151 - const code = output[0].code 152 - const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] 153 - .filter(spec => idSet.has(spec)) 154 - raw.set(input, { code, deps }) 144 + const deps = new Set<string>() 145 + let code: string 146 + try { 147 + const builder = await rolldown({ 148 + input, 149 + platform: 'browser', 150 + treeshake: false, 151 + plugins: [{ 152 + name: 'cos-externalise-deps', 153 + async resolveId(id, importer) { 154 + if (!importer) { 155 + return null 156 + } 157 + const dep = await this.resolve(id, importer, { skipSelf: true }) 158 + if (!dep) { 159 + return null 160 + } 161 + deps.add(dep.id) 162 + // Externalise under a synthetic specifier keyed by the resolved 163 + // id, so the emitted import is a literal token we rewrite later. 164 + // Source specifiers may be relative (`./shared/x.mjs`); the 165 + // token makes the rewrite independent of how they were written. 166 + return { id: `cos-dep:${dep.id}`, external: true } 167 + }, 168 + }], 169 + }) 170 + // `minify` is part of the pinned recipe (see RECIPE): it both shrinks 171 + // the chunk and strips rolldown's `//#region <path>` debug comments, 172 + // which embed cwd-relative paths and would otherwise make the hash 173 + // depend on the build location. 174 + const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) 175 + await builder.close() 176 + code = output[0].code 177 + } 178 + catch (error) { 179 + throw new Error( 180 + `[cos] cannot bundle managed package as a standalone chunk:\n ${input}\n` 181 + + `It likely imports build-time virtuals (e.g. \`#build/*\`, \`#imports\`) that only ` 182 + + `resolve inside the host build, so it is not a self-contained, shareable artifact. ` 183 + + `Only depend on packages whose source resolves from disk on its own.\n\n` 184 + + `Underlying error: ${(error as Error).message}`, 185 + { cause: error }, 186 + ) 187 + } 188 + 189 + raw.set(input, { code, deps: [...deps] }) 190 + queue.push(...deps) 155 191 } 156 192 157 193 // Hash bottom-up: a chunk's specifier for a dependency is that ··· 172 208 const { code, deps } = raw.get(id)! 173 209 let resolved = code 174 210 for (const dep of deps) { 175 - resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id]))) 211 + resolved = rewriteSpecifier(resolved, `cos-dep:${dep}`, contentSpecifier(visit(dep, [...stack, id]))) 176 212 } 177 213 178 214 const hash = createHash('sha256').update(resolved).digest('hex') ··· 192 228 return hash 193 229 } 194 230 195 - for (const id of ids) { 231 + for (const id of raw.keys()) { 196 232 visit(id, []) 197 233 } 198 234 ··· 201 237 if (file.type !== 'chunk') { 202 238 continue 203 239 } 204 - for (const id of ids) { 240 + // App chunks only reference the packages the app imported directly, 241 + // externalised as `cos-ext:<id>` by this plugin's `resolveId`. 242 + for (const id of collected) { 205 243 file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 206 244 } 207 245 if (file.isEntry) {
+30
test/build-virtual.test.ts
··· 1 + import { execSync } from 'node:child_process' 2 + import { rmSync } from 'node:fs' 3 + import { fileURLToPath } from 'node:url' 4 + import { join } from 'node:path' 5 + import { describe, expect, it } from 'vitest' 6 + 7 + const fixtureDir = fileURLToPath(new URL('./fixtures/build-virtual', import.meta.url)) 8 + 9 + describe('nuxt #app is rejected as a cos candidate', () => { 10 + it('fails the build with a clear diagnostic, not a raw resolve error', () => { 11 + // `#app` is stitched together from per-app build virtuals (`#build/*`, 12 + // `#imports`, ...), so it is neither self-contained nor shareable across 13 + // origins. The plugin must say so rather than emit a cryptic error. 14 + rmSync(join(fixtureDir, '.output'), { recursive: true, force: true }) 15 + rmSync(join(fixtureDir, '.nuxt'), { recursive: true, force: true }) 16 + 17 + let output = '' 18 + expect(() => { 19 + try { 20 + execSync('npx nuxi build', { cwd: fixtureDir, encoding: 'utf8', stdio: 'pipe' }) 21 + } 22 + catch (error) { 23 + output = `${(error as { stdout?: string }).stdout ?? ''}${(error as { stderr?: string }).stderr ?? ''}` 24 + throw error 25 + } 26 + }).toThrow() 27 + 28 + expect(output).toContain('cannot bundle managed package as a standalone chunk') 29 + }, 240_000) 30 + })
+14
test/fixtures/build-virtual/app.vue
··· 1 + <template> 2 + <div> 3 + <p>count: {{ count }}</p> 4 + <button @click="count++"> 5 + increment 6 + </button> 7 + </div> 8 + </template> 9 + 10 + <script setup lang="ts"> 11 + import { ref } from 'vue' 12 + 13 + const count = ref(0) 14 + </script>
+6
test/fixtures/build-virtual/nuxt.config.ts
··· 1 + export default defineNuxtConfig({ 2 + modules: ['nuxt-cos'], 3 + cos: { 4 + packages: [/^#app(?:\/|$)/], 5 + }, 6 + })
+14
test/fixtures/build-virtual/package.json
··· 1 + { 2 + "private": true, 3 + "name": "build-virtual-fixture", 4 + "type": "module", 5 + "scripts": { 6 + "dev": "nuxt dev", 7 + "build": "nuxt build", 8 + "generate": "nuxt generate" 9 + }, 10 + "dependencies": { 11 + "nuxt-cos": "latest", 12 + "nuxt": "^4.4.8" 13 + } 14 + }
+112 -40
test/plugin.test.ts
··· 5 5 import { afterAll, beforeAll, describe, expect, it } from 'vitest' 6 6 import { build } from 'vite' 7 7 import { cosPlugin } from '../src/vite' 8 + import type { Alias } from 'vite' 8 9 9 - // Build inside the project tree so the fixture resolves `vue` from the project 10 + // Build inside the project tree so fixtures resolve packages from the project 10 11 // node_modules rather than a detached temp dir. 11 12 const scratchRoot = fileURLToPath(new URL('./.plugin-scratch', import.meta.url)) 12 13 const nodeModules = fileURLToPath(new URL('../node_modules', import.meta.url)) 13 - const vueEntry = globSync('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js', { cwd: nodeModules })[0]! 14 14 15 - describe('cosPlugin (standalone vite build)', () => { 16 - let root: string 17 - let outDir: string 18 - let assetsDir: string 15 + function resolvePkg(glob: string): string { 16 + const match = globSync(glob, { cwd: nodeModules })[0] 17 + if (!match) { 18 + throw new Error(`fixture dependency not found: ${glob}`) 19 + } 20 + return join(nodeModules, match) 21 + } 19 22 20 - beforeAll(async () => { 21 - mkdirSync(scratchRoot, { recursive: true }) 22 - root = mkdtempSync(join(scratchRoot, 'app-')) 23 - outDir = join(root, 'dist') 24 - assetsDir = join(outDir, 'assets') 25 - mkdirSync(join(root, 'src'), { recursive: true }) 26 - writeFileSync( 27 - join(root, 'index.html'), 28 - '<!doctype html><html><head></head><body><script type="module" src="/src/main.js"></script></body></html>', 29 - ) 30 - writeFileSync(join(root, 'src/main.js'), 'import { ref } from "vue"\ndocument.body.dataset.count = String(ref(0).value)\n') 23 + interface Built { 24 + outDir: string 25 + assetsDir: string 26 + cosChunks: () => string[] 27 + specifiersOf: (file: string) => string[] 28 + html: () => string 29 + } 31 30 32 - await build({ 33 - root, 34 - logLevel: 'error', 35 - // The fixture lives in a scratch dir; point bare `vue` at the project copy. 36 - resolve: { alias: { vue: join(nodeModules, vueEntry) } }, 37 - plugins: [cosPlugin({ packages: [/^(?:vue$|@vue\/)/] })], 38 - build: { outDir, emptyOutDir: true, rollupOptions: { input: join(root, 'index.html') } }, 39 - }) 40 - }, 120_000) 31 + async function buildApp(entry: string, packages: Array<string | RegExp>, alias: Alias[]): Promise<Built> { 32 + mkdirSync(scratchRoot, { recursive: true }) 33 + const root = mkdtempSync(join(scratchRoot, 'app-')) 34 + const outDir = join(root, 'dist') 35 + const assetsDir = join(outDir, 'assets') 36 + mkdirSync(join(root, 'src'), { recursive: true }) 37 + writeFileSync( 38 + join(root, 'index.html'), 39 + '<!doctype html><html><head></head><body><script type="module" src="/src/main.js"></script></body></html>', 40 + ) 41 + writeFileSync(join(root, 'src/main.js'), entry) 41 42 42 - afterAll(() => { 43 - rmSync(scratchRoot, { recursive: true, force: true }) 43 + await build({ 44 + root, 45 + logLevel: 'error', 46 + resolve: { alias }, 47 + plugins: [cosPlugin({ packages })], 48 + build: { outDir, emptyOutDir: true, rollupOptions: { input: join(root, 'index.html') } }, 44 49 }) 45 50 46 - function cosChunks(): string[] { 47 - return readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f)) 51 + return { 52 + outDir, 53 + assetsDir, 54 + cosChunks: () => readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f)), 55 + specifiersOf: (file) => { 56 + const code = readFileSync(join(assetsDir, file), 'utf8') 57 + return [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] 58 + }, 59 + html: () => readFileSync(join(outDir, 'index.html'), 'utf8'), 48 60 } 61 + } 62 + 63 + afterAll(() => { 64 + rmSync(scratchRoot, { recursive: true, force: true }) 65 + }) 66 + 67 + describe('cosPlugin with vue', () => { 68 + let app: Built 69 + 70 + beforeAll(async () => { 71 + app = await buildApp( 72 + 'import { ref } from "vue"\ndocument.body.dataset.count = String(ref(0).value)\n', 73 + [/^(?:vue$|@vue\/)/], 74 + [{ find: 'vue', replacement: resolvePkg('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js') }], 75 + ) 76 + }, 120_000) 49 77 50 78 it('emits content-addressed chunks whose names match their bytes', () => { 51 - expect(cosChunks().length).toBeGreaterThanOrEqual(1) 52 - for (const file of cosChunks()) { 53 - const hash = createHash('sha256').update(readFileSync(join(assetsDir, file))).digest('hex') 79 + expect(app.cosChunks().length).toBeGreaterThanOrEqual(1) 80 + for (const file of app.cosChunks()) { 81 + const hash = createHash('sha256').update(readFileSync(join(app.assetsDir, file))).digest('hex') 54 82 expect(hash).toBe(file.replace('.js', '')) 55 83 } 56 84 }) 57 85 58 86 it('rewrites managed imports to content-addressed specifiers', () => { 59 - for (const file of cosChunks()) { 60 - const code = readFileSync(join(assetsDir, file), 'utf8') 61 - const specifiers = [...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!) 62 - for (const specifier of specifiers) { 87 + for (const file of app.cosChunks()) { 88 + for (const specifier of app.specifiersOf(file)) { 63 89 expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/) 64 90 } 65 91 } 66 92 }) 67 93 68 94 it('injects the loader into index.html and removes the default entry script', () => { 69 - const html = readFileSync(join(outDir, 'index.html'), 'utf8') 95 + const html = app.html() 70 96 expect(html).toContain('<script id="cos-loader">') 71 97 expect(html).toMatch(/cos1:[a-f0-9]{64}/) 72 98 expect(html).not.toMatch(/<script type="module"[^>]*src="[^"]*\.js"/) 73 99 }) 74 100 75 101 it('derives the base path from the vite config', () => { 76 - const html = readFileSync(join(outDir, 'index.html'), 'utf8') 77 - expect(html).toMatch(/"base":"\/assets\/"/) 102 + expect(app.html()).toMatch(/"base":"\/assets\/"/) 103 + }) 104 + }) 105 + 106 + describe('cosPlugin with a non-vue package graph (unhead + hookable)', () => { 107 + let app: Built 108 + 109 + beforeAll(async () => { 110 + // unhead imports hookable transitively; managing only `unhead` should still 111 + // externalise hookable into its own shared chunk via auto-collection, 112 + // exactly as @vue/shared is for the vue graph. This proves the algorithm is 113 + // package-agnostic, not vue-shaped, and that transitive deps are collected. 114 + app = await buildApp( 115 + 'import { createHead } from "unhead/client"\ndocument.title = String(!!createHead)\n', 116 + ['unhead/client'], 117 + [ 118 + { find: /^unhead\/client$/, replacement: resolvePkg('.pnpm/unhead@*/node_modules/unhead/dist/client.mjs') }, 119 + { find: /^hookable$/, replacement: resolvePkg('.pnpm/hookable@*/node_modules/hookable/dist/index.mjs') }, 120 + ], 121 + ) 122 + }, 120_000) 123 + 124 + it('auto-collects transitive deps the app never imported directly', () => { 125 + // The app imports only `unhead/client`, yet hookable (a transitive dep) and 126 + // unhead's internal shared chunks each become their own managed chunk. 127 + expect(app.cosChunks().length).toBeGreaterThan(1) 128 + for (const file of app.cosChunks()) { 129 + const hash = createHash('sha256').update(readFileSync(join(app.assetsDir, file))).digest('hex') 130 + expect(hash).toBe(file.replace('.js', '')) 131 + } 132 + }) 133 + 134 + it('externalises shared deps into leaf chunks rather than inlining them', () => { 135 + // A leaf imports no managed chunk; if deps were inlined there would be no 136 + // leaves, and a chunk with deps depends on those leaves. 137 + const leaves = app.cosChunks().filter(f => app.specifiersOf(f).length === 0) 138 + expect(leaves.length).toBeGreaterThanOrEqual(1) 139 + 140 + const dependants = app.cosChunks().filter(f => app.specifiersOf(f).length > 0) 141 + expect(dependants.length).toBeGreaterThanOrEqual(1) 142 + }) 143 + 144 + it('references dependencies only by content-addressed specifier', () => { 145 + for (const file of app.cosChunks()) { 146 + for (const specifier of app.specifiersOf(file)) { 147 + expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/) 148 + } 149 + } 78 150 }) 79 151 })