vite-plugin-melange
Version:
Vite plugin for Melange
867 lines (735 loc) • 25.7 kB
JavaScript
import path from 'path';
import { spawn, spawnSync } from 'child_process';
import { existsSync, promises, readFileSync } from 'fs';
import colors from 'picocolors';
import * as net from 'node:net';
import * as util from 'node:util';
import getEtag from 'etag';
import strip from 'strip-ansi';
var socket;
const initializePayload =
"((2:id(10:initialize))(6:method10:initialize)(6:params((12:dune_version(1:32:15))(2:id(19:vite-plugin-melange1:3))(16:protocol_version1:0))))";
// this is the whole version payload sent by ocamllsp:
// "((2:id(12:version menu))(6:method12:version_menu)(6:params((7:promote(1:1))(17:poll/running-jobs(1:1))(13:poll/progress(1:11:2))(15:poll/diagnostic(1:11:2))(4:ping(1:1))(16:format-dune-file(1:1))(11:diagnostics(1:11:2))(9:build_dir(1:1))(8:shutdown(1:1))(24:cancel-poll/running-jobs(1:1))(20:cancel-poll/progress(1:1))(22:cancel-poll/diagnostic(1:1))(12:notify/abort(1:1))(10:notify/log(1:1)))))";
const versionsPayload =
"((2:id(12:version menu))(6:method12:version_menu)(6:params((13:poll/progress(1:11:2))(15:poll/diagnostic(1:11:2)))))";
const pollProgressPayload =
"((2:id((4:poll(4:auto1:0))(1:i1:0)))(6:method13:poll/progress)(6:params(4:auto1:0)))";
const pollDiagnosticsPayload =
"((2:id((4:poll(4:auto1:1))(1:i1:0)))(6:method15:poll/diagnostic)(6:params(4:auto1:1)))";
// Melange error:
// { directory: '/home/pierre/dev/melange-vite-template', id: '3', loc: { start: { pos_bol: '0', pos_cnum: '11', pos_fname: '/home/pierre/dev/melange-vite-template/src/truc.re', pos_lnum: '1' }, stop: { pos_bol: '0', pos_cnum: '14', pos_fname: '/home/pierre/dev/melange-vite-template/src/truc.re', pos_lnum: '1' } }, message: [ 'Vbox', [ '0', [ 'Box', [ '0', [ 'Verbatim', 'Unbound value asd\nHint: Did you mean asr?' ] ] ] ] ], promotion: {}, related: {}, severity: 'error', targets: {} }
// Dune error:
// { id: '0', loc: { start: { pos_bol: '0', pos_cnum: '0', pos_fname: '/home/pierre/dev/melange-tea-template/melange-tea-template.opam', pos_lnum: '1' }, stop: { pos_bol: '0', pos_cnum: '0', pos_fname: '/home/pierre/dev/melange-tea-template/melange-tea-template.opam', pos_lnum: '1' } }, message: [ 'Vbox', [ '0', [ 'Box', [ '0', [ 'Concat', [ [ 'Break', [ [ '', '1', '' ], [ '', '0', '' ] ] ], { Seq: { Tag: { Error: {}, Verbatim: 'Error' }, Char: ':' }, Text: "This opam file doesn't have a corresponding (package ...) stanza in the dune-project file. Since you have at least one other (package ...) stanza in your dune-project file, you must a (package ...) stanza for each opam package in your project." } ] ] ] ] ] ], promotion: {}, related: {}, severity: 'error', targets: {} }
function extractMessage(message) {
if (message && message[1] && message[1].Text) {
return message[1].Text;
} else {
return message;
}
}
function make_error(input) {
return {
id: parseInt(input.id),
file: input.loc && input.loc.start.pos_fname,
start: input.loc && {
line: parseInt(input.loc.start.pos_lnum),
column: parseInt(input.loc.start.pos_cnum),
},
end: input.loc && {
line: parseInt(input.loc.stop.pos_lnum),
column: parseInt(input.loc.stop.pos_cnum),
},
message: extractMessage(input.message[1][1][1][1][1]),
severity: input.severity,
};
}
function init(
rpcPath,
onSuccess,
onDiagnosticAdd,
onDiagnosticRemove,
onRpcError,
) {
// console.log("init RPC socket");
// console.log(rpcPath);
socket = net.createConnection(rpcPath);
socket.on("connect", () => {
// console.log("RPC socket connected");
socket.write(initializePayload);
});
socket.on("end", () => {
console.log("RPC connection disconnected");
});
socket.on("error", (err) => {
if (err.code === "ENOENT") {
setTimeout(() => {
init(
rpcPath,
onSuccess,
onDiagnosticAdd,
onDiagnosticRemove,
onRpcError,
);
}, 200);
} else {
onRpcError(err);
}
});
socket.on("timeout", () => {
console.log("RPC connection timeout");
});
socket.on("data", (data) => {
const payloads = parse(data.toString());
payloads.forEach((payload) => {
// console.log(util.inspect(payload, {depth: Infinity, colors: true, compact: false}));
try {
// ((2:id(10:initialize))(6:result(2:ok())))
// [ { id: [ 'initialize' ], result: [ 'ok', {} ] } ]
if (payload.id[0] === "initialize" && payload.result[0] === "ok") {
socket.write(versionsPayload);
}
// { id: [ 'version menu' ], result: [ 'ok', { 'poll/diagnostic': '2', 'poll/progress': '2' } ] }
else if (
payload.id[0] === "version menu" &&
payload.result[0] === "ok"
) {
socket.write(pollProgressPayload);
socket.write(pollDiagnosticsPayload);
}
// { id: { poll: [ 'auto', '0' ], i: '0' }, result: [ 'error', { kind: 'Invalid_request', message: 'initialize request expected' } ] }
else if (payload.result[0] === "error") {
onRpcError(payload.result[1]);
}
// { id: { poll: [ 'auto', '0' ], i: '0' }, result: [ 'ok', [ 'Some', [ 'success', {} ] ] ] }
else if (payload.id.poll && payload.id.poll[1] === "0") {
if (payload.result[1][1][0] === "success") {
onSuccess();
}
socket.write(pollProgressPayload);
}
// result: [ 'ok', [ 'Some', { Add: { directory: '/home/pierre/dev/melange-vite-template', id: '3', loc: { start: { pos_bol: '0', pos_cnum: '11', pos_fname: '/home/pierre/dev/melange-vite-template/src/truc.re', pos_lnum: '1' }, stop: { pos_bol: '0', pos_cnum: '14', pos_fname: '/home/pierre/dev/melange-vite-template/src/truc.re', pos_lnum: '1' } }, message: [ 'Vbox', [ '0', [ 'Box', [ '0', [ 'Verbatim', 'Unbound value asd\nHint: Did you mean asr?' ] ] ] ] ], promotion: {}, related: {}, severity: 'error', targets: {} } } ] ]
else if (payload.id.poll && payload.id.poll[1] === "1") {
if (payload.result[1] && payload.result[1][1].Add) {
onDiagnosticAdd(make_error(payload.result[1][1].Add));
} else if (payload.result[1] && payload.result[1][1].Remove) {
onDiagnosticRemove(make_error(payload.result[1][1].Remove));
}
socket.write(pollDiagnosticsPayload);
} else {
console.log("Unhandled payload");
console.log(
util.inspect(payload, {
depth: Infinity,
colors: true,
compact: false,
}),
);
}
} catch (e) {
console.log("Unhandled payload");
console.log(
util.inspect(payload, {
depth: Infinity,
colors: true,
compact: false,
}),
);
}
});
});
}
function destroy() {
socket && socket.destroy();
}
function parse(input) {
const result = [];
const stack = [];
while (input[0]) {
switch (input[0]) {
case "(":
stack.push([]);
input = input.slice(1);
break;
case ")":
if (stack.length) {
let top = stack.pop();
if (
top.every(function (i) {
return Array.isArray(i) && i.length === 2;
})
) {
top = Object.fromEntries(top);
}
if (stack.length) {
var last = stack[stack.length - 1];
last.push(top);
} else {
result.push(top);
}
input = input.slice(1);
} else {
throw new Error("Syntax Error - unmatched closing paren");
}
break;
default:
const size = parseInt(input);
input = input.slice(input.indexOf(":") + 1);
const top = stack[stack.length - 1];
top.push(input.slice(0, size));
input = input.slice(size);
}
}
return result;
}
/*
** Code from Vite
*/
const ERR_OPTIMIZE_DEPS_PROCESSING_ERROR = "ERR_OPTIMIZE_DEPS_PROCESSING_ERROR";
const ERR_OUTDATED_OPTIMIZED_DEP = "ERR_OUTDATED_OPTIMIZED_DEP";
const NULL_BYTE_PLACEHOLDER = `__x00__`;
const VALID_ID_PREFIX = `/@id/`;
const importQueryRE = /(\?|&)import=?(?:&|$)/;
const trailingSeparatorRE = /[\?&]$/;
const timestampRE = /\bt=\d{13}&?\b/;
function removeTimestampQuery(url) {
return url.replace(timestampRE, "").replace(trailingSeparatorRE, "");
}
function removeImportQuery(url) {
return url.replace(importQueryRE, "$1").replace(trailingSeparatorRE, "");
}
// Strip valid id prefix. This is prepended to resolved Ids that are
// not valid browser import specifiers by the importAnalysis plugin.
function unwrapId(id) {
return id.startsWith(VALID_ID_PREFIX) ? id.slice(VALID_ID_PREFIX.length) : id;
}
const isDebug = !!process.env.DEBUG;
function genSourceMapUrl(map) {
if (typeof map !== "string") {
map = JSON.stringify(map);
}
return `data:application/json;base64,${Buffer.from(map).toString("base64")}`;
}
function getCodeWithSourcemap(type, code, map) {
if (isDebug) {
code += `\n/*${JSON.stringify(map, null, 2).replace(/\*\//g, "*\\/")}*/\n`;
}
if (type === "js") {
code += `\n//# sourceMappingURL=${genSourceMapUrl(map ?? undefined)}`;
} else if (type === "css") {
code += `\n/*# sourceMappingURL=${genSourceMapUrl(map ?? undefined)} */`;
}
return code;
}
const alias = {
js: "application/javascript",
css: "text/css",
html: "text/html",
json: "application/json",
};
function send(req, res, content, type, options) {
const {
etag = getEtag(content, { weak: true }),
cacheControl = "no-cache",
headers,
map,
} = options;
if (res.writableEnded) {
return;
}
if (req.headers["if-none-match"] === etag) {
res.statusCode = 304;
res.end();
return;
}
res.setHeader("Content-Type", alias[type] || type);
res.setHeader("Cache-Control", cacheControl);
res.setHeader("Etag", etag);
if (headers) {
for (const name in headers) {
res.setHeader(name, headers[name]);
}
}
// inject source map reference
if (map && map.mappings) {
if (type === "js" || type === "css") {
content = getCodeWithSourcemap(type, content.toString(), map);
}
}
res.statusCode = 200;
res.end(content);
return;
}
const postfixRE = /[?#].*$/;
function cleanUrl(url) {
return url.replace(postfixRE, '');
}
function splitFileAndPostfix(path) {
const file = cleanUrl(path);
return { file, postfix: path.slice(file.length) };
}
// used to propagate errors for WS in dev server mode
function prepareError(err) {
// only copy the information we need and avoid serializing unnecessary
// properties, since some errors may attach full objects (e.g. PostCSS)
return {
message: strip(err.message),
stack: strip(cleanStack(err.stack || "")),
id: err.id,
frame: strip(err.frame || ""),
plugin: err.plugin,
pluginCode: err.pluginCode,
loc: err.loc,
};
}
function pad(source, n = 2) {
const lines = source.split(splitRE);
return lines.map((l) => ` `.repeat(n) + l).join(`\n`);
}
function buildErrorMessage(err, args, includeStack = true) {
if (err.plugin) args.push(` Plugin: ${colors.magenta(err.plugin)}`);
const loc = err.loc ? `:${err.loc.line}:${err.loc.column}` : "";
if (err.id) args.push(` File: ${colors.cyan(err.id)}${loc}`);
if (err.frame) args.push(colors.yellow(pad(err.frame)));
if (includeStack && err.stack) args.push(pad(cleanStack(err.stack)));
return args.join("\n");
}
function cleanStack(stack) {
return stack
.split(/\n/g)
.filter((l) => /^\s*at/.test(l))
.join("\n");
}
function posToNumber(source, pos) {
if (typeof pos === "number") return pos;
const lines = source.split(splitRE);
const { line, column } = pos;
let start = 0;
for (let i = 0; i < line - 1 && i < lines.length; i++) {
start += lines[i].length + 1;
}
return start + column;
}
const splitRE = /\r?\n/g;
const range = 2;
function generateCodeFrame(source, start = 0, end) {
start = Math.max(posToNumber(source, start), 0);
end = Math.min(
end !== undefined ? posToNumber(source, end) : start,
source.length
);
const lines = source.split(splitRE);
let count = 0;
const res = [];
for (let i = 0; i < lines.length; i++) {
count += lines[i].length;
if (count >= start) {
for (let j = i - range; j <= i + range || end > count; j++) {
if (j < 0 || j >= lines.length) continue;
const line = j + 1;
res.push(
`${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${
lines[j]
}`
);
const lineLength = lines[j].length;
if (j === i) {
// push underline
const pad = Math.max(start - (count - lineLength), 0);
const length = Math.max(
1,
end > count ? lineLength - pad : end - start
);
res.push(` | ` + " ".repeat(pad) + "^".repeat(length));
} else if (j > i) {
if (end > count) {
const length = Math.max(Math.min(end - count, lineLength), 1);
res.push(` | ` + "^".repeat(length));
}
count += lineLength + 1;
}
}
break;
}
count++;
}
return res.join("\n");
}
async function transformMiddleware(server, req, res, next) {
// this is what the transform middleware does for filetypes it
// recognizes (js, tx...), this is mostly a copy
try {
let url = decodeURI(removeTimestampQuery(req.url)).replace(
NULL_BYTE_PLACEHOLDER,
"\0"
);
url = removeImportQuery(url);
// Strip valid id prefix. This is prepended to resolved Ids that are
// not valid browser import specifiers by the importAnalysis plugin.
url = unwrapId(url);
// check if we can return 304 early
const ifNoneMatch = req.headers["if-none-match"];
if (
ifNoneMatch &&
(await server.moduleGraph.getModuleByUrl(url, false))?.transformResult
?.etag === ifNoneMatch
) {
// isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)
res.statusCode = 304;
return res.end();
}
// resolve, load and transform using the plugin container
const result = await server.transformRequest(url, server, {
html: req.headers.accept?.includes("text/html"),
});
if (result) {
return send(req, res, result.code, "js", {
etag: result.etag,
// allow browser to cache npm deps!
cacheControl: "no-cache",
headers: server.config.server.headers,
map: result.map,
});
}
} catch (e) {
if (e?.code === ERR_OPTIMIZE_DEPS_PROCESSING_ERROR) {
// Skip if response has already been sent
if (!res.writableEnded) {
res.statusCode = 504; // status code request timeout
res.end();
}
// This timeout is unexpected
logger.error(e.message);
return;
}
if (e?.code === ERR_OUTDATED_OPTIMIZED_DEP) {
// Skip if response has already been sent
if (!res.writableEnded) {
res.statusCode = 504; // status code request timeout
res.end();
}
// We don't need to log an error in this case, the request
// is outdated because new dependencies were discovered and
// the new pre-bundle dependencies have changed.
// A full-page reload has been issued, and these old requests
// can't be properly fulfilled. This isn't an unexpected
// error but a normal part of the missing deps discovery flow
return;
}
return next(e);
}
}
function build(buildCommand) {
let args = buildCommand.split(" ");
let cmd = args.shift();
return spawnSync(cmd, args);
}
function buildWatch(watchCommand) {
let args = watchCommand.split(" ");
let cmd = args.shift();
return spawn(cmd, args);
}
function createViteErrorFromRpc(error, root) {
return {
plugin: "melange-plugin",
pluginCode: "MELANGE_COMPILATION_FAILED",
// message: match.groups.message.replace(/^> /gm, "").replace(/^Error: /, ""),
message: error.message,
frame:
error.start &&
generateCodeFrame(
readFileSync(error.file, "utf-8"),
error.start,
error.end
),
stack: "",
id: path.relative(root, error.file),
loc: error.start && {
file: error.file,
line: error.start.line,
column: error.start.column + 1,
},
isError: error.severity === "error",
};
}
function isMelangeSourceType(id) {
return id.endsWith(".ml") || id.endsWith(".mlx") || id.endsWith(".re") || id.endsWith(".res");
}
function tryFiles(files) {
for (let file of files) {
if (existsSync(file)) {
return file;
}
}
return undefined;
}
function melangePlugin(options) {
const {
buildCommand,
watchCommand,
duneDir,
buildContext,
buildTarget,
emitDir,
} = options;
let config;
let duneProcess;
let currentServer;
let currentError;
const changedSourceFiles = new Set();
const dunePath = () => {
return path.join(config.root, duneDir || ".");
};
const rpcPath = () => {
return path.join(dunePath(), "_build/.rpc/dune");
};
const emitPath = () => {
return path.join(config.root, emitDir || ".");
};
const builtPath = (relativeJsPath) => {
// https://melange.re/v1.0.0/build-system/#javascript-artifacts-layout
return path.join(
dunePath(),
"_build",
buildContext || "default",
path.relative(dunePath(), emitPath()),
buildTarget || "output",
relativeJsPath || ""
);
};
const artifactPath = (relativeJsPath) => {
return path.join(
dunePath(),
"_build",
buildContext || "default",
relativeJsPath || ""
);
};
const depsDir = () => {
return builtPath("node_modules");
};
const sourceToBuiltFile = (id) => {
let base;
if (id.includes(artifactPath(""))) {
base = artifactPath("");
} else {
base = dunePath();
}
// console.log(`${base} ${id} ${path.relative(base, id)}`)
const relativeJsPath = path
.relative(base, id)
.replace(/\.(ml|mlx|re|res)$/, ".js");
return builtPath(relativeJsPath);
};
const builtFileToSource = (id) => {
const relativePathAsJs = path.relative(builtPath(), id);
return tryFiles([
path.join(config.root, relativePathAsJs.replace(/\.js$/, ".ml")),
path.join(config.root, relativePathAsJs.replace(/\.js$/, ".mlx")),
path.join(config.root, relativePathAsJs.replace(/\.js$/, ".re")),
path.join(config.root, relativePathAsJs.replace(/\.js$/, ".res")),
path.join(artifactPath(relativePathAsJs).replace(/\.js$/, ".ml")),
path.join(artifactPath(relativePathAsJs).replace(/\.js$/, ".mlx")),
path.join(artifactPath(relativePathAsJs).replace(/\.js$/, ".re")),
path.join(artifactPath(relativePathAsJs).replace(/\.js$/, ".res")),
]);
};
const sendError = function (error) {
const builtError = buildErrorMessage(error, [
colors.red(`Internal server error: ${error.message}`),
]);
currentServer.config.logger &&
currentServer.config.logger.error(builtError, {
clear: true,
timestamp: true,
});
currentServer.ws &&
currentServer.ws.send({
type: "error",
err: prepareError(error),
});
};
const onSuccess = function () {
// console.log('Success');
// this._container.config.logger.clearScreen("error");
// this._container.config.logger.info(
// colors.green("Melange compilation successful")
// );
const changedModules = [...changedSourceFiles]
.map((file) => [
...((currentServer.moduleGraph.getModulesByFile(file) &&
currentServer.moduleGraph.getModulesByFile(file)) ||
[]),
])
.flat();
changedModules.forEach((module) => {
module.file = path.relative(config.root, module.file);
currentServer.reloadModule(module);
});
if (changedModules.length === 0 && currentError) {
if (currentServer.ws.clients.size === 0) {
this._container.config.logger.clearScreen("error");
this._container.config.logger.info(
colors.green("Melange compilation error fixed!")
);
} else {
currentServer.ws.send({ type: "full-reload" });
}
}
changedSourceFiles.clear();
currentError = null;
};
const onDiagnosticAdd = function (error) {
// console.log('DiagnosticAdd');
// console.log(error);
const viteError = createViteErrorFromRpc(error, config.root);
sendError(viteError);
currentError = viteError;
};
const onDiagnosticRemove = function (error) {
// console.log('DiagnosticRemove');
// console.log(error);
};
const onRpcError = function (error) {
console.log("RPC error");
console.log(error);
};
return {
name: "melange-plugin",
enforce: "pre",
configResolved(resolvedConfig) {
config = resolvedConfig;
},
buildStart() {
if (this.meta.watchMode) {
duneProcess = buildWatch(watchCommand);
let error = "";
duneProcess.stderr.on("data", (data) => {
// console.log(data.toString());
error += data.toString();
});
duneProcess.on("close", (code) => {
if (code === 1 && error != "") {
console.log(`child process exited with code ${code}`);
this.error(error);
}
});
process.on("exit", () => {
duneProcess.kill();
});
init(
rpcPath(),
onSuccess.bind(this),
onDiagnosticAdd.bind(this),
onDiagnosticRemove.bind(this),
onRpcError.bind(this)
);
} else {
let child = build(buildCommand);
if (child.status != 0 && child.stderr) {
this.error(child.stderr.toString());
}
}
},
closeBundle() {
// console.log("close bundle");
destroy();
duneProcess && duneProcess.kill();
},
async resolveId(source, importer, options) {
const {source: file, postfix} = splitFileAndPostfix(source);
importer = importer && cleanUrl(importer);
// console.log(`${source} from ${importer}`);
// Opam deps can get compiled in
// `_build/$buildContext/$emitDir/$buildTarget/node_modules/`
// It's the case for `melange` (stdlib), `melange.belt`,
// `melange.runtime`, `reason-react`...
if (
!source.startsWith("/") &&
!source.startsWith(".") &&
existsSync(depsDir() + "/" + source)
) {
return { id: depsDir() + "/" + source, moduleSideEffects: null };
}
// Normal resolution sometimes does not happen...
if (isMelangeSourceType(source)) {
// console.log('importing melange file');
if (existsSync(source)) {
return { id: source + postfix };
}
const resolution = path.resolve(path.dirname(importer), source);
if (existsSync(resolution)) {
// console.log('resolution found');
return { id: resolution + postfix };
}
}
if (
!(importer && isMelangeSourceType(importer) && source.startsWith("."))
) {
// console.log('resolveId returning null');
return null;
}
// When a compiled file imports another compiled file,
// `importer` will be the source file, so we resolve from the compiled file
// and then return the source path for the resulting file
importer = sourceToBuiltFile(importer);
const resolution = path.resolve(path.dirname(importer), source);
// console.log(`${importer} resolves ${resolution}`);
if (existsSync(resolution)) {
// console.log(`${importer} resolves ${resolution} it exists`);
const sourceFile = builtFileToSource(resolution);
// console.log(`${source} is ${sourceFile}`);
if (sourceFile) {
return { id: sourceFile + postfix };
} else {
// if the file imported is `runtime_deps` (from dune), there won't be any sourceFile
return { id: resolution + postfix };
}
}
return null;
},
transformIndexHtml(html) {
if (currentError) {
// by throwing in transformIndexHtml, we use the errorMiddleware
// as soon as possible
throw currentError;
} else {
return html;
}
},
async load(id) {
id = cleanUrl(id);
// console.log(`loading ${id}`);
if (isMelangeSourceType(id)) {
// console.log(`so loading ${sourceToBuiltFile(id)}`);
try {
return await promises.readFile(sourceToBuiltFile(id), "utf-8");
} catch (error) {
return "";
}
}
return null;
},
async handleHotUpdate({ file, modules, read, server }) {
// We don't want to send an HMR update for files that have been updated
// but make the compilation fail. So we store which files have changed,
// and when a compilation has succeeded, we send an HMR update for those
// files and reset the list of changed files.
if (isMelangeSourceType(file)) {
changedSourceFiles.add(file);
return [];
}
return modules;
},
configureServer(server) {
currentServer = server;
server.middlewares.use(async function (req, res, next) {
if (isMelangeSourceType(cleanUrl(req.url))) {
return transformMiddleware(server, req, res, next);
}
next();
});
},
};
}
export { melangePlugin as default };