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

refactor: extract vite plugin

+338 -156
+1
.gitignore
··· 55 55 Temporary Items 56 56 .apdisk 57 57 test/.cos-extension 58 + test/.plugin-scratch
+10 -1
package.json
··· 50 50 "playwright-core": "^1.61.1", 51 51 "typescript": "~6.0.3", 52 52 "vitest": "^4.1.8", 53 - "vue-tsc": "^3.3.3" 53 + "vue-tsc": "^3.3.3", 54 + "vite": "^7.3.5" 55 + }, 56 + "peerDependencies": { 57 + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" 58 + }, 59 + "peerDependenciesMeta": { 60 + "vite": { 61 + "optional": true 62 + } 54 63 } 55 64 }
+3
pnpm-lock.yaml
··· 51 51 typescript: 52 52 specifier: ~6.0.3 53 53 version: 6.0.3 54 + vite: 55 + specifier: ^7.3.5 56 + version: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) 54 57 vitest: 55 58 specifier: ^4.1.8 56 59 version: 4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
+7 -155
src/module.ts
··· 1 - import { createHash } from 'node:crypto' 2 1 import { defineNuxtModule, addServerPlugin, addVitePlugin, createResolver } from '@nuxt/kit' 3 - import { rolldown } from 'rolldown' 4 - import type { CosManifest } from './runtime/loader' 5 - 6 - const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__' 7 - 8 - /** 9 - * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving 10 - * `__COS_MANIFEST__` as a literal token for the caller to substitute. 11 - */ 12 - async function bundleLoader(entry: string): Promise<string> { 13 - const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true }) 14 - const { output } = await builder.generate({ format: 'iife', minify: true }) 15 - await builder.close() 16 - return output[0].code 17 - } 2 + import { cosPlugin } from './vite' 18 3 19 4 export interface ModuleOptions { 20 5 /** ··· 25 10 packages: Array<string | RegExp> 26 11 } 27 12 28 - /** 29 - * Recipe version embedded in every content-addressed specifier, meant to be bumped 30 - * whenever the build recipe (bundler version, options, define replacements) 31 - * changes in a way that alters emitted bytes, so chunks built under different 32 - * recipes cannot silently collide on the same SHA-256. 33 - */ 34 - const RECIPE = 'cos1' 35 - 36 - function contentSpecifier(hash: string): string { 37 - return `${RECIPE}:${hash}` 38 - } 39 - 40 13 export default defineNuxtModule<ModuleOptions>({ 41 14 meta: { 42 15 name: 'nuxt-cos', ··· 51 24 } 52 25 53 26 const resolver = createResolver(import.meta.url) 54 - const packages = options.packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p) 55 27 56 - const loaderEntry = resolver.resolve('./runtime/loader.entry') 57 - let loaderTemplate: Promise<string> | undefined 58 28 let scriptContent = '' 59 29 60 30 nuxt.options.nitro.virtual ||= {} ··· 62 32 63 33 addServerPlugin(resolver.resolve('./runtime/server/plugins/inject')) 64 34 65 - const collected = new Set<string>() 66 - 67 - addVitePlugin(() => ({ 68 - name: 'nuxt-cos', 69 - enforce: 'pre', 70 - resolveId: { 71 - order: 'pre', 72 - async handler(id, importer, resolveOptions) { 73 - if (!packages.some(p => p.test(id))) { 74 - return 75 - } 76 - 77 - const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true }) 78 - if (!resolved) { 79 - return 80 - } 81 - 82 - collected.add(resolved.id) 83 - 84 - // Externalise under a synthetic specifier so it never clashes with the 85 - // real module id elsewhere in the app graph. It is rewritten to a 86 - // content-addressed specifier in `generateBundle`, once every managed 87 - // chunk has been hashed bottom-up. 88 - return { id: `cos-ext:${resolved.id}`, external: true } 89 - }, 90 - }, 91 - async generateBundle(_outputOptions, bundle) { 92 - const ids = [...collected] 93 - const idSet = new Set(ids) 94 - 95 - // Build each managed package once, externalising its siblings. The raw 96 - // output keeps sibling imports as their resolved absolute ids, which 97 - // double as the dependency edges between managed chunks. 98 - const raw = new Map<string, { code: string, deps: string[] }>() 99 - for (const input of ids) { 100 - const builder = await rolldown({ 101 - input, 102 - platform: 'browser', 103 - treeshake: false, 104 - external: ids.filter(id => id !== input), 105 - }) 106 - const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) 107 - await builder.close() 108 - 109 - const code = output[0].code 110 - const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))] 111 - .filter(spec => idSet.has(spec)) 112 - raw.set(input, { code, deps }) 113 - } 114 - 115 - // Hash bottom-up: a chunk's specifier for a dependency is that 116 - // dependency's content hash, so a chunk can only be hashed once all of 117 - // its dependencies have been. The npm graph for these packages is a DAG. 118 - const hashes = new Map<string, string>() 119 - const managed: CosManifest['chunks'] = {} 120 - 121 - const visit = (id: string, stack: string[]): string => { 122 - const existing = hashes.get(id) 123 - if (existing) { 124 - return existing 125 - } 126 - if (stack.includes(id)) { 127 - throw new Error(`[nuxt-cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`) 128 - } 129 - 130 - const { code, deps } = raw.get(id)! 131 - let resolved = code 132 - for (const dep of deps) { 133 - resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id]))) 134 - } 135 - 136 - const hash = createHash('sha256').update(resolved).digest('hex') 137 - const fileName = `_nuxt/${hash}.js` 138 - hashes.set(id, hash) 139 - managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash } 140 - bundle[fileName] = { 141 - type: 'asset', 142 - fileName, 143 - name: hash, 144 - names: [hash], 145 - originalFileName: null, 146 - originalFileNames: [], 147 - needsCodeReference: false, 148 - source: resolved, 149 - } 150 - return hash 151 - } 152 - 153 - for (const id of ids) { 154 - visit(id, []) 155 - } 156 - 157 - let entry: CosManifest['entry'] | undefined 158 - for (const file of Object.values(bundle)) { 159 - if (file.type !== 'chunk') { 160 - continue 161 - } 162 - for (const id of ids) { 163 - file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 164 - } 165 - if (file.isEntry) { 166 - // the entry is app-specific and should not be content-addressed 167 - entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(/^_nuxt\//, '') } 168 - } 169 - } 170 - 171 - if (!entry) { 172 - return 173 - } 174 - 175 - const manifest: CosManifest = { base: '/_nuxt/', entry, chunks: managed } 176 - loaderTemplate ??= bundleLoader(loaderEntry) 177 - scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest)) 35 + addVitePlugin(() => cosPlugin({ 36 + packages: options.packages, 37 + base: '/_nuxt/', 38 + loaderEntry: resolver.resolve('./runtime/loader.entry'), 39 + onGenerated: (content) => { 40 + scriptContent = content 178 41 }, 179 42 }), { client: true, server: false }) 180 43 }, 181 44 }) 182 - 183 - function rewriteSpecifier(code: string, from: string, to: string): string { 184 - const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 185 - const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g') 186 - const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g') 187 - const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g') 188 - return code 189 - .replace(dynamic, `$1$2${to}$2$3`) 190 - .replace(fromImport, `$1$2${to}$2`) 191 - .replace(bareImport, `$1$2${to}$2`) 192 - }
+238
src/vite.ts
··· 1 + import { createHash } from 'node:crypto' 2 + import { fileURLToPath } from 'node:url' 3 + import { rolldown } from 'rolldown' 4 + import type { Plugin } from 'vite' 5 + import type { CosManifest } from './runtime/loader' 6 + 7 + export type { CosManifest } 8 + 9 + const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__' 10 + 11 + /** 12 + * Recipe version embedded in every content-addressed specifier. Bump this 13 + * whenever the build recipe (bundler version, options, define replacements) 14 + * changes in a way that alters emitted bytes, so chunks built under different 15 + * recipes cannot silently collide on the same SHA-256. 16 + */ 17 + const RECIPE = 'cos1' 18 + 19 + const DEFAULT_LOADER_ENTRY = fileURLToPath(new URL('./runtime/loader.entry.js', import.meta.url)) 20 + 21 + export interface CosPluginOptions { 22 + /** 23 + * Packages to extract into standalone Cross-Origin Storage chunks. Each entry 24 + * is matched against the imported module specifier; a plain string is treated 25 + * as an exact match. 26 + */ 27 + packages: Array<string | RegExp> 28 + /** 29 + * Public base path the managed chunks are served from. Defaults to Vite's 30 + * resolved `base` joined with `build.assetsDir`. 31 + */ 32 + base?: string 33 + /** 34 + * Path to the runtime loader entry to bundle into the injected `<script>`. 35 + * Defaults to the bundled loader. Override only to swap the loader runtime. 36 + */ 37 + loaderEntry?: string 38 + /** 39 + * Called once the managed chunks are emitted, with the loader `<script>` body 40 + * (loader IIFE + inlined manifest). SSR frameworks should inject this into 41 + * their rendered HTML themselves. When omitted, the plugin injects it into 42 + * `index.html` via `transformIndexHtml` for plain client builds. 43 + */ 44 + onGenerated?: (scriptContent: string) => void 45 + } 46 + 47 + function contentSpecifier(hash: string): string { 48 + return `${RECIPE}:${hash}` 49 + } 50 + 51 + function toMatchers(packages: Array<string | RegExp>): RegExp[] { 52 + return packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p) 53 + } 54 + 55 + /** 56 + * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving 57 + * `__COS_MANIFEST__` as a literal token for the caller to substitute. Bundling 58 + * from source keeps the loader correct regardless of how the host build loaded 59 + * this plugin. 60 + */ 61 + async function bundleLoader(entry: string): Promise<string> { 62 + const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true }) 63 + const { output } = await builder.generate({ format: 'iife', minify: true }) 64 + await builder.close() 65 + return output[0].code 66 + } 67 + 68 + function rewriteSpecifier(code: string, from: string, to: string): string { 69 + const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 70 + const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g') 71 + const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g') 72 + const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g') 73 + return code 74 + .replace(dynamic, `$1$2${to}$2$3`) 75 + .replace(fromImport, `$1$2${to}$2`) 76 + .replace(bareImport, `$1$2${to}$2`) 77 + } 78 + 79 + function joinBase(base: string, assetsDir: string): string { 80 + const prefix = base.endsWith('/') ? base : `${base}/` 81 + const dir = assetsDir.replace(/^\/+|\/+$/g, '') 82 + return dir ? `${prefix}${dir}/` : prefix 83 + } 84 + 85 + export function cosPlugin(options: CosPluginOptions): Plugin { 86 + const packages = toMatchers(options.packages) 87 + const loaderEntry = options.loaderEntry ?? DEFAULT_LOADER_ENTRY 88 + 89 + const collected = new Set<string>() 90 + let assetsDir = 'assets' 91 + let resolvedBase = '/' 92 + let loaderTemplate: Promise<string> | undefined 93 + let scriptContent = '' 94 + 95 + return { 96 + name: 'vite-plugin-cos', 97 + enforce: 'pre', 98 + apply: 'build', 99 + configResolved(config) { 100 + assetsDir = config.build.assetsDir 101 + resolvedBase = config.base 102 + }, 103 + resolveId: { 104 + order: 'pre', 105 + async handler(id, importer, resolveOptions) { 106 + if (!packages.some(p => p.test(id))) { 107 + return 108 + } 109 + 110 + const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true }) 111 + if (!resolved) { 112 + return 113 + } 114 + 115 + collected.add(resolved.id) 116 + 117 + // Externalise under a synthetic specifier so it never clashes with the 118 + // real module id elsewhere in the app graph. It is rewritten to a 119 + // content-addressed specifier in `generateBundle`, once every managed 120 + // chunk has been hashed bottom-up. 121 + return { id: `cos-ext:${resolved.id}`, external: true } 122 + }, 123 + }, 124 + async generateBundle(_outputOptions, bundle) { 125 + const ids = [...collected] 126 + if (!ids.length) { 127 + return 128 + } 129 + const idSet = new Set(ids) 130 + const base = options.base ?? joinBase(resolvedBase, assetsDir) 131 + const assetPrefix = assetsDir ? `${assetsDir.replace(/^\/+|\/+$/g, '')}/` : '' 132 + 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. 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() 150 + 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 }) 155 + } 156 + 157 + // Hash bottom-up: a chunk's specifier for a dependency is that 158 + // dependency's content hash, so a chunk can only be hashed once all of 159 + // its dependencies have been. The npm graph for these packages is a DAG. 160 + const hashes = new Map<string, string>() 161 + const managed: CosManifest['chunks'] = {} 162 + 163 + const visit = (id: string, stack: string[]): string => { 164 + const existing = hashes.get(id) 165 + if (existing) { 166 + return existing 167 + } 168 + if (stack.includes(id)) { 169 + throw new Error(`[cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`) 170 + } 171 + 172 + const { code, deps } = raw.get(id)! 173 + let resolved = code 174 + for (const dep of deps) { 175 + resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id]))) 176 + } 177 + 178 + const hash = createHash('sha256').update(resolved).digest('hex') 179 + const fileName = `${assetPrefix}${hash}.js` 180 + hashes.set(id, hash) 181 + managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash } 182 + bundle[fileName] = { 183 + type: 'asset', 184 + fileName, 185 + name: hash, 186 + names: [hash], 187 + originalFileName: null, 188 + originalFileNames: [], 189 + needsCodeReference: false, 190 + source: resolved, 191 + } 192 + return hash 193 + } 194 + 195 + for (const id of ids) { 196 + visit(id, []) 197 + } 198 + 199 + let entry: CosManifest['entry'] | undefined 200 + for (const file of Object.values(bundle)) { 201 + if (file.type !== 'chunk') { 202 + continue 203 + } 204 + for (const id of ids) { 205 + file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) 206 + } 207 + if (file.isEntry) { 208 + // The entry is app-specific and is re-rendered by Vite after this 209 + // hook, so it cannot be content-addressed here; it loads from the 210 + // network under a stable specifier instead. 211 + entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(new RegExp(`^${assetPrefix}`), '') } 212 + } 213 + } 214 + 215 + if (!entry) { 216 + return 217 + } 218 + 219 + const manifest: CosManifest = { base, entry, chunks: managed } 220 + loaderTemplate ??= bundleLoader(loaderEntry) 221 + scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest)) 222 + options.onGenerated?.(scriptContent) 223 + }, 224 + transformIndexHtml: { 225 + order: 'post', 226 + handler(html) { 227 + if (options.onGenerated || !scriptContent) { 228 + return html 229 + } 230 + return html 231 + .replace(/<script type="module"[^>]*src="[^"]*"[^>]*><\/script>/g, '') 232 + .replace('</head>', `<script id="cos-loader">${scriptContent}</script></head>`) 233 + }, 234 + }, 235 + } 236 + } 237 + 238 + export default cosPlugin
+79
test/plugin.test.ts
··· 1 + import { createHash } from 'node:crypto' 2 + import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync, globSync } from 'node:fs' 3 + import { fileURLToPath } from 'node:url' 4 + import { join } from 'node:path' 5 + import { afterAll, beforeAll, describe, expect, it } from 'vitest' 6 + import { build } from 'vite' 7 + import { cosPlugin } from '../src/vite' 8 + 9 + // Build inside the project tree so the fixture resolves `vue` from the project 10 + // node_modules rather than a detached temp dir. 11 + const scratchRoot = fileURLToPath(new URL('./.plugin-scratch', import.meta.url)) 12 + 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 + 15 + describe('cosPlugin (standalone vite build)', () => { 16 + let root: string 17 + let outDir: string 18 + let assetsDir: string 19 + 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') 31 + 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) 41 + 42 + afterAll(() => { 43 + rmSync(scratchRoot, { recursive: true, force: true }) 44 + }) 45 + 46 + function cosChunks(): string[] { 47 + return readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f)) 48 + } 49 + 50 + 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') 54 + expect(hash).toBe(file.replace('.js', '')) 55 + } 56 + }) 57 + 58 + 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) { 63 + expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/) 64 + } 65 + } 66 + }) 67 + 68 + it('injects the loader into index.html and removes the default entry script', () => { 69 + const html = readFileSync(join(outDir, 'index.html'), 'utf8') 70 + expect(html).toContain('<script id="cos-loader">') 71 + expect(html).toMatch(/cos1:[a-f0-9]{64}/) 72 + expect(html).not.toMatch(/<script type="module"[^>]*src="[^"]*\.js"/) 73 + }) 74 + 75 + it('derives the base path from the vite config', () => { 76 + const html = readFileSync(join(outDir, 'index.html'), 'utf8') 77 + expect(html).toMatch(/"base":"\/assets\/"/) 78 + }) 79 + })