Skip to content

Instantly share code, notes, and snippets.

@notcome
Created March 5, 2025 10:47
Show Gist options
  • Save notcome/18d31fe63313041b2880753b04816a3e to your computer and use it in GitHub Desktop.
Save notcome/18d31fe63313041b2880753b04816a3e to your computer and use it in GitHub Desktop.
A faster Deno vite plugin
import { Plugin } from 'vite'
import fsp from 'node:fs/promises'
import { transform } from "esbuild";
import {
isDenoSpecifier,
mediaTypeToLoader,
parseDenoSpecifier,
resolveDeno,
ResolvedInfo,
toDenoSpecifier,
} from './utils.ts'
import { cwd } from 'node:process'
const logLevel = 100
const peerModulePrefix = '@pea2'
const viteTempPrefixes = ['/__manifest?', '/@id/__x00', '/shadcn/']
async function log(message: string, level: number = 0) {
if (level < logLevel) {
return
}
await fsp.appendFile('/tmp/faster-deno.txt', message + '\n\n')
}
class Lock {
private locked: boolean = false
private waitQueue: Array<() => void> = []
acquireLock(): Promise<void> {
// If lock is not currently held, acquire it immediately
if (!this.locked) {
this.locked = true
return Promise.resolve()
}
// Otherwise, wait for the lock to be released
return new Promise<void>((resolve) => {
this.waitQueue.push(resolve)
})
}
releaseLock(): void {
if (!this.locked) {
throw new Error('Cannot release a lock that is not held')
}
// If there are waiters, resolve the next one in queue
if (this.waitQueue.length > 0) {
const nextResolver = this.waitQueue.shift()!
nextResolver()
} else {
// Otherwise, mark the lock as free
this.locked = false
}
}
}
class Resolver {
cwd: string
esmModules: Map<string, ResolvedInfo>
codeSpecifierToPath: Map<string, string>
peerModules: Map<string, ResolvedInfo>
lock: Lock
constructor(cwd: string) {
this.cwd = cwd
this.esmModules = new Map()
this.codeSpecifierToPath = new Map()
this.peerModules = new Map()
this.lock = new Lock()
}
async resolveId(id: string): Promise<ResolvedInfo | null> {
const isPeerModule = id.startsWith(peerModulePrefix)
if (isPeerModule) {
const peerModule = this.peerModules.get(id)
if (peerModule) {
return peerModule
}
}
await log(
`Execute "deno info" for\n ${id}`, 1
)
const result = await resolveDeno(id, this.cwd)
if (!result) {
return null
}
const { specifier, tree } = result
let targetModule: ResolvedInfo | null = null
let cachedPathCount = 0
for (const module of tree.modules) {
if (!('kind' in module && module.kind === 'esm')) {
// await log(`Skipping non-esm module\n ${module.specifier}`)
continue
}
const modulePath = module.local
if (!this.esmModules.has(modulePath)) {
this.esmModules.set(modulePath, module)
this.codeSpecifierToPath.set(module.specifier, modulePath)
cachedPathCount += 1
}
if (specifier === module.specifier) {
targetModule = module
if (isPeerModule) {
this.peerModules.set(id, module)
}
}
}
await log(`Cached ${cachedPathCount} paths`)
return targetModule
}
async fetchESMModule(path: string, id: string): Promise<ResolvedInfo | null> {
const module = this.esmModules.get(path)
if (module) {
return module
}
return await this.resolveId(id)
}
async _resolveNestedImport(
id: string,
importerId: string,
importerPath: string,
): Promise<string | null> {
const module = await this.fetchESMModule(importerPath, importerId)
if (!module) {
await log('Cannot resolve due to missing importer module!')
return null
}
const dependency = module.dependencies.find((d) => d.specifier === id)
if (!dependency) {
await log(
`Cannot resolve since dependency is missing!:\n${
JSON.stringify(module, null, 2)
}`,
)
return null
}
const specifier = dependency.code.specifier
let targetModule: ResolvedInfo | undefined = undefined
const targetPath = this.codeSpecifierToPath.get(specifier)
if (targetPath) {
targetModule = this.esmModules.get(targetPath)
} else {
const result = await this.resolveId(specifier)
if (!result) {
await log(`Cannot resolve ${specifier}`)
return null
}
targetModule = result
}
if (!targetModule) {
throw new Error('Internal inconsistency!')
}
return toDenoSpecifier(
targetModule.mediaType,
specifier,
targetModule.local,
)
}
async resolveNestedImport(
id: string,
importerId: string,
importerPath: string,
): Promise<string | null> {
await this.lock.acquireLock()
try {
await log(
`Requested to resolve\n ${id}\n ${importerId}\n ${importerPath}`,
)
const resolved = await this._resolveNestedImport(
id,
importerId,
importerPath,
)
await log(
`Resolved to ${resolved} for:\n ${id}\n ${importerId}\n ${importerPath}`,
)
return resolved
} finally {
this.lock.releaseLock()
}
}
async _resolveBase(id: string): Promise<string | null> {
const targetModule = await this.resolveId(id)
if (!targetModule) {
await log(`Cannot resolve ${id}`)
return null
}
return toDenoSpecifier(
targetModule.mediaType,
targetModule.specifier,
targetModule.local,
)
}
async resolveBase(id: string): Promise<string | null> {
await this.lock.acquireLock()
try {
return await this._resolveBase(id)
} finally {
this.lock.releaseLock()
}
}
}
function mainPlugin(): Plugin {
const root = cwd()
const resolver = new Resolver(root)
return {
async resolveId(id: string, importer: string) {
// Those are temporary virtual files created by vite.
// Fast-path here to improve refresh performance.
for (const prefix of viteTempPrefixes) {
if (id.startsWith(prefix)) {
return null
}
}
if (isDenoSpecifier(id)) {
return
}
if (!isDenoSpecifier(importer)) {
return await resolver.resolveBase(id)
} else {
// We only work with dependencies of Deno-specific files.
const { id: importerId, resolved: importerPath } = parseDenoSpecifier(
importer,
)
return await resolver.resolveNestedImport(id, importerId, importerPath)
}
},
async load(id: string) {
if (!isDenoSpecifier(id)) {
return
}
const { loader, resolved } = parseDenoSpecifier(id);
const content = await fsp.readFile(resolved, "utf-8");
if (loader === "JavaScript")
return content;
if (loader === "Json") {
return `export default ${content}`;
}
const result = await transform(content, {
format: "esm",
loader: mediaTypeToLoader(loader),
logLevel: "debug",
});
// Issue: https://github.com/denoland/deno-vite-plugin/issues/38
// Esbuild uses an empty string as empty value and vite expects
// `null` to be the empty value. This seems to be only the case in
// `dev` mode
const map = result.map === "" ? null : result.map;
return {
code: result.code,
map,
};
},
}
}
export default function fasterDeno(): Plugin[] {
return [mainPlugin()]
}
import { exec, execFile, ExecOptions } from 'node:child_process'
import process from 'node:process'
import { Loader } from 'esbuild'
export type DenoSpecifierName = string & { __brand: 'deno' }
export type DenoMediaType =
| 'TypeScript'
| 'TSX'
| 'JavaScript'
| 'JSX'
| 'Json'
export function isDenoSpecifier(str: string): str is DenoSpecifierName {
return str.startsWith('\0deno')
}
export function toDenoSpecifier(
loader: DenoMediaType,
id: string,
resolved: string,
): DenoSpecifierName {
return `\0deno::${loader}::${id}::${resolved}` as DenoSpecifierName
}
export function parseDenoSpecifier(spec: DenoSpecifierName): {
loader: DenoMediaType
id: string
resolved: string
} {
const [_, loader, id, resolved] = spec.split('::') as [
string,
string,
DenoMediaType,
string,
]
return { loader: loader as DenoMediaType, id, resolved }
}
export interface ResolvedInfo {
kind: 'esm'
local: string
size: number
mediaType: DenoMediaType
specifier: string
dependencies: Array<{
specifier: string
code: {
specifier: string
span: { start: unknown; end: unknown }
}
}>
}
interface NpmResolvedInfo {
kind: 'npm'
specifier: string
npmPackage: string
}
interface ExternalResolvedInfo {
kind: 'external'
specifier: string
}
interface ResolveError {
specifier: string
error: string
}
interface DenoInfoJsonV1 {
version: 1
redirects: Record<string, string>
roots: string[]
modules: Array<
NpmResolvedInfo | ResolvedInfo | ExternalResolvedInfo | ResolveError
>
}
export interface DenoResolveResult {
id: string
kind: 'esm' | 'npm'
loader: DenoMediaType | null
dependencies: ResolvedInfo['dependencies']
}
function isResolveError(
info: NpmResolvedInfo | ResolvedInfo | ExternalResolvedInfo | ResolveError,
): info is ResolveError {
return 'error' in info && typeof info.error === 'string'
}
let checkedDenoInstall = false
const DENO_BINARY = process.platform === 'win32' ? 'deno.exe' : 'deno'
export async function execAsync(
cmd: string,
options: ExecOptions,
): Promise<{ stderr: string; stdout: string }> {
return await new Promise((resolve, reject) =>
exec(cmd, options, (error, stdout, stderr) => {
if (error) reject(error)
else resolve({ stdout, stderr })
})
)
}
export async function resolveDeno(
id: string,
cwd: string,
): Promise<{ specifier: string; tree: DenoInfoJsonV1 } | null> {
if (!checkedDenoInstall) {
try {
await execAsync(`${DENO_BINARY} --version`, { cwd })
checkedDenoInstall = true
} catch {
throw new Error(
`Deno binary could not be found. Install Deno to resolve this error.`,
)
}
}
// There is no JS-API in Deno to get the final file path in Deno's
// cache directory. The `deno info` command reveals that information
// though, so we can use that.
const output = await new Promise<string | null>((resolve, reject) => {
execFile(DENO_BINARY, ['info', '--json', id], { cwd }, (error, stdout) => {
if (error) {
if (String(error).includes('Integrity check failed')) {
reject(error)
} else {
resolve(null)
}
} else resolve(stdout)
})
})
if (output === null) {
return null
}
const json = JSON.parse(output) as DenoInfoJsonV1
const actualId = json.roots[0]
// Find the final resolved cache path. First, we need to check
// if the redirected specifier, which represents the final specifier.
// This is often used for `http://` imports where a server can do
// redirects.
const redirected = json.redirects[actualId] ?? actualId
// Find the module information based on the redirected speciffier
const mod = json.modules.find((info) => info.specifier === redirected)
if (mod === undefined) {
return null
}
// Specifier not found by deno
if (isResolveError(mod)) {
return null
}
return {
specifier: redirected,
tree: json,
}
}
export function mediaTypeToLoader(media: DenoMediaType): Loader {
switch (media) {
case "JSX":
return "jsx";
case "JavaScript":
return "js";
case "Json":
return "json";
case "TSX":
return "tsx";
case "TypeScript":
return "ts";
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment