squint-cljs
Version:
<img src="./logo/logo.svg" width="100%">
459 lines (439 loc) • 19.9 kB
JavaScript
// Browser REPL over vite's HMR WebSocket, shared between squint and cherry
// (see cherry ADR 0004).
//
// Design (compare with the standalone-WebSocketServer approach on the
// browser-repl-on-main branch): there is no second WebSocket server. REPL eval
// rides vite's own dev-server WS (import.meta.hot), so reconnection and the
// module graph are owned by vite and stay consistent with hot reload.
//
// The plugin:
// - compiles cljs -> js in-process via the dialect's compileFile API (compile
// all on startup, recompile changed files via vite's watcher; one-shot for
// build),
// - runs the dialect's nREPL server and injects a browser transport, so
// editors connect over bencode TCP and eval is delegated to the browser over
// vite's WS. Same nREPL impl as `<dialect> nrepl-server`; no second copy of
// bencode/ops.
// - injects a browser-side eval listener (import.meta.hot) speaking the nREPL
// server's eval message format.
//
// An optional dev-only HTTP endpoint (POST /__repl_eval, off by default, see
// ENABLE_HTTP_EVAL) drives the same eval path with curl, handy without an
// editor.
//
// The adapter supplies the dialect:
// {
// name, // 'squint' | 'cherry': derives virtual module id,
// // WS event names, log prefixes, env var, dev hook
// coreImport, // core module specifier for the browser client,
// // e.g. 'squint-cljs/core.js'; must export pr_str
// compileFile, readConfig, depsPaths, // node API fns
// startServer, handleBrowserMessage, evalString, // nREPL server fns
// }
import { readdirSync, existsSync } from 'node:fs';
import { join, resolve, sep } from 'node:path';
const CLJS_RE = /\.clj[sc]$/;
// Dev-only HTTP eval endpoint (POST /__repl_eval), handy for driving eval with
// curl when there's no editor. Off by default (an open dev server would expose
// arbitrary in-page eval); flip to true in-source when you need it.
const ENABLE_HTTP_EVAL = false;
// Browser-side listener, served as a virtual module so the page imports it and
// gets a real import.meta.hot context. Speaks the nREPL server's eval format:
// receives {op:"eval", code, id, session}, replies {op:"eval", value/ex, id, session}.
function clientCode({ name, coreImport, evt, evtReply, log }) {
return `
import { pr_str } from '${coreImport}';
// Render a top-level Promise as #<Promise <value>> by racing it against a
// short timeout; pending/rejected get their own readable forms. Mirrors
// pr-str-repl in squint.repl.nrepl-server-common so node and browser eval
// print the same thing.
const PROMISE_PRINT_TIMEOUT_MS = 1000;
async function pr_str_repl(v) {
if (!(v instanceof Promise)) return pr_str(v);
const settled = v.then(r => ({ tag: 'resolved', val: r }),
e => ({ tag: 'rejected', val: e }));
const timer = new Promise(resolve =>
setTimeout(() => resolve({ tag: 'pending' }), PROMISE_PRINT_TIMEOUT_MS));
const r = await Promise.race([settled, timer]);
if (r.tag === 'pending') return '#<Promise pending>';
if (r.tag === 'rejected') return '#<Promise rejected ' + pr_str(r.val) + '>';
return '#<Promise ' + pr_str(r.val) + '>';
}
// Resolve a bare specifier to the exact url vite serves it at, then import THAT
// url. We can't \`import('/@resolve-deps/preact')\` and let the endpoint redirect:
// the browser's module map keys by the *request* url, so a redirected import is
// a different map entry than the page's own \`import('/node_modules/.vite/deps/
// preact.js?v=...')\` - two preact instances, two \`options\` registries, and a
// REPL \`render\` then can't reconcile the page's existing tree. Resolving first
// and importing the canonical url shares the one instance.
// Indirect import so vite's import-analysis doesn't rewrite it: a literal
// \`import(url)\` here gets wrapped as \`import(__vite__injectQuery(url,'import'))\`,
// which appends &import to the url - a different string than the page's own
// \`import('/node_modules/.vite/deps/preact.js?v=...')\`, hence a second module
// instance. Building the import() via Function keeps the url byte-for-byte.
const __rawImport = new Function('u', 'return import(u)');
async function __replImport(spec) {
const res = await fetch('/@resolve-deps/' + encodeURIComponent(spec));
if (!res.ok) throw new Error('${name}-repl: could not resolve ' + spec);
const url = await res.text();
return __rawImport(url);
}
// JS-interop completion against the page's globalThis. Mirrors js-completions
// in squint.repl.nrepl-server-common (node side) so browser and node behave
// the same.
function __jsCompletions(prefix) {
if (!prefix || !prefix.startsWith('js/')) return [];
const s = prefix.slice(3);
const parts = s.split('.');
const partial = parts[parts.length - 1];
const path = parts.slice(0, -1);
let obj = globalThis;
for (const seg of path) { obj = obj == null ? obj : obj[seg]; }
if (obj == null) return [];
const acc = new Set();
for (let o = obj; o != null; o = Object.getPrototypeOf(o)) {
for (const n of Object.getOwnPropertyNames(o)) acc.add(n);
}
const pre = 'js/' + (path.length ? path.join('.') + '.' : '');
return Array.from(acc).filter((n) => n.startsWith(partial)).sort().slice(0, 100).map((n) => pre + n);
}
if (import.meta.hot) {
import.meta.hot.on('${evt}', async ({ op, code, id, session, prefix }) => {
if (op === 'complete-js') {
import.meta.hot.send('${evtReply}', {
op: 'complete-js', id, session, completions: __jsCompletions(prefix),
});
return;
}
if (op !== 'eval') return;
// bare dynamic imports in eval'd code resolve through __replImport
// (\\s* tolerates the compiler emitting e.g. \`import ('preact')\` for :refer)
const rewritten = code.replace(/import\\s*\\(\\s*'(.+?)'\\s*\\)/g, "__replImport('$1')");
let value, ex;
try {
// compile wraps the user's top-level value in [v] so a Promise survives
// the async IIFE without being auto-unwrapped; unbox before printing.
const boxed = await eval(rewritten);
value = await pr_str_repl(boxed[0]);
} catch (e) {
ex = e && e.message ? e.message : String(e);
}
import.meta.hot.send('${evtReply}', {
op: 'eval',
id,
session,
value,
...(ex ? { ex } : {}),
});
});
console.info('${log} nrepl listener ready');
}
`;
}
export function makeVitePlugin(adapter) {
const {
name,
coreImport,
compileFile,
readConfig,
depsPaths,
startServer,
handleBrowserMessage,
evalString,
} = adapter;
const PLUGIN_NAME = `${name}-repl`;
const VIRTUAL_CLIENT = `virtual:${name}-repl-client`;
const RESOLVED_CLIENT = '\0' + VIRTUAL_CLIENT;
const EVT = `${name}:nrepl`;
const EVT_REPLY = `${name}:nrepl-reply`;
const LOG = `[${PLUGIN_NAME}]`;
const ENV_PORT = `${name.toUpperCase()}_NREPL_PORT`;
const DEV_HOOK = `__${name}_dev_hook`;
return function plugin(options = {}) {
let root;
let isBuild = false;
let logger = console;
// All settings come from the config file (plugin options override, then env
// for the nREPL port). Resolved in configResolved (needs `root`).
// `paths` are absolute source dirs.
let paths = [];
let outDir = 'js';
let extension = 'js';
let main; // entry ns(s): string or array, injected into index.html
let target = 'browser';
let nreplPort = 1339;
let debug = false;
// {import-source} when set (e.g. for React/Preact): the compiler emits
// jsx()/jsxs() calls + imports the runtime, instead of raw <tags> a bundler
// would have to transform. We flip :development per mode (dev -> jsx-dev-runtime).
let jsxRuntime; // JS object from the config file's :jsx-runtime, or undefined
// Shared compiler ns-state, threaded through every file compile and handed to
// the nREPL server, so the REPL knows the vars/aliases the files defined (a
// cljs atom; survives the JS boundary as an opaque object). Dev only.
let nsState;
// source file -> {before-load: [...], after-load: [...]}: munged globalThis
// paths of ^:dev/before-load / ^:dev/after-load fns, refreshed per compile,
// dropped when the source is deleted.
const devHooks = new Map();
async function compileCljs(file) {
const res = await compileFile({
'in-file': file,
'output-dir': join(root, outDir),
paths,
extension,
// REPL output (globalThis bindings, dynamic imports) in dev; regular,
// optimizable ESM for production builds.
repl: !isBuild,
// Dev uses the jsx-dev-runtime (better errors/source info); build uses
// the production jsx-runtime.
...(jsxRuntime ? { 'jsx-runtime': { ...jsxRuntime, development: !isBuild } } : {}),
// thread the shared ns-state (dev only; the build doesn't need a REPL)
...(isBuild ? {} : { 'ns-state': nsState }),
});
if (!isBuild) {
nsState = res['ns-state']; // capture/refresh the shared atom
devHooks.set(file, res['dev-hooks']);
}
return res;
}
function hookCalls(which) {
const paths = [...devHooks.values()].flatMap((h) => (h ? h[which] : []));
return paths.map((p) => `${DEV_HOOK}(${JSON.stringify(p)});`).join(' ');
}
async function compileAll() {
for (const dir of paths) {
let entries;
try {
entries = readdirSync(dir, { recursive: true, withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (e.isFile() && CLJS_RE.test(e.name)) {
const file = join(e.parentPath ?? e.path, e.name);
try {
await compileCljs(file);
} catch (err) {
logger.error(LOG + ' compile error in ' + file + ': ' + (err.message || err));
}
}
}
}
}
return {
name: PLUGIN_NAME,
configResolved(config) {
root = config.root;
isBuild = config.command === 'build';
logger = config.logger ?? console;
// the config file is the source of truth; plugin options override it.
const cfg = readConfig(root) || {};
// :deps in the config file resolve (via `clojure -Spath`) to absolute
// source dirs; add them to paths so the dep namespaces are compiled and
// their requires resolve. depsPaths reads the config file itself: the
// deps map has symbol keys that would not survive readConfig's
// clj<->js round-trip.
const depDirs = options.paths ? [] : depsPaths(root);
paths = [...(options.paths ?? cfg.paths ?? ['src']), ...depDirs].map((p) =>
resolve(root, p),
);
outDir = options.outDir ?? cfg['output-dir'] ?? 'js';
extension = options.extension ?? cfg.extension ?? 'js';
main = options.main ?? cfg.main;
target = options.target ?? cfg.target ?? 'browser';
jsxRuntime = options.jsxRuntime ?? cfg['jsx-runtime'];
// env wins over the config file (a runtime override, e.g. for tests/CI)
nreplPort =
options.nreplPort ??
(process.env[ENV_PORT] ? Number(process.env[ENV_PORT]) : undefined) ??
cfg['nrepl-port'] ??
1339;
debug = options.debug ?? cfg.debug ?? false;
if (target !== 'browser') {
throw new Error(
`${name} vite plugin: target ${JSON.stringify(target)} not supported yet (only 'browser')`,
);
}
},
// Production build: compile everything before vite bundles.
async buildStart() {
if (isBuild) await compileAll();
},
resolveId(id) {
if (id === VIRTUAL_CLIENT) return RESOLVED_CLIENT;
},
load(id) {
if (id === RESOLVED_CLIENT) {
return clientCode({ name, coreImport, evt: EVT, evtReply: EVT_REPLY, log: LOG });
}
},
// Make compiled cljs->js modules self-accepting in dev so a recompile
// hot-swaps the module (re-runs it, re-binding globalThis.<ns> with the
// new code) instead of triggering a full page reload. ^:dev/before-load
// hooks run on dispose (old module about to be replaced), ^:dev/after-load
// hooks on accept (new module has executed). Hooks resolve by name on
// globalThis at call time. Injected at serve time only, so the files on
// disk and the production build stay clean.
transform(code, id) {
if (isBuild) return;
const file = id.split('?')[0];
const outBase = join(root, outDir) + sep;
if (file.startsWith(outBase) && file.endsWith('.' + extension)) {
const before = hookCalls('before-load');
const after = hookCalls('after-load');
const noHooksHint =
`console.info('[${name}] hot reloaded, but no ^:dev/after-load hook is defined to e.g. re-render');`;
return (
code +
`
if (import.meta.hot) {
globalThis.${DEV_HOOK} ??= (p) => {
let f = globalThis;
for (const seg of p.split('.')) f = f?.[seg];
if (typeof f !== 'function') { console.warn('[${name}] dev hook not found: ' + p); return; }
try { f(); } catch (e) { console.error('[${name}] dev hook ' + p + ' threw', e); }
};
${before ? `import.meta.hot.dispose(() => { ${before} });` : ''}
import.meta.hot.accept(() => { ${after || noHooksHint} });
}
`
);
}
},
transformIndexHtml: {
// 'pre' so the injected entry script is collected as a build input
// (vite's build-html scans scripts during its own transform).
order: 'pre',
handler() {
const tags = [];
// The REPL eval listener is dev-only (uses import.meta.hot); don't ship it.
if (!isBuild) {
tags.push({
tag: 'script',
// vite serves virtual modules under /@id/, encoding the leading
// null byte of the resolved id as __x00__
attrs: { type: 'module', src: '/@id/__x00__' + VIRTUAL_CLIENT },
injectTo: 'head',
});
}
// Inject the entry namespace's compiled module, so index.html doesn't
// hardcode the output path. ns -> file uses the compiler's munging.
// Relative src so `vite build` treats it as a bundle input (absolute =
// public asset).
for (const ns of [].concat(main ?? [])) {
const file = String(ns).replace(/-/g, '_').replace(/\./g, '/');
tags.push({
tag: 'script',
attrs: { type: 'module', src: `${outDir}/${file}.${extension}` },
injectTo: 'body',
});
}
return tags;
},
},
async configureServer(server) {
// Compile once on startup, then recompile changed cljs via vite's watcher.
await compileAll();
const onChange = async (file) => {
const abs = resolve(file);
if (!CLJS_RE.test(abs) || !paths.some((p) => abs.startsWith(p + sep))) return;
try {
await compileCljs(abs);
logger.info(LOG + ' compiled ' + abs);
} catch (err) {
logger.error(LOG + ' compile error in ' + abs + ': ' + (err.message || err));
}
};
for (const p of paths) server.watcher.add(p);
server.watcher.on('change', onChange);
server.watcher.on('add', onChange);
server.watcher.on('unlink', (file) => devHooks.delete(resolve(file)));
// Resolve a bare specifier from REPL-eval'd dynamic import()s to the url
// vite serves it at, returned as text (the client imports that url; see
// __replImport in the injected client - it must import the canonical
// url, not a redirect, to share the page's module instance). npm/path
// specifiers go through vite's resolver; a bare ns name (e.g. `index`,
// which the compiler emits for a local cljs require) that vite can't
// resolve falls back to its compiled output under outDir (e.g. /js/index.js).
const sendUrl = (res, url) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(url);
};
server.middlewares.use('/@resolve-deps', async (req, res) => {
const spec = decodeURIComponent(req.url.slice(1));
let resolved = null;
// resolve via the plugin container (runs vite's own resolver + dep
// pre-bundling). vite 6+ exposes it per-environment; older vite only on
// the server. (server.moduleGraph.resolveId existed in vite <=5 but was
// dropped in 6+, so don't depend on it.)
const container =
server.environments?.client?.pluginContainer ?? server.pluginContainer;
try {
resolved = await container.resolveId(spec);
} catch {
resolved = null;
}
if (resolved && resolved.id) {
// Return the SAME url the page uses for this module. vite serves files
// under `root` at a root-relative url and files outside it under /@fs;
// an optimized dep (<root>/node_modules/.vite/deps/foo.js?v=HASH) must
// come back as /node_modules/.vite/deps/foo.js?v=HASH - the form the
// page's own imports resolve to - so the browser reuses the one module
// instance (a different url string => a second copy => two `options`
// registries => a REPL `render` can't reconcile the page's tree and
// #jsx silently no-ops).
const id = resolved.id;
const url = id.startsWith(root + sep)
? id.slice(root.length)
: `/@fs${id}`;
sendUrl(res, url);
return;
}
const rel = outDir + '/' + spec.replace(/\./g, '/') + '.' + extension;
if (existsSync(join(root, rel))) {
sendUrl(res, '/' + rel);
return;
}
res.writeHead(404);
res.end();
});
// Start the dialect's nREPL server, delegating eval to the browser over
// vite's WS. Browser replies come back on the reply event.
server.ws.on(EVT_REPLY, (data) => handleBrowserMessage(data));
await startServer({
port: nreplPort,
debug,
browserTransport: {
send: (msg) => server.ws.send(EVT, msg),
// resolved lazily: vite only knows its URL once it's listening
url: () => server.resolvedUrls?.local?.[0] ?? server.resolvedUrls?.network?.[0],
},
// share the ns-state accumulated by the file compiles above, so the
// REPL knows their vars/aliases (e.g. a def that ran at page load)
nsState,
});
logger.info(LOG + ' nREPL server on port ' + nreplPort);
// Dev HTTP trigger: drive the same eval path with curl (no editor needed).
// Gated off by default; see ENABLE_HTTP_EVAL.
if (ENABLE_HTTP_EVAL) {
server.middlewares.use('/__repl_eval', async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405);
res.end();
return;
}
let body = '';
for await (const chunk of req) body += chunk;
res.writeHead(200, { 'content-type': 'application/json' });
try {
const out = await evalString(body);
res.end(JSON.stringify({ value: out.value, ns: out.ns }));
} catch (e) {
res.end(JSON.stringify({ err: e && e.message ? e.message : String(e) }));
}
});
}
},
};
};
}