@tonyfettes/vite-plugin-melange
Version:
Vite plugin for Melange
613 lines (506 loc) • 18.4 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var path = require('path');
require('process');
var child_process = require('child_process');
var fs = require('fs');
var getEtag = require('etag');
var colors = require('picocolors');
var strip = require('strip-ansi');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
var getEtag__default = /*#__PURE__*/_interopDefaultLegacy(getEtag);
var colors__default = /*#__PURE__*/_interopDefaultLegacy(colors);
var strip__default = /*#__PURE__*/_interopDefaultLegacy(strip);
/*
** Code from Vite
*/
// TODO: move in separate file
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 queryRE = /\?.*$/s;
const hashRE = /#.*$/s;
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__default["default"](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 cleanUrl = (url) => url.replace(hashRE, "").replace(queryRE, "");
// 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__default["default"](err.message),
stack: strip__default["default"](cleanStack(err.stack || "")),
id: err.id,
frame: strip__default["default"](err.frame || ""),
plugin: err.plugin,
pluginCode: err.pluginCode,
loc: err.loc,
};
}
function buildErrorMessage(err, args, includeStack = true) {
// if (err.plugin) args.push(` Plugin: ${colors.magenta(err.plugin)}`)
// @TODO we can add line and column numbers
if (err.id) args.push(`${colors__default["default"].white("file:")} ${colors__default["default"].cyan(err.id)}`);
if (err.frame) args.push(colors__default["default"].yellow(err.frame));
if (includeStack && err.stack) args.push(cleanStack(err.stack));
return args.join("\n") + "\n";
}
function cleanStack(stack) {
return stack
.split(/\n/g)
.filter((l) => /^\s*at/.test(l))
.join("\n");
}
function logWarning(server, err) {
server.config.logger.warn(
buildErrorMessage(err, [colors__default["default"].yellow(err.message)]),
{
clear: false,
timestamp: true,
error: err,
}
);
}
function logError(server, err) {
server.config.logger.error(
buildErrorMessage(err, [colors__default["default"].red(err.message)]),
{
clear: false,
timestamp: true,
error: err,
}
);
}
const melangeLogRE =
/> File "(?<file>.+)", lines? (?<line>[\d-]+)(, characters (?<col>[\d-]+))?:\r?\n(?<frame>(> +(\d+.+|\.\.\.)\r?\n)+)?(> (?<arrows>[ \^]+)\r?\n)?(?<message>> [^]*?)(?=> File|\[\d+\]|\$ .*|$)/g;
function build(buildCommand) {
let args = buildCommand.split(" ");
let cmd = args.shift();
return child_process.spawnSync(cmd, args);
}
function buildWatch(watchCommand) {
let args = watchCommand.split(" ");
let cmd = args.shift();
return child_process.spawn(cmd, args);
}
function createViteError(match, artifactToSource) {
let frame = match.groups.frame;
if (frame) {
const lineBorderIndex = match.groups.frame.indexOf("|");
if (match.groups.arrows) {
frame +=
match.groups.arrows.slice(0, lineBorderIndex) +
"| " +
match.groups.arrows.slice(lineBorderIndex);
}
}
const file = artifactToSource(match.groups.file);
return {
plugin: "melange-plugin",
pluginCode: "MELANGE_COMPILATION_FAILED",
message: match.groups.message.replace(/^> /gm, "").replace(/^Error: /, ""),
frame: frame,
stack: "",
id: file,
loc: {
file: file,
line: match.groups.line.replace(/-\d+/, ""),
column: match.groups.col,
},
isError: /^> Error: /.test(match.groups.message),
};
}
function isMelangeSourceType(id) {
return id.endsWith(".ml") || id.endsWith(".re") || id.endsWith(".res");
}
function tryFiles(files) {
for (let file of files) {
if (fs.existsSync(file)) {
return file;
}
}
return undefined;
}
function parseLog(log, displayedErrors, artifactToSource) {
const messages = Array.from(log.matchAll(melangeLogRE), (match) => {
if (displayedErrors.has(match.index)) {
return undefined;
} else {
displayedErrors.add(match.index);
return createViteError(match, artifactToSource);
}
}).filter((err) => err);
const firstError = messages.find((err) => err.isError);
const warnings = messages.filter((err) => !err.isError);
if (firstError) {
throw [firstError, warnings];
} else {
return warnings;
}
}
function melangePlugin(options) {
const { projectRoot, buildCommand, watchCommand, buildContext, buildTarget, emitDir } =
options;
let config;
const changedSourceFiles = new Set();
const displayedErrors = new Set();
const melangeLogFile = () => path__default["default"].join(projectRoot || config.root, "_build/log");
const builtPath = (relativeJsPath) => {
// https://melange.re/v1.0.0/build-system/#javascript-artifacts-layout
return path__default["default"].join(
projectRoot || config.root,
"_build",
buildContext || "default",
emitDir || "",
buildTarget || "output",
relativeJsPath || ""
);
};
const artifactPath = (relativeJsPath) => {
return path__default["default"].join(
projectRoot || config.root,
"_build",
buildContext || "default",
relativeJsPath || ""
);
};
const depsDir = () => {
return builtPath("node_modules");
};
const sourceToBuiltFile = (id) => {
let base;
if (id.includes(artifactPath(""))) {
base = artifactPath("");
} else {
base = projectRoot || config.root;
}
const relativeJsPath = path__default["default"]
.relative(base, id)
.replace(/\.(ml|re|res)$/, ".js");
return builtPath(relativeJsPath);
};
const builtFileToSource = (id) => {
const relativePathAsJs = path__default["default"].relative(builtPath(), id);
return tryFiles([
path__default["default"].join(projectRoot || config.root, relativePathAsJs.replace(/\.js$/, ".ml")),
path__default["default"].join(projectRoot || config.root, relativePathAsJs.replace(/\.js$/, ".re")),
path__default["default"].join(projectRoot || config.root, relativePathAsJs.replace(/\.js$/, ".res")),
path__default["default"].join(artifactPath(relativePathAsJs).replace(/\.js$/, ".ml")),
path__default["default"].join(artifactPath(relativePathAsJs).replace(/\.js$/, ".re")),
path__default["default"].join(artifactPath(relativePathAsJs).replace(/\.js$/, ".res")),
]);
};
const artifactToSource = (id) => {
if (id.includes(builtPath())) {
return id.replace(builtPath(), projectRoot || config.root);
} else {
return path__default["default"].join(projectRoot || config.root, id);
}
};
return {
name: "melange-plugin",
enforce: "pre",
configResolved(resolvedConfig) {
config = resolvedConfig;
},
buildStart() {
if (this.meta.watchMode) {
let child = buildWatch(watchCommand);
let error = "";
child.stderr.on("data", (data) => {
// this.error(data.toString());
error += data.toString();
});
child.on("close", (code) => {
// console.log(`child process exited with code ${code}`);
if (code != 0 && error != "") {
this.error(error);
}
});
process.on("exit", () => {
child.kill();
});
// this does not work at the moment so we rely on handleHotUpdate
// this.addWatchFile(melangeLogFile());
} else {
let child = build(buildCommand);
if (child.status != 0 && child.stderr) {
this.error(child.stderr.toString());
}
const log = fs.readFileSync(melangeLogFile(), "utf-8");
try {
const warnings = parseLog(log, displayedErrors, artifactToSource);
warnings.forEach((err) => {
this.warn(buildErrorMessage(err, [colors__default["default"].yellow(err.message)]));
});
} catch (messages) {
const [error, warnings] = messages;
warnings.forEach((err) => {
this.warn(buildErrorMessage(err, [colors__default["default"].yellow(err.message)]));
});
this.error(error);
}
}
},
async resolveId(source, importer, options) {
source = cleanUrl(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(".") &&
fs.existsSync(depsDir() + "/" + source)
) {
return { id: depsDir() + "/" + source, moduleSideEffects: null };
}
if (
!(importer && isMelangeSourceType(importer) && source.startsWith("."))
) {
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__default["default"].resolve(path__default["default"].dirname(importer), source);
// console.log(`${importer} resolves ${resolution}`);
if (fs.existsSync(resolution)) {
// console.log(`${importer} resolves ${resolution} it exists`);
const sourceFile = builtFileToSource(resolution);
// console.log(`${source} is ${sourceFile}`);
if (sourceFile) {
return { id: sourceFile };
} else {
// if the file imported is `runtime_deps` (from dune), there won't be any sourceFile
return { id: resolution };
}
} else {
const log = await fs.promises.readFile(melangeLogFile(), "utf-8");
try {
const warnings = parseLog(log, displayedErrors, artifactToSource);
warnings.forEach((err) => {
this.warn(buildErrorMessage(err, [colors__default["default"].yellow(err.message)]));
});
} catch (messages) {
const [error, warnings] = messages;
warnings.forEach((err) => {
this.warn(buildErrorMessage(err, [colors__default["default"].yellow(err.message)]));
});
this.error(error);
}
}
return null;
},
async load(id) {
id = cleanUrl(id);
if (isMelangeSourceType(id)) {
try {
return await fs.promises.readFile(sourceToBuiltFile(id), "utf-8");
} catch (error) {
const log = await fs.promises.readFile(melangeLogFile(), "utf-8");
try {
const warnings = parseLog(log, displayedErrors, artifactToSource);
warnings.forEach((err) => {
this.warn(buildErrorMessage(err, [colors__default["default"].yellow(err.message)]));
});
} catch (messages) {
const [error, warnings] = messages;
warnings.forEach((err) => {
this.warn(buildErrorMessage(err, [colors__default["default"].yellow(err.message)]));
});
this.error(error);
}
return null;
}
}
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 the log file shows that a compilation has succeeded, we send
// an HMR update for those files and reset the list of changed files.
if (file == melangeLogFile()) {
const log = await read();
try {
const warnings = parseLog(log, displayedErrors, artifactToSource);
warnings.forEach((err) => {
logWarning(server, err);
});
const changedModules = [...changedSourceFiles]
.map((file) => [
...((server.moduleGraph.getModulesByFile(file) &&
server.moduleGraph.getModulesByFile(file)) ||
[]),
])
.flat();
changedSourceFiles.clear();
return changedModules;
} catch (messages) {
const [error, warnings] = messages;
warnings.forEach((err) => {
logWarning(server, err);
});
logError(server, error);
server.ws.send({
type: "error",
err: prepareError(error),
});
return [];
}
}
if (isMelangeSourceType(file)) {
changedSourceFiles.add(file);
return [];
}
return modules;
},
configureServer(server) {
server.middlewares.use(async function (req, res, next) {
if (isMelangeSourceType(cleanUrl(req.url))) {
// 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);
}
}
next();
});
},
};
}
exports["default"] = melangePlugin;
exports.genSourceMapUrl = genSourceMapUrl;
exports.getCodeWithSourcemap = getCodeWithSourcemap;