vite-plugin-node-worker
Version:
Vite plugin for Node.js worker threads. Supports DEV and BUILD modes.
571 lines (493 loc) • 19.5 kB
JavaScript
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL, URL, URLSearchParams } from "node:url";
import MagicString from "magic-string";
import { transformWithEsbuild, normalizePath,transformWithOxc } from "vite";
const transformWith = typeof transformWithOxc === "function" ? transformWithOxc : transformWithEsbuild;
const DEBUG = process.env.VNW_DEBUG === "1";
function normalizeAliases(raw) {
if (!raw) return [];
if (Array.isArray(raw))
return raw.map((e) => ({ find: e.find, replacement: e.replacement }));
return Object.entries(raw).map(([find, replacement]) => ({
find,
replacement,
}));
}
function applyAliases(spec, aliases) {
let out = spec;
for (const { find, replacement } of aliases) {
if (!find) continue;
if (find instanceof RegExp) {
if (find.test(out)) out = out.replace(find, replacement);
} else if (typeof find === "string") {
if (out.startsWith(find)) out = replacement + out.slice(find.length);
}
}
return out;
}
function sha1(s) {
return crypto.createHash("sha1").update(s).digest("hex");
}
function toRelativePath(filename, importer) {
const from = path.posix.dirname(importer);
const relPath = path.posix.relative(from, filename);
const norm = normalizePath(relPath);
return norm.startsWith(".") ? norm : `./${norm}`;
}
const queryRE = /\?.*$/s;
const hashRE = /#.*$/s;
const cleanUrl = (url) => url.replace(hashRE, "").replace(queryRE, "");
const PROBE_EXTS = ["", ".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"];
function fileExists(p) {
try {
return fs.existsSync(p) && fs.statSync(p).isFile();
} catch {
return false;
}
}
function tryResolveFileLike(base) {
const ext = path.extname(base);
const baseNoExt = ext ? base.slice(0, -ext.length) : base;
const candidates = new Set();
if (ext) {
candidates.add(base);
candidates.add(baseNoExt + ".ts");
candidates.add(baseNoExt + ".tsx");
candidates.add(baseNoExt + ".mts");
candidates.add(baseNoExt + ".cts");
candidates.add(baseNoExt + ".mjs");
candidates.add(baseNoExt + ".cjs");
candidates.add(baseNoExt + ".js");
} else {
for (const e of PROBE_EXTS) {
candidates.add(baseNoExt + e);
}
}
const dir = base;
for (const e of PROBE_EXTS.slice(1)) {
candidates.add(path.join(dir, `index${e}`));
}
for (const file of candidates) {
if (fileExists(file)) return file;
}
return null;
}
const toFileURL = (p) => pathToFileURL(path.resolve(p)).href;
const nodeWorkerAssetUrlRE = /__VITE_NODE_WORKER_ASSET__([\w$]+)__/g;
const RE = {
importFrom: /(import\s+[^'";]*?from\s*['"])([^'"\n]+)(['"])/g,
exportFrom: /(export\s+(?:type\s+)?(?:\{[^}]*\}|\*\s+as\s+\w+|\*|[\w$,\s]*)\s*from\s*['"])([^'"\n]+)(['"])/g,
dynImport: /(import\s*\(\s*['"])([^'"\n]+)(['"]\s*\))/g,
sideImport: /(import\s*['"])([^'"\n]+)(['"])/g,
};
const NODE_BUILTINS = new Set([
'assert','buffer','child_process','cluster','console','constants','crypto','dgram','diagnostics_channel','dns','domain','events','fs','fs/promises','http','http2','https','module','net','os','path','perf_hooks','process','punycode','querystring','readline','repl','stream','string_decoder','sys','timers','tls','tty','url','util','v8','vm','wasi','worker_threads','zlib'
]);
async function rewriteEntryAliasesToFile(src, entryFile, opts) {
const projectRoot = (opts && opts.root) || process.cwd();
const projectAliases = (opts && opts.aliases) || [];
const env = opts && opts.env;
// Use opts.cacheDir if provided, otherwise default to node_modules/.vite-plugin-node-worker/dev
const DEV_CACHE_DIR = (opts && opts.cacheDir)
? path.resolve(projectRoot, opts.cacheDir)
: path.join(projectRoot, "node_modules/.vite-plugin-node-worker/dev");
const cache = new Map();
const active = new Set();
// Cache transpiled outputs by (fsPath, mtime) to avoid re-transpiling unchanged files
const esbuildCache = new Map(); // key: fsPath, value: { mtimeMs: number, code: string }
const resolveCache = new Map(); // key: `${fromFile}::${spec}` -> string | null
function getMtimeMsSafe(p) {
try {
const st = fs.statSync(p);
return st.mtimeMs || 0;
} catch {
return 0;
}
}
function toURL(p) {
return toFileURL(p);
}
async function resolveToFs(spec, fromFile) {
if (!spec || typeof spec !== "string") return null;
const cacheKey = `${fromFile || ''}::${spec}`;
if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey);
// Treat external URL-like imports as non-rewritable
if (/^(?:https?:|data:|deno:)/.test(spec)) { resolveCache.set(cacheKey, null); return null; }
// Normalize node: scheme and skip node built-ins entirely
const noNodePrefix = spec.replace(/^node:/, "");
const isBare = !spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("file:") && !/^[A-Za-z]:\\/.test(spec);
if (isBare && NODE_BUILTINS.has(noNodePrefix)) { resolveCache.set(cacheKey, null); return null; }
// Apply project aliases for non-relative, non-absolute specs first
if (isBare) {
const aliased = applyAliases(spec, projectAliases);
if (aliased !== spec) spec = aliased;
else if (env?.pluginContainer?.resolveId) {
const r = await env.pluginContainer.resolveId(spec, fromFile);
if (r && r.id) {
// Explicit externals or node: builtins – do not rewrite
if (r.external) { resolveCache.set(cacheKey, null); return null; }
if (r.id.startsWith("node:")) { resolveCache.set(cacheKey, null); return null; }
// /@fs/ -> absolute filesystem path
if (r.id.startsWith("/@fs/")) { const v = r.id.slice(4); resolveCache.set(cacheKey, v); return v; }
// Virtual pre-bundled id – handle upstream in rewriteModule
if (r.id.startsWith("/@id/")) { resolveCache.set(cacheKey, r.id); return r.id; }
// Absolute path or file URL returned by resolver
if (path.isAbsolute(r.id) || r.id.startsWith("file:")) { resolveCache.set(cacheKey, r.id); return r.id; }
// Unknown shape – let upstream treat as virtual id
resolveCache.set(cacheKey, r.id); return r.id;
}
resolveCache.set(cacheKey, null); return null;
}
}
// file: URLs -> filesystem path
if (spec.startsWith("file:")) {
try { const v = new URL(spec).pathname; resolveCache.set(cacheKey, v); return v; } catch { resolveCache.set(cacheKey, null); return null; }
}
// Re-apply aliases defensively
spec = applyAliases(spec, projectAliases);
// Resolve to absolute base path
let base;
if (path.isAbsolute(spec)) {
base = spec.startsWith("/") ? path.resolve(projectRoot, spec.slice(1)) : spec;
} else if (spec.startsWith(".")) {
base = path.resolve(path.dirname(fromFile), spec);
} else {
base = path.resolve(projectRoot, spec);
}
const resolved = tryResolveFileLike(base);
resolveCache.set(cacheKey, resolved);
return resolved;
}
async function rewriteModule(fsPath) {
const skip = /\.(json|css|scss|sass|less|svg|png|jpe?g|gif|webp)$/i.test(
fsPath
);
if (skip) {
const url = toURL(fsPath);
cache.set(fsPath, url);
return url;
}
if (active.has(fsPath)) {
return toURL(fsPath);
}
if (cache.has(fsPath)) return cache.get(fsPath);
let code;
const isVirtualId = fsPath.startsWith("/@id/") || fsPath.startsWith("\u0000") || fsPath.startsWith("virtual:");
if (fsPath.startsWith("/@fs/")) {
fsPath = fsPath.slice(4);
}
try {
if (isVirtualId) {
if (!env || typeof env.transformRequest !== "function") {
// Cannot transform virtual ids without Vite – leave as-is
const url = toURL(fsPath);
cache.set(fsPath, url);
return url;
}
// Avoid SSR helpers in dev: use ssr: false
const res = await env.transformRequest(fsPath, { ssr: false });
if (!res || !res.code) {
const url = toURL(fsPath);
cache.set(fsPath, url);
return url;
}
code = res.code;
} else {
code = fs.readFileSync(fsPath, "utf8");
const ext = path.extname(fsPath).toLowerCase();
// Only TS-like files need transpilation
const isTsLike = ext === ".ts" || ext === ".tsx" || ext === ".mts" || ext === ".cts";
if (isTsLike) {
const mtimeMs = getMtimeMsSafe(fsPath);
const cached = esbuildCache.get(fsPath);
if (cached && cached.mtimeMs === mtimeMs) {
// Reuse previous esbuild output
code = cached.code;
} else {
try {
const esb = await transformWith(code, fsPath, {
loader: ext === ".tsx" ? "tsx" : "ts",
format: "esm",
sourcemap: false,
target: "esnext",
tsconfigRaw: {}
});
if (esb && esb.code) {
code = esb.code;
esbuildCache.set(fsPath, { mtimeMs, code });
}
} catch (e) {
if (DEBUG) console.warn("[vite-plugin-node-worker][DEBUG] esbuild transform failed for", fsPath, e);
}
}
}
}
} catch (err) {
if (DEBUG) {
console.error(
"[vite-plugin-node-worker][DEBUG] failed to read file for rewrite:",
fsPath,
"\n",
err
);
}
throw err;
}
const originalURL = toURL(fsPath);
cache.set(fsPath, originalURL);
active.add(fsPath);
try {
let out = code;
let ms = new MagicString(out);
async function applyTripletRule(re, resolver) {
re.lastIndex = 0;
for (const m of out.matchAll(re)) {
const p1 = m[1];
const spec = m[2];
const p3 = m[3];
if (!spec) continue;
const start = m.index + p1.length;
const end = start + spec.length;
const replacement = await resolver(spec);
if (replacement && replacement !== spec) {
ms.overwrite(start, end, replacement, { contentOnly: true });
}
}
out = ms.toString();
}
await applyTripletRule(RE.sideImport, async (spec) => {
const childFs = await resolveToFs(spec, fsPath);
if (!childFs || childFs === fsPath) return spec;
return await rewriteModule(childFs);
});
await applyTripletRule(RE.importFrom, async (spec) => {
const childFs = await resolveToFs(spec, fsPath);
if (!childFs || childFs === fsPath) return spec;
return await rewriteModule(childFs);
});
try {
await applyTripletRule(RE.exportFrom, async (spec) => {
const childFs = await resolveToFs(spec, fsPath);
if (!childFs || childFs === fsPath) return spec;
return await rewriteModule(childFs);
});
} catch (e) {
if (DEBUG) {
console.warn(
"[vite-plugin-node-worker][DEBUG] skipping export-from rewrite due to",
fsPath,
"\n",
e
);
}
}
await applyTripletRule(/(['"])(~\/[\w@./-]+)(['"])/g, async (spec) => {
const childFs = await resolveToFs(spec, fsPath);
if (!childFs) return spec;
return toURL(childFs);
});
// Handle absolute imports that Vite (or upstream transforms) may have left as
// "/node_modules/...". In Node, an import like that is treated as an absolute
// path from the filesystem root and will fail ("file:///node_modules/..." does not exist).
// We prefer to ask Vite's resolver first, then fallback.
await applyTripletRule(/(['"])\/(node_modules\/[^'"\n]+)(['"])/g, async (spec) => {
const full = '/' + spec; // restore leading slash
if (env?.pluginContainer?.resolveId) {
const r = await env.pluginContainer.resolveId(full, fsPath);
if (r?.external) return full;
if (r?.id?.startsWith('/@fs/')) return await rewriteModule(r.id.slice(4));
if (r?.id?.startsWith('/@id/')) return await rewriteModule(r.id);
if (r?.id && (path.isAbsolute(r.id) || r.id.startsWith('file:'))) return await rewriteModule(r.id);
}
const childFs = await resolveToFs(full, fsPath);
return childFs ? await rewriteModule(childFs) : full;
});
if (DEBUG && /(^|[^A-Za-z0-9_])~\//.test(out)) {
console.warn(
"[vite-plugin-node-worker][DEBUG] alias tokens remain after rewrite in",
fsPath
);
}
// Safety normalization for any stray /node_modules/... strings in template literals or elsewhere
out = out.replace(/(["'`])\/(node_modules\/[^"'`\n]+)(["'`])/g, (m, q1, p, q3) => {
const abs = path.resolve(projectRoot, p);
const probed = tryResolveFileLike(abs) || abs;
return `${q1}${toURL(probed)}${q3}`;
});
// Safety net: if upstream still injected SSR helpers, provide a minimal shim
if (out.includes("__vite_ssr_import__")) {
out = `async function __vite_ssr_import__(u){ return import(u); }\n` + out;
}
if (out.includes("__vite_ssr_dynamic_import__")) {
out = `async function __vite_ssr_dynamic_import__(u){ return import(u); }\n` + out;
}
fs.mkdirSync(DEV_CACHE_DIR, { recursive: true });
const hash = sha1(fsPath + "|" + out).slice(0, 12);
const outFile = path.join(DEV_CACHE_DIR, `${hash}.mjs`);
if (!fileExists(outFile)) {
fs.writeFileSync(outFile, out + `\n//# sourceURL=${toURL(fsPath)}`);
if (DEBUG) {
console.log("[vite-plugin-node-worker][DEBUG] emitted", outFile, "from", fsPath);
}
}
const url = toURL(outFile);
cache.set(fsPath, url);
return url;
} finally {
active.delete(fsPath);
}
}
const entryURL = await rewriteModule(entryFile);
return { entryURL };
}
export default function workerPlugin() {
let sourcemap = false;
let isServe = false;
let root = process.cwd();
let aliases = [];
let devServer = null;
let cacheDirFromVite = null;
return {
name: "vite:node-worker",
enforce: "pre",
configResolved(config) {
sourcemap = !!config.build.sourcemap;
isServe = config.command === "serve";
root = config.root || root;
aliases = normalizeAliases(config.resolve && config.resolve.alias);
cacheDirFromVite = config.cacheDir || path.join(root, 'node_modules/.vite');
},
configureServer(s) {
devServer = s;
},
resolveId(id, importer) {
const query = parseRequest(id);
if (!query) return;
if (query.nodeWorker != null || query.modulePath != null) {
return id + `&importer=${importer}`;
}
},
async load(id) {
const query = parseRequest(id);
if (!query || !query.importer) return;
const cleanPath = cleanUrl(id);
const importerFile = (query.importer || "").replace(/\?.*$/, "");
const tryPaths = [];
const resolved = await this.resolve(cleanPath, importerFile);
if (resolved?.id) tryPaths.push(resolved.id.replace(/\?.*$/, ""));
const resolved2 = await this.resolve(cleanPath, importerFile, {
skipSelf: true,
});
if (resolved2?.id && resolved2.id !== resolved?.id) {
tryPaths.unshift(resolved2.id.replace(/\?.*$/, ""));
}
if (!cleanPath.startsWith("/")) {
tryPaths.push(path.resolve(path.dirname(importerFile), cleanPath));
} else {
tryPaths.push(path.resolve(root, cleanPath.slice(1)));
}
tryPaths.push(path.resolve(root, cleanPath));
let absId = tryPaths.find((p) => {
try {
return fs.existsSync(p);
} catch {
return false;
}
});
if (!absId) absId = (resolved?.id || cleanPath).replace(/\?.*$/, "");
if (isServe) {
const env =
this.environment ||
(devServer?.environments?.client ?? devServer?.environments?.ssr) ||
null;
try {
const { entryURL } = await rewriteEntryAliasesToFile("", absId, {
root,
aliases,
env,
cacheDir: path.join(cacheDirFromVite || path.join(root, 'node_modules/.vite'), 'node-worker')
});
absId = new URL(entryURL).pathname;
} catch (err) {
if (DEBUG) {
console.error(
"[vite-plugin-node-worker][DEBUG] rewrite failed:",
absId,
"\n",
err
);
}
}
if (DEBUG) {
console.log("[vite-plugin-node-worker][DEBUG] worker entry URL:", toFileURL(absId));
}
if (query.modulePath != null) {
return `export default new URL(${JSON.stringify(toFileURL(absId))})`;
}
if (query.nodeWorker != null) {
return `
import { Worker } from 'node:worker_threads';
const url = new URL(${JSON.stringify(toFileURL(absId))});
export default function (options) { return new Worker(url, options) }
`;
}
return;
}
if (query.nodeWorker != null || query.modulePath != null) {
const base = path.basename(cleanPath, path.extname(cleanPath));
const refId = this.emitFile({
type: "chunk",
id: absId,
importer: query.importer,
fileName: `${base}.worker.mjs`, // temporary name; we will rename in generateBundle
});
const assetRefId = `__VITE_NODE_WORKER_ASSET__${refId}__`;
if (query.modulePath != null) {
return `export default ${assetRefId}`;
}
return `
import { Worker } from 'node:worker_threads';
export default function (options) { return new Worker(new URL(${assetRefId}, import.meta.url), options) }
`;
}
},
handleHotUpdate(ctx) {
// Best-effort: when files change, future rewrites will produce new hashed cache filenames.
// If you later promote caches to module scope, invalidate them here.
return ctx.modules;
},
generateBundle(_, bundle) {
// Replace placeholders with final relative paths (no renaming, no hashing)
for (const fileName of Object.keys(bundle)) {
const b = bundle[fileName];
if (b.type !== 'chunk') continue;
if (!nodeWorkerAssetUrlRE.test(b.code)) continue;
nodeWorkerAssetUrlRE.lastIndex = 0;
const s = new MagicString(b.code);
let m;
while ((m = nodeWorkerAssetUrlRE.exec(b.code))) {
const full = m[0];
const refId = m[1];
let workerName;
try {
workerName = this.getFileName(refId);
} catch {
// Unknown refId – skip without throwing
continue;
}
const rel = toRelativePath(workerName, fileName);
s.overwrite(m.index, m.index + full.length, JSON.stringify(rel), { contentOnly: true });
}
b.code = s.toString();
if (sourcemap) b.map = s.generateMap({ hires: true });
}
},
};
}
function parseRequest(id) {
const { search } = new URL(id, "file:");
if (!search) return null;
return Object.fromEntries(new URLSearchParams(search));
}