Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
dist
*.tgz
package-lock.json
.DS_Store
20 changes: 9 additions & 11 deletions assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,18 @@ function enqueue(fn: () => Promise<string>): Promise<string> {
})
}

function buildGlobalsMap(
export function buildGlobalsMap(
external: string[],
): Record<string, string> {
const globals: Record<string, string> = {}
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
}
Expand Down
18 changes: 9 additions & 9 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import fs from 'node:fs'
import type { CookedQuery } from './types.js'

const cache = new Map<string, string>()

export function getCacheKey(
export async function getCacheKey(
filepath: string,
query: CookedQuery_Like,
): string {
const stat = fs.statSync(filepath)
query: CookedQuery,
): Promise<string> {
const stat = await fs.promises.stat(filepath)
const fingerprint = `${stat.mtimeMs}:${stat.size}`
const queryStr = stableStringify(query)
return `${filepath}:${queryStr}:${fingerprint}`
Expand All @@ -29,14 +30,13 @@ export function invalidateCache(filepath: string): void {
}
}

type CookedQuery_Like = Record<string, unknown>

function stableStringify(obj: CookedQuery_Like): string {
function stableStringify(obj: CookedQuery): string {
const record = obj as unknown as Record<string, unknown>
return JSON.stringify(
Object.keys(obj)
Object.keys(record)
.sort()
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = obj[key]
acc[key] = record[key]
return acc
}, {}),
)
Expand Down
28 changes: 20 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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',
Expand All @@ -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)
}
}
})
},

Expand Down Expand Up @@ -62,14 +75,12 @@ export default function cookedPlugin(options?: CookedOptions): Plugin {

this.addWatchFile(filepath)

const cacheKey = getCacheKey(
filepath,
query as unknown as Record<string, unknown>,
)
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',
}
}
Expand Down Expand Up @@ -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',
}
},
Expand Down
7 changes: 5 additions & 2 deletions src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
115 changes: 105 additions & 10 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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')
Expand All @@ -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)
Expand Down Expand Up @@ -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)
})
})
Loading