diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c083850..0fa947f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20, 22] + node: [20, 22] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/.gitignore b/.gitignore index 04f9a0d..b294ea6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules dist *.tgz +package-lock.json .DS_Store diff --git a/assets/logo.svg b/assets/logo.svg index 2ecedbd..bbdf66e 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,13 +1,11 @@ - - - - - - - - - - - + + + + + + + + + diff --git a/src/bundle.ts b/src/bundle.ts index 35bde4f..8737bbd 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -81,14 +81,18 @@ function enqueue(fn: () => Promise): Promise { }) } -function buildGlobalsMap( +export function buildGlobalsMap( external: string[], ): Record { const globals: Record = {} for (const pkg of external) { - // Strip scope prefix (@scope/pkg → pkg), then camelCase - const bare = pkg.startsWith('@') ? pkg.split('/')[1] ?? pkg : pkg - globals[pkg] = bare.replace(/[-.](\w)/g, (_, c: string) => c.toUpperCase()) + // @scope/pkg → scopePkg, lodash-es → lodashEs + const name = pkg + .replace(/^@/, '') + .replace(/[/\-.]/g, ' ') + .trim() + .replace(/ (\w)/g, (_, c: string) => c.toUpperCase()) + globals[pkg] = name } return globals } diff --git a/src/cache.ts b/src/cache.ts index 1745bdc..3baa3a4 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,12 +1,13 @@ import fs from 'node:fs' +import type { CookedQuery } from './types.js' const cache = new Map() -export function getCacheKey( +export async function getCacheKey( filepath: string, - query: CookedQuery_Like, -): string { - const stat = fs.statSync(filepath) + query: CookedQuery, +): Promise { + const stat = await fs.promises.stat(filepath) const fingerprint = `${stat.mtimeMs}:${stat.size}` const queryStr = stableStringify(query) return `${filepath}:${queryStr}:${fingerprint}` @@ -29,14 +30,13 @@ export function invalidateCache(filepath: string): void { } } -type CookedQuery_Like = Record - -function stableStringify(obj: CookedQuery_Like): string { +function stableStringify(obj: CookedQuery): string { + const record = obj as unknown as Record return JSON.stringify( - Object.keys(obj) + Object.keys(record) .sort() .reduce>((acc, key) => { - acc[key] = obj[key] + acc[key] = record[key] return acc }, {}), ) diff --git a/src/index.ts b/src/index.ts index de48ff4..df73721 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import fs from 'node:fs' -import type { Plugin, ResolvedConfig } from 'vite' +import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' import { parseCookedQuery } from './parse-query.js' import { compileCode } from './transform.js' import { bundleCode } from './bundle.js' @@ -11,8 +11,11 @@ export { parseCookedQuery } from './parse-query.js' export { compileCode } from './transform.js' export { bundleCode } from './bundle.js' +const HMR_ACCEPT = `\nif (import.meta.hot) { import.meta.hot.accept() }\n` + export default function cookedPlugin(options?: CookedOptions): Plugin { let resolvedConfig: ResolvedConfig + let server: ViteDevServer | undefined return { name: 'vite-plugin-cooked', @@ -22,9 +25,19 @@ export default function cookedPlugin(options?: CookedOptions): Plugin { resolvedConfig = config }, - configureServer(server) { + configureServer(_server) { + server = _server server.watcher.on('change', (changedPath) => { invalidateCache(changedPath) + + // Invalidate cooked modules that depend on the changed file + for (const [, mod] of server!.moduleGraph.idToModuleMap) { + if (!mod.id) continue + const parsed = parseCookedQuery(mod.id) + if (parsed && parsed.filepath === changedPath) { + server!.moduleGraph.invalidateModule(mod) + } + } }) }, @@ -62,14 +75,12 @@ export default function cookedPlugin(options?: CookedOptions): Plugin { this.addWatchFile(filepath) - const cacheKey = getCacheKey( - filepath, - query as unknown as Record, - ) + const cacheKey = await getCacheKey(filepath, query) const cached = getFromCache(cacheKey) if (cached) { + const code = 'export default ' + JSON.stringify(cached) return { - code: 'export default ' + JSON.stringify(cached), + code: server ? code + HMR_ACCEPT : code, moduleType: 'js', } } @@ -97,8 +108,9 @@ export default function cookedPlugin(options?: CookedOptions): Plugin { setCache(cacheKey, result) + const code = 'export default ' + JSON.stringify(result) return { - code: 'export default ' + JSON.stringify(result), + code: server ? code + HMR_ACCEPT : code, moduleType: 'js', } }, diff --git a/src/transform.ts b/src/transform.ts index ce7db62..1a790b5 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -34,10 +34,13 @@ async function transformWithEsbuild( format: 'esm', minify, ...(target ? { target } : {}), - ...(query.banner ? { banner: query.banner } : {}), }) - return result.code + let output = result.code + if (query.banner) { + output = query.banner + '\n' + output + } + return output } async function transformWithOxc( diff --git a/test/index.test.ts b/test/index.test.ts index d5c92a5..87a4aa9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,7 +2,7 @@ import path from 'node:path' import { describe, expect, it, vi } from 'vitest' import { parseCookedQuery } from '../src/parse-query.js' import { compileCode } from '../src/transform.js' -import { bundleCode } from '../src/bundle.js' +import { bundleCode, buildGlobalsMap } from '../src/bundle.js' import { getCacheKey, getFromCache, invalidateCache, setCache } from '../src/cache.js' import cookedPlugin from '../src/index.js' import type { CookedQuery } from '../src/types.js' @@ -367,10 +367,17 @@ describe('external', () => { minify: false, external: ['lodash-es'], }) - // IIFE should reference the global variable + // IIFE should reference the global variable (lodash-es → lodashEs) expect(code).toContain('lodashEs') }) + it('buildGlobalsMap preserves scope in global name', () => { + const globals = buildGlobalsMap(['@emotion/react', '@scope/my-pkg', 'lodash-es']) + expect(globals['@emotion/react']).toBe('emotionReact') + expect(globals['@scope/my-pkg']).toBe('scopeMyPkg') + expect(globals['lodash-es']).toBe('lodashEs') + }) + it('external=* + format=iife throws', async () => { await expect( bundleCode({ @@ -434,9 +441,15 @@ describe('external', () => { describe('cache', () => { it('returns cached result on second call', async () => { const filepath = fixture('basic.ts') - const query = { to: 'js', minify: false, format: 'es', nobundle: false } + const query = { + to: 'js' as const, + minify: false, + format: 'es' as const, + nobundle: false, + external: null, + } - const key = getCacheKey(filepath, query) + const key = await getCacheKey(filepath, query) expect(getFromCache(key)).toBeUndefined() setCache(key, 'cached-code') @@ -446,21 +459,35 @@ describe('cache', () => { expect(getFromCache(key)).toBeUndefined() }) - it('generates different keys for different queries', () => { + it('generates different keys for different queries', async () => { const filepath = fixture('basic.ts') - const key1 = getCacheKey(filepath, { to: 'js', minify: false }) - const key2 = getCacheKey(filepath, { to: 'js', minify: true }) + const key1 = await getCacheKey(filepath, { + to: 'js', + minify: false, + nobundle: false, + external: null, + }) + const key2 = await getCacheKey(filepath, { + to: 'js', + minify: true, + nobundle: false, + external: null, + }) expect(key1).not.toBe(key2) }) - it('generates different keys for different external values', () => { + it('generates different keys for different external values', async () => { const filepath = fixture('basic.ts') - const key1 = getCacheKey(filepath, { + const key1 = await getCacheKey(filepath, { to: 'js', + minify: false, + nobundle: false, external: null, }) - const key2 = getCacheKey(filepath, { + const key2 = await getCacheKey(filepath, { to: 'js', + minify: false, + nobundle: false, external: ['lodash-es'], }) expect(key1).not.toBe(key2) @@ -639,4 +666,72 @@ describe('plugin hooks', () => { /\?to=ts cannot be used with bundle mode/, ) }) + + it('load does not include HMR code without dev server', async () => { + const plugin = cookedPlugin() + const load = plugin.load as Function + const addWatchFile = vi.fn() + + const id = fixture('basic.ts') + '?to=js&nobundle' + const result = await load.call({ addWatchFile }, id) + + expect(result.code).not.toContain('import.meta.hot') + }) + + it('load includes HMR accept code after configureServer', async () => { + const plugin = cookedPlugin() + const configureServer = plugin.configureServer as Function + const load = plugin.load as Function + const addWatchFile = vi.fn() + + // Simulate dev server + const mockServer = { + watcher: { on: vi.fn() }, + moduleGraph: { idToModuleMap: new Map() }, + } + configureServer(mockServer) + + const id = fixture('with-types.ts') + '?to=js&nobundle' + const result = await load.call({ addWatchFile }, id) + + expect(result.code).toContain('export default ') + expect(result.code).toContain('import.meta.hot') + expect(result.code).toContain('import.meta.hot.accept()') + }) + + it('configureServer invalidates cooked modules on file change', () => { + const plugin = cookedPlugin() + const configureServer = plugin.configureServer as Function + + const invalidateModule = vi.fn() + const cookedModId = fixture('basic.ts') + '?to=js' + const otherModId = '/some/other.ts?to=js' + + const mockMod = { id: cookedModId } + const otherMod = { id: otherModId } + const idToModuleMap = new Map([ + [cookedModId, mockMod], + [otherModId, otherMod], + ]) + + let changeHandler: Function + const mockServer = { + watcher: { + on: vi.fn((event: string, handler: Function) => { + if (event === 'change') changeHandler = handler + }), + }, + moduleGraph: { idToModuleMap, invalidateModule }, + } + + configureServer(mockServer) + + // Trigger file change for the cooked source file + changeHandler!(fixture('basic.ts')) + + // Should invalidate the matching cooked module + expect(invalidateModule).toHaveBeenCalledWith(mockMod) + // Should NOT invalidate unrelated modules + expect(invalidateModule).not.toHaveBeenCalledWith(otherMod) + }) })