diff --git a/.gitignore b/.gitignore index 9f726d4..2daac9e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ Network Trash Folder Temporary Items .apdisk test/.cos-extension +test/.plugin-scratch diff --git a/package.json b/package.json index 46e7660..ddb944e 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,15 @@ "playwright-core": "^1.61.1", "typescript": "~6.0.3", "vitest": "^4.1.8", - "vue-tsc": "^3.3.3" + "vue-tsc": "^3.3.3", + "vite": "^7.3.5" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a80f754..926a395 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: typescript: specifier: ~6.0.3 version: 6.0.3 + vite: + specifier: ^7.3.5 + version: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) vitest: specifier: ^4.1.8 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)) diff --git a/src/module.ts b/src/module.ts index a3f5cd3..3e7f5aa 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,20 +1,5 @@ -import { createHash } from 'node:crypto' import { defineNuxtModule, addServerPlugin, addVitePlugin, createResolver } from '@nuxt/kit' -import { rolldown } from 'rolldown' -import type { CosManifest } from './runtime/loader' - -const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__' - -/** - * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving - * `__COS_MANIFEST__` as a literal token for the caller to substitute. - */ -async function bundleLoader(entry: string): Promise { - const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true }) - const { output } = await builder.generate({ format: 'iife', minify: true }) - await builder.close() - return output[0].code -} +import { cosPlugin } from './vite' export interface ModuleOptions { /** @@ -25,18 +10,6 @@ export interface ModuleOptions { packages: Array } -/** - * 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({ meta: { name: 'nuxt-cos', @@ -51,10 +24,7 @@ export default defineNuxtModule({ } const resolver = createResolver(import.meta.url) - const packages = options.packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p) - const loaderEntry = resolver.resolve('./runtime/loader.entry') - let loaderTemplate: Promise | undefined let scriptContent = '' nuxt.options.nitro.virtual ||= {} @@ -62,131 +32,13 @@ export default defineNuxtModule({ addServerPlugin(resolver.resolve('./runtime/server/plugins/inject')) - const collected = new Set() - - addVitePlugin(() => ({ - name: 'nuxt-cos', - enforce: 'pre', - resolveId: { - order: 'pre', - async handler(id, importer, resolveOptions) { - if (!packages.some(p => p.test(id))) { - return - } - - const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true }) - if (!resolved) { - return - } - - collected.add(resolved.id) - - // 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 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: ids.filter(id => id !== input), - }) - const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true }) - await builder.close() - - 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 - } - if (stack.includes(id)) { - throw new Error(`[nuxt-cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`) - } - - 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: hash, - names: [hash], - originalFileName: null, - originalFileNames: [], - needsCodeReference: false, - source: resolved, - } - return hash - } - - for (const id of ids) { - visit(id, []) - } - - let entry: CosManifest['entry'] | undefined - for (const file of Object.values(bundle)) { - if (file.type !== 'chunk') { - continue - } - for (const id of ids) { - file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!)) - } - if (file.isEntry) { - // the entry is app-specific and should not be content-addressed - entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(/^_nuxt\//, '') } - } - } - - if (!entry) { - return - } - - const manifest: CosManifest = { base: '/_nuxt/', entry, chunks: managed } - loaderTemplate ??= bundleLoader(loaderEntry) - scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest)) + addVitePlugin(() => cosPlugin({ + packages: options.packages, + base: '/_nuxt/', + loaderEntry: resolver.resolve('./runtime/loader.entry'), + onGenerated: (content) => { + scriptContent = content }, }), { client: true, server: false }) }, }) - -function rewriteSpecifier(code: string, from: string, to: string): string { - const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g') - const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g') - const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g') - return code - .replace(dynamic, `$1$2${to}$2$3`) - .replace(fromImport, `$1$2${to}$2`) - .replace(bareImport, `$1$2${to}$2`) -} diff --git a/src/vite.ts b/src/vite.ts new file mode 100644 index 0000000..276db8a --- /dev/null +++ b/src/vite.ts @@ -0,0 +1,238 @@ +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { rolldown } from 'rolldown' +import type { Plugin } from 'vite' +import type { CosManifest } from './runtime/loader' + +export type { CosManifest } + +const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__' + +/** + * Recipe version embedded in every content-addressed specifier. Bump this + * 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' + +const DEFAULT_LOADER_ENTRY = fileURLToPath(new URL('./runtime/loader.entry.js', import.meta.url)) + +export interface CosPluginOptions { + /** + * Packages to extract into standalone Cross-Origin Storage chunks. Each entry + * is matched against the imported module specifier; a plain string is treated + * as an exact match. + */ + packages: Array + /** + * Public base path the managed chunks are served from. Defaults to Vite's + * resolved `base` joined with `build.assetsDir`. + */ + base?: string + /** + * Path to the runtime loader entry to bundle into the injected ``) + }, + }, + } +} + +export default cosPlugin diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 0000000..eda8fc6 --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,79 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync, globSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { build } from 'vite' +import { cosPlugin } from '../src/vite' + +// Build inside the project tree so the fixture resolves `vue` from the project +// node_modules rather than a detached temp dir. +const scratchRoot = fileURLToPath(new URL('./.plugin-scratch', import.meta.url)) +const nodeModules = fileURLToPath(new URL('../node_modules', import.meta.url)) +const vueEntry = globSync('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js', { cwd: nodeModules })[0]! + +describe('cosPlugin (standalone vite build)', () => { + let root: string + let outDir: string + let assetsDir: string + + beforeAll(async () => { + mkdirSync(scratchRoot, { recursive: true }) + root = mkdtempSync(join(scratchRoot, 'app-')) + outDir = join(root, 'dist') + assetsDir = join(outDir, 'assets') + mkdirSync(join(root, 'src'), { recursive: true }) + writeFileSync( + join(root, 'index.html'), + '', + ) + writeFileSync(join(root, 'src/main.js'), 'import { ref } from "vue"\ndocument.body.dataset.count = String(ref(0).value)\n') + + await build({ + root, + logLevel: 'error', + // The fixture lives in a scratch dir; point bare `vue` at the project copy. + resolve: { alias: { vue: join(nodeModules, vueEntry) } }, + plugins: [cosPlugin({ packages: [/^(?:vue$|@vue\/)/] })], + build: { outDir, emptyOutDir: true, rollupOptions: { input: join(root, 'index.html') } }, + }) + }, 120_000) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + }) + + function cosChunks(): string[] { + return readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f)) + } + + it('emits content-addressed chunks whose names match their bytes', () => { + expect(cosChunks().length).toBeGreaterThanOrEqual(1) + for (const file of cosChunks()) { + const hash = createHash('sha256').update(readFileSync(join(assetsDir, file))).digest('hex') + expect(hash).toBe(file.replace('.js', '')) + } + }) + + it('rewrites managed imports to content-addressed specifiers', () => { + for (const file of cosChunks()) { + const code = readFileSync(join(assetsDir, file), 'utf8') + const specifiers = [...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!) + for (const specifier of specifiers) { + expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/) + } + } + }) + + it('injects the loader into index.html and removes the default entry script', () => { + const html = readFileSync(join(outDir, 'index.html'), 'utf8') + expect(html).toContain('