UNPKG

vite-plugin-melange

Version:
894 lines (758 loc) 27.1 kB
'use strict'; var path = require('path'); var child_process = require('child_process'); var fs = require('fs'); var colors = require('picocolors'); var net = require('node:net'); var util = require('node:util'); var getEtag = require('etag'); var strip = require('strip-ansi'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n["default"] = e; return Object.freeze(n); } var path__default = /*#__PURE__*/_interopDefaultLegacy(path); var colors__default = /*#__PURE__*/_interopDefaultLegacy(colors); var net__namespace = /*#__PURE__*/_interopNamespace(net); var util__namespace = /*#__PURE__*/_interopNamespace(util); var getEtag__default = /*#__PURE__*/_interopDefaultLegacy(getEtag); var strip__default = /*#__PURE__*/_interopDefaultLegacy(strip); 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__namespace.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__namespace.inspect(payload, { depth: Infinity, colors: true, compact: false, }), ); } } catch (e) { console.log("Unhandled payload"); console.log( util__namespace.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__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 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__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 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__default["default"].magenta(err.plugin)}`); const loc = err.loc ? `:${err.loc.line}:${err.loc.column}` : ""; if (err.id) args.push(` File: ${colors__default["default"].cyan(err.id)}${loc}`); if (err.frame) args.push(colors__default["default"].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 child_process.spawnSync(cmd, args); } function buildWatch(watchCommand) { let args = watchCommand.split(" "); let cmd = args.shift(); return child_process.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( fs.readFileSync(error.file, "utf-8"), error.start, error.end ), stack: "", id: path__default["default"].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(".re") || id.endsWith(".res"); } function tryFiles(files) { for (let file of files) { if (fs.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__default["default"].join(config.root, duneDir || "."); }; const rpcPath = () => { return path__default["default"].join(dunePath(), "_build/.rpc/dune"); }; const emitPath = () => { return path__default["default"].join(config.root, emitDir || "."); }; const builtPath = (relativeJsPath) => { // https://melange.re/v1.0.0/build-system/#javascript-artifacts-layout return path__default["default"].join( dunePath(), "_build", buildContext || "default", path__default["default"].relative(dunePath(), emitPath()), buildTarget || "output", relativeJsPath || "" ); }; const artifactPath = (relativeJsPath) => { return path__default["default"].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__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(config.root, relativePathAsJs.replace(/\.js$/, ".ml")), path__default["default"].join(config.root, relativePathAsJs.replace(/\.js$/, ".re")), path__default["default"].join(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 sendError = function (error) { const builtError = buildErrorMessage(error, [ colors__default["default"].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__default["default"].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__default["default"].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(".") && fs.existsSync(depsDir() + "/" + source) ) { return { id: depsDir() + "/" + source, moduleSideEffects: null }; } // Normal resolution sometimes does not happen... if (isMelangeSourceType(source)) { // console.log('importing melange file'); if (fs.existsSync(source)) { return { id: source + postfix }; } const resolution = path__default["default"].resolve(path__default["default"].dirname(importer), source); if (fs.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__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 + 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 fs.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(); }); }, }; } module.exports = melangePlugin;