diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d0ef88301..a4352d389a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ #### :rocket: New Feature - Add support for type coercion for invariant type arguments such as array payloads. https://github.com/rescript-lang/rescript-compiler/pull/6518 +- Start treating `rescript` command the same as the `rescript build` command, so now you can do `rescript -w`. https://github.com/rescript-lang/rescript-compiler/pull/6524 #### :bug: Bug Fix diff --git a/jscomp/build_tests/cli_help/input.js b/jscomp/build_tests/cli_help/input.js index 2c38947612..d2ebcde3c7 100755 --- a/jscomp/build_tests/cli_help/input.js +++ b/jscomp/build_tests/cli_help/input.js @@ -98,6 +98,28 @@ assert.match( // FIXME: Should be 0 assert.equal(out.status, 1); +// FIXME: Has the same problem with `rescript -w` +out = child_process.spawnSync(`../../../rescript`, ["-w", "--help"], { + encoding: "utf8", + cwd: __dirname, +}); +assert.equal(out.stdout, ">>>> Start compiling\n" + buildHelp); +assert.match( + out.stderr, + new RegExp( + "Uncaught Exception Error: ENOENT: no such file or directory, watch 'bsconfig.json'\n" + ) +); + +// Shows cli help with --help arg even if there are invalid arguments after it +out = child_process.spawnSync(`../../../rescript`, ["--help", "-w"], { + encoding: "utf8", + cwd: __dirname, +}); +assert.equal(out.stdout, cliHelp); +assert.equal(out.stderr, ""); +assert.equal(out.status, 0); + // Shows build help with -h arg out = child_process.spawnSync(`../../../rescript`, ["build", "-h"], { encoding: "utf8", @@ -108,12 +130,12 @@ assert.equal(out.stderr, ""); assert.equal(out.status, 0); // Exits with build help with unknown arg -out = child_process.spawnSync(`../../../rescript`, ["build", "-wtf"], { +out = child_process.spawnSync(`../../../rescript`, ["build", "-foo"], { encoding: "utf8", cwd: __dirname, }); assert.equal(out.stdout, ""); -assert.equal(out.stderr, 'Error: Unknown option "-wtf".\n' + buildHelp); +assert.equal(out.stderr, 'Error: Unknown option "-foo".\n' + buildHelp); assert.equal(out.status, 2); // Shows cli help with --help arg @@ -143,22 +165,22 @@ assert.equal(out.stdout, cliHelp); assert.equal(out.stderr, ""); assert.equal(out.status, 0); -// Shows cli help with unknown command +// Exits with cli help with unknown command out = child_process.spawnSync(`../../../rescript`, ["built"], { encoding: "utf8", cwd: __dirname, }); assert.equal(out.stdout, ""); -assert.equal(out.stderr, `Error: Unknown command or flag "built".\n` + cliHelp); +assert.equal(out.stderr, `Error: Unknown command "built".\n` + cliHelp); assert.equal(out.status, 2); -// Shows cli help with unknown args -out = child_process.spawnSync(`../../../rescript`, ["-w"], { +// Exits with build help with unknown args +out = child_process.spawnSync(`../../../rescript`, ["-foo"], { encoding: "utf8", cwd: __dirname, }); assert.equal(out.stdout, ""); -assert.equal(out.stderr, `Error: Unknown command or flag "-w".\n` + cliHelp); +assert.equal(out.stderr, 'Error: Unknown option "-foo".\n' + buildHelp); assert.equal(out.status, 2); // Shows clean help with --help arg @@ -180,12 +202,12 @@ assert.equal(out.stderr, ""); assert.equal(out.status, 0); // Exits with clean help with unknown arg -out = child_process.spawnSync(`../../../rescript`, ["clean", "-wtf"], { +out = child_process.spawnSync(`../../../rescript`, ["clean", "-foo"], { encoding: "utf8", cwd: __dirname, }); assert.equal(out.stdout, ""); -assert.equal(out.stderr, 'Error: Unknown option "-wtf".\n' + cleanHelp); +assert.equal(out.stderr, 'Error: Unknown option "-foo".\n' + cleanHelp); assert.equal(out.status, 2); // Shows format help with --help arg diff --git a/packages/artifacts.txt b/packages/artifacts.txt index 632bb368e7..0fbc2e7676 100644 --- a/packages/artifacts.txt +++ b/packages/artifacts.txt @@ -1061,6 +1061,7 @@ package.json rescript scripts/bin_path.js scripts/rescript_arg.js +scripts/rescript_bsb.js scripts/rescript_convert.js scripts/rescript_dump.js scripts/rescript_format.js diff --git a/rescript b/rescript index e3d2dbe0c2..a0ba2963a1 100755 --- a/rescript +++ b/rescript @@ -2,71 +2,27 @@ //@ts-check "use strict"; -/* This script is supposed to be running in project root directory +/** + * This script is supposed to be running in project root directory * It matters since we need read .sourcedirs(location) * and its content are file/directories with regard to project root */ -var child_process = require("child_process"); -var os = require("os"); -var path = require("path"); -var fs = require("fs"); var bsc_exe = require("./scripts/bin_path").bsc_exe; var rescript_exe = require("./scripts/bin_path").rescript_exe; +var bsb = require("./scripts/rescript_bsb"); -var LAST_SUCCESS_BUILD_STAMP = 0; var cwd = process.cwd(); -var lockFileName = path.join(cwd, ".bsb.lock"); process.env.BSB_PROJECT_ROOT = cwd; -const isTtyError = process.stderr.isTTY; -const isTtyStd = process.stdout.isTTY; - -let verbose = false; - -/** - * @time{[number,number]} - */ -let startTime; -function updateStartTime() { - startTime = process.hrtime(); -} -function updateFinishTime() { - const diff = process.hrtime(startTime); - return diff[0] * 1e9 + diff[1]; -} - -/** - * - * @param {string} file - * @returns - */ -function getWatchFiles(file) { - if (fs.existsSync(file)) { - return JSON.parse(fs.readFileSync(file, "utf8")); - } else { - return { dirs: [], generated: [] }; - } -} - -/** - * - * @param {*} str - */ -function dlog(str) { - if (verbose) { - console.log(str); - } -} - -var process_argv = process.argv; - if (process.env.NINJA_ANSI_FORCED === undefined) { if (require("tty").isatty(1)) { process.env.NINJA_ANSI_FORCED = "1"; } } else { - dlog(`NINJA_ANSI_FORCED: "${process.env.NINJA_ANSI_FORCED}"`); + if (process.argv.includes("-verbose")) { + console.log(`NINJA_ANSI_FORCED: "${process.env.NINJA_ANSI_FORCED}"`); + } } const helpMessage = `Usage: rescript @@ -89,113 +45,17 @@ Run \`rescript -h\` for subcommand help. Examples: rescript build -h rescript format -h`; -var isBuilding = false; -function releaseBuild() { - if (isBuilding) { - try { - fs.unlinkSync(lockFileName); - } catch (err) {} - isBuilding = false; - } -} - -// We use [~perm:0o664] rather than our usual default perms, [0o666], because -// lock files shouldn't rely on the umask to disallow tampering by other. -function acquireBuild() { - if (isBuilding) { - return false; - } else { - try { - const fid = fs.openSync(lockFileName, "wx", 0o664); - fs.closeSync(fid); - isBuilding = true; - } catch (err) { - if (err.code === "EEXIST") { - console.warn(lockFileName, "already exists, try later"); - } else console.log(err); - } - return isBuilding; - } -} - function onUncaughtException(err) { console.error("Uncaught Exception", err); - releaseBuild(); + bsb.releaseBuild(); process.exit(1); } function exitProcess() { - releaseBuild(); + bsb.releaseBuild(); process.exit(0); } -/** - * @param {number} [code] - */ -function logFinishCompiling(code) { - let log = `>>>> Finish compiling`; - if (code) { - log = log + " (exit: " + code + ")"; - } - if (isTtyStd) { - log = "\x1b[36m" + log + "\x1b[0m"; - } - if (code) { - console.log(log); - } else { - console.log(log, Math.floor(updateFinishTime() / 1e6), "mseconds"); - } -} - -function logStartCompiling() { - updateStartTime(); - let log = `>>>> Start compiling`; - if (isTtyStd) { - log = "\x1b[36m" + log + "\x1b[0m"; - } - console.log(log); -} - -/** - * @param {Array} args - * @param {(code: number) => void} [maybeOnClose] - */ -function delegateCommand(args, maybeOnClose) { - /** - * @type {child_process.ChildProcess} - */ - var p; - if (acquireBuild()) { - try { - p = child_process.spawn(rescript_exe, args, { - stdio: "inherit", - }); - } catch (e) { - if (e.code === "ENOENT") { - // when bsb is actually not found - console.error(String(e)); - } - releaseBuild(); - process.exit(2); - } - // The 'close' event will always emit after 'exit' was already emitted, or - // 'error' if the child failed to spawn. - p.on("close", code => { - releaseBuild(); - const exitCode = code === null ? 1 : code; - if (maybeOnClose) { - maybeOnClose(exitCode); - return; - } - process.exit(exitCode); - }); - } else { - console.warn(`Another build detected or stale lockfile ${lockFileName}`); - // rasing magic code - process.exit(133); - } -} - process.on("uncaughtException", onUncaughtException); // OS signal handlers @@ -207,369 +67,66 @@ process.on("SIGUSR2", exitProcess); process.on("SIGTERM", exitProcess); process.on("SIGHUP", exitProcess); +const process_argv = process.argv; const maybeSubcommand = process_argv[2]; -if (maybeSubcommand === "build" && process_argv.includes("-w")) { - // All clients of type MiniWebSocket - /** - * @type {any[]} - */ - let wsClients = []; - let withWebSocket = false; - let webSocketHost = "localhost"; - let webSocketPort = 9999; - - let resConfig = "rescript.json"; - if (!fs.existsSync(resConfig)) { - resConfig = "bsconfig.json"; - } - - const sourcedirs = path.join("lib", "bs", ".sourcedirs.json"); - - let LAST_BUILD_START = 0; - let LAST_FIRED_EVENT = 0; - /** - * @type {[string,string][]} - */ - let reasonsToRebuild = []; - let watchGenerated = []; - - /** - * watchers are held so that we close it later - */ - let watchers = []; - - const delegatedArgs = process_argv.slice(2); - verbose = delegatedArgs.includes("-verbose"); - - var wsParamIndex = delegatedArgs.indexOf("-ws"); - if (wsParamIndex > -1) { - var hostAndPortNumber = (delegatedArgs[wsParamIndex + 1] || "").split(":"); - /** - * @type {number} - */ - var portNumber; - if (hostAndPortNumber.length === 1) { - portNumber = parseInt(hostAndPortNumber[0]); - } else { - webSocketHost = hostAndPortNumber[0]; - portNumber = parseInt(hostAndPortNumber[1]); - } - if (!isNaN(portNumber)) { - webSocketPort = portNumber; - } - withWebSocket = true; - dlog(`WebSocket host & port number: ${webSocketHost}:${webSocketPort}`); - } - - const rescriptWatchBuildArgs = verbose - ? ["build", "-no-deps", "-verbose"] - : ["build", "-no-deps"]; - - function notifyClients() { - wsClients = wsClients.filter(x => !x.closed && !x.socket.destroyed); - var wsClientsLen = wsClients.length; - dlog(`Alive sockets number: ${wsClientsLen}`); - var data = '{"LAST_SUCCESS_BUILD_STAMP":' + LAST_SUCCESS_BUILD_STAMP + "}"; - for (var i = 0; i < wsClientsLen; ++i) { - // in reverse order, the last pushed get notified earlier - var client = wsClients[wsClientsLen - i - 1]; - if (!client.closed) { - client.sendText(data); - } - } - } - - function setUpWebSocket() { - var WebSocket = require("./lib/minisocket.js").MiniWebSocket; - var id = setInterval(notifyClients, 3000); - require("http") - .createServer() - .on("upgrade", function (req, socket, upgradeHead) { - dlog("connection opened"); - var ws = new WebSocket(req, socket, upgradeHead); - socket.on("error", function (err) { - dlog(`Socket Error ${err}`); - }); - wsClients.push(ws); - }) - .on("error", function (err) { - // @ts-ignore - if (err !== undefined && err.code === "EADDRINUSE") { - var error = isTtyStd ? `\x1b[1;31mERROR:\x1b[0m` : `ERROR:`; - console.error(`${error} The websocket port number ${webSocketPort} is in use. -Please pick a different one using the \`-ws [host:]port\` flag from bsb.`); - } else { - console.error(err); - } - process.exit(2); - }) - .listen(webSocketPort, webSocketHost); - } - - function watchBuild(watchConfig) { - var watchFiles = watchConfig.dirs; - watchGenerated = watchConfig.generated; - // close and remove all unused watchers - watchers = watchers.filter(function (watcher) { - if (watcher.dir === resConfig) { - return true; - } else if (watchFiles.indexOf(watcher.dir) < 0) { - dlog(`${watcher.dir} is no longer watched`); - watcher.watcher.close(); - return false; - } else { - return true; - } - }); - - // adding new watchers - for (var i = 0; i < watchFiles.length; ++i) { - var dir = watchFiles[i]; - if ( - !watchers.find(function (watcher) { - return watcher.dir === dir; - }) - ) { - dlog(`watching dir ${dir} now`); - var watcher = fs.watch(dir, onChange); - watchers.push({ dir: dir, watcher: watcher }); - } else { - // console.log(dir, 'already watched') - } - } - } - - /** - * @param {string | null} fileName - */ - function checkIsRebuildReason(fileName) { - // Return true if filename is nil, filename is only provided on Linux, macOS, Windows, and AIX. - // On other systems, we just have to assume that any change is valid. - // This could cause problems if source builds (generating js files in the same directory) are supported. - if (!fileName) return true; - - return ( - ((fileName.endsWith(".res") || - fileName.endsWith(".resi") || - fileName.endsWith(".ml") || - fileName.endsWith(".mli")) && - !watchGenerated.includes(fileName)) || - fileName === resConfig - ); - } - - /** - * @return {boolean} - */ - function needRebuild() { - return reasonsToRebuild.length !== 0; - } - - /** - * @param code {number} - */ - function buildFinishedCallback(code) { - if (code === 0) { - LAST_SUCCESS_BUILD_STAMP = Date.now(); - notifyClients(); - } - logFinishCompiling(code); - releaseBuild(); - if (needRebuild()) { - build(0); - } else { - var files = getWatchFiles(sourcedirs); - watchBuild(files); - } - } - - /** - * TODO: how to make it captured by vscode - * @param error {string} - * @param highlight {string} - */ - function outputError(error, highlight) { - if (isTtyError && highlight) { - process.stderr.write( - error.replace(highlight, "\x1b[1;31m" + highlight + "\x1b[0m") +if (!maybeSubcommand) { + bsb.build([]); +} else { + switch (maybeSubcommand) { + case "info": { + bsb.info(process_argv.slice(3)); + break; + } + case "clean": { + bsb.clean(process_argv.slice(3)); + break; + } + case "build": { + bsb.build(process_argv.slice(3)); + break; + } + case "format": + require("./scripts/rescript_format.js").main( + process.argv.slice(3), + rescript_exe, + bsc_exe ); - } else { - process.stderr.write(error); - } - } - - // Note this function filters the error output - // it relies on the fact that ninja will merege stdout and stderr - // of the compiler output, if it does not - // then we should have a way to not filter the compiler output - /** - * - * @param {number} depth - */ - function build(depth) { - if (reasonsToRebuild.length === 0) { - dlog("No need to rebuild"); - return; - } else { - dlog(`Rebuilding since ${reasonsToRebuild}`); - } - if (acquireBuild()) { - logStartCompiling(); - child_process - .spawn(rescript_exe, rescriptWatchBuildArgs, { - stdio: ["inherit", "inherit", "pipe"], - }) - // @ts-ignore - .on("data", function (s) { - outputError(s, "ninja: error"); - }) - .on("exit", buildFinishedCallback) - .stderr.setEncoding("utf8"); - // This is important to clean up all - // previous queued events - reasonsToRebuild = []; - LAST_BUILD_START = Date.now(); - } - // if acquiring lock failed, no need retry here - // since buildFinishedCallback will try again - // however this is no longer the case for multiple-process - // it could fail due to other issues like .bsb.lock - else { - dlog( - `Acquire lock failed, do the build later ${depth} : ${reasonsToRebuild}` + break; + case "dump": + require("./scripts/rescript_dump.js").main( + process.argv.slice(3), + rescript_exe, + bsc_exe ); - const waitTime = Math.pow(2, depth) * 40; - setTimeout(() => { - build(Math.min(depth + 1, 5)); - }, waitTime); - } - } - - /** - * - * @param {fs.WatchEventType} event - * @param {string | null} reason - */ - function onChange(event, reason) { - var eventTime = Date.now(); - var timeDiff = eventTime - LAST_BUILD_START; - var eventDiff = eventTime - LAST_FIRED_EVENT; - dlog(`Since last build: ${timeDiff} -- ${eventDiff}`); - if (timeDiff < 5 || eventDiff < 5) { - // for 5ms, we could think that the ninja not get - // kicked yet, so there is really no need - // to send more events here - - // note reasonsToRebuild also - // helps avoid redundant build, but this will - // save the event loop call `setImmediate` - return; - } - if (checkIsRebuildReason(reason)) { - dlog(`\nEvent ${event} ${reason}`); - LAST_FIRED_EVENT = eventTime; - reasonsToRebuild.push([event, reason || ""]); - // Some editors are using temporary files to store edits. - // This results in two sync change events: change + rename and two sync builds. - // Using setImmediate will ensure that only one build done. - setImmediate(() => { - if (needRebuild()) { - if (process.env.BS_WATCH_CLEAR && console.clear) { - console.clear(); - } - build(0); - } - }); - } - } - - /** - * - * @param {boolean} withWebSocket - */ - function startWatchMode(withWebSocket) { - if (withWebSocket) { - setUpWebSocket(); - } - // for column one based error message - - process.stdin.on("close", exitProcess); - // close when stdin stops - if (os.platform() !== "win32") { - process.stdin.on("end", exitProcess); - process.stdin.resume(); - } - - watchers.push({ watcher: fs.watch(resConfig, onChange), dir: resConfig }); - } - - logStartCompiling(); - delegateCommand(delegatedArgs, _ => { - startWatchMode(withWebSocket); - buildFinishedCallback(0); - }); -} else { - // We want to show the compile time for build - // But bsb might show a help message in some cases - // We don't want to show the compile time in the case - // But we can only be sure about that when building without any additional args - const isDefinitelyBuild = - maybeSubcommand === undefined || - (maybeSubcommand === "build" && process_argv.length === 3); - - if (isDefinitelyBuild) { - logStartCompiling(); - delegateCommand(process_argv.slice(2), exitCode => { - logFinishCompiling(exitCode); - process.exit(exitCode); - }); - } else { - switch (maybeSubcommand) { - case "info": - case "clean": - case "build": { - delegateCommand(process_argv.slice(2)); - break; - } - case "format": - require("./scripts/rescript_format.js").main( - process.argv.slice(3), - rescript_exe, - bsc_exe - ); - break; - case "dump": - require("./scripts/rescript_dump.js").main( - process.argv.slice(3), - rescript_exe, - bsc_exe - ); - break; - case "convert": - require("./scripts/rescript_convert.js").main( - process.argv.slice(3), - rescript_exe, - bsc_exe - ); - break; - case "-h": - case "-help": - case "--help": - case "help": - console.log(helpMessage); - break; - case "-v": - case "-version": - case "--version": - case "version": - console.log(require("./package.json").version); - break; - default: + break; + case "convert": + require("./scripts/rescript_convert.js").main( + process.argv.slice(3), + rescript_exe, + bsc_exe + ); + break; + case "-h": + case "-help": + case "--help": + case "help": + console.log(helpMessage); + break; + case "-v": + case "-version": + case "--version": + case "version": + console.log(require("./package.json").version); + break; + default: + if (maybeSubcommand.startsWith("-")) { + bsb.build(process_argv.slice(2)); + } else { console.error( - `Error: Unknown command or flag "${maybeSubcommand}".\n${helpMessage}` + `Error: Unknown command "${maybeSubcommand}".\n${helpMessage}` ); process.exit(2); - } + } } } diff --git a/scripts/rescript_bsb.js b/scripts/rescript_bsb.js new file mode 100644 index 0000000000..a64415ffe4 --- /dev/null +++ b/scripts/rescript_bsb.js @@ -0,0 +1,506 @@ +//@ts-check + +const fs = require("fs"); +const path = require("path"); +var os = require("os"); +const child_process = require("child_process"); +const rescript_exe = require("./bin_path").rescript_exe; + +/** + * @typedef {Object} ProjectFiles + * @property {Array} dirs + * @property {Array} generated + */ + +/** + * @typedef {Object} WatcherRef + * @property {string} dir + * @property {fs.FSWatcher} watcher + */ + +const cwd = process.cwd(); +const lockFileName = path.join(cwd, ".bsb.lock"); + +let isBuilding = false; +function releaseBuild() { + if (isBuilding) { + try { + fs.unlinkSync(lockFileName); + } catch (err) {} + isBuilding = false; + } +} + +// We use [~perm:0o664] rather than our usual default perms, [0o666], because +// lock files shouldn't rely on the umask to disallow tampering by other. +function acquireBuild() { + if (isBuilding) { + return false; + } else { + try { + const fid = fs.openSync(lockFileName, "wx", 0o664); + fs.closeSync(fid); + isBuilding = true; + } catch (err) { + if (err.code === "EEXIST") { + console.warn(lockFileName, "already exists, try later"); + } else console.log(err); + } + return isBuilding; + } +} + +/** + * @param {Array} args + * @param {(code: number) => void} [maybeOnClose] + */ +function delegate(args, maybeOnClose) { + /** + * @type {child_process.ChildProcess} + */ + let p; + if (acquireBuild()) { + try { + p = child_process.spawn(rescript_exe, args, { + stdio: "inherit", + }); + } catch (e) { + if (e.code === "ENOENT") { + // when bsb is actually not found + console.error(String(e)); + } + releaseBuild(); + process.exit(2); + } + // The 'close' event will always emit after 'exit' was already emitted, or + // 'error' if the child failed to spawn. + p.on("close", code => { + releaseBuild(); + const exitCode = code === null ? 1 : code; + if (maybeOnClose) { + maybeOnClose(exitCode); + return; + } + process.exit(exitCode); + }); + } else { + console.warn(`Another build detected or stale lockfile ${lockFileName}`); + // rasing magic code + process.exit(133); + } +} + +/** + * @param {Array} args + */ +function info(args) { + delegate(["info", ...args]); +} + +/** + * @param {Array} args + */ +function clean(args) { + delegate(["clean", ...args]); +} + +const isTtyError = process.stderr.isTTY; +const isTtyStd = process.stdout.isTTY; + +/** + * @type {[number,number]} + */ +let startTime; +function updateStartTime() { + startTime = process.hrtime(); +} +function updateFinishTime() { + const diff = process.hrtime(startTime); + return diff[0] * 1e9 + diff[1]; +} + +/** + * @param {number} [code] + */ +function logFinishCompiling(code) { + let log = `>>>> Finish compiling`; + if (code) { + log = log + " (exit: " + code + ")"; + } + if (isTtyStd) { + log = "\x1b[36m" + log + "\x1b[0m"; + } + if (code) { + console.log(log); + } else { + console.log(log, Math.floor(updateFinishTime() / 1e6), "mseconds"); + } +} + +function logStartCompiling() { + updateStartTime(); + let log = `>>>> Start compiling`; + if (isTtyStd) { + log = "\x1b[36m" + log + "\x1b[0m"; + } + console.log(log); +} + +function exitProcess() { + releaseBuild(); + process.exit(0); +} + +/** + * @param {string} file + * @returns + */ +function getProjectFiles(file) { + if (fs.existsSync(file)) { + return JSON.parse(fs.readFileSync(file, "utf8")); + } else { + return { dirs: [], generated: [] }; + } +} + +/** + * @param {Array} args + */ +function watch(args) { + // All clients of type MiniWebSocket + /** + * @type {any[]} + */ + let wsClients = []; + let withWebSocket = false; + let webSocketHost = "localhost"; + let webSocketPort = 9999; + + let resConfig = "rescript.json"; + if (!fs.existsSync(resConfig)) { + resConfig = "bsconfig.json"; + } + + const sourcedirs = path.join("lib", "bs", ".sourcedirs.json"); + + var LAST_SUCCESS_BUILD_STAMP = 0; + + let LAST_BUILD_START = 0; + let LAST_FIRED_EVENT = 0; + /** + * @type {[string,string][]} + */ + let reasonsToRebuild = []; + /** + * @type {string[]} + */ + let watchGenerated = []; + + /** + * @type {WatcherRef[]} + * watchers are held so that we close it later + */ + let watchers = []; + + const verbose = args.includes("-verbose"); + const dlog = verbose ? console.log : () => {}; + + var wsParamIndex = args.indexOf("-ws"); + if (wsParamIndex > -1) { + var hostAndPortNumber = (args[wsParamIndex + 1] || "").split(":"); + /** + * @type {number} + */ + var portNumber; + if (hostAndPortNumber.length === 1) { + portNumber = parseInt(hostAndPortNumber[0]); + } else { + webSocketHost = hostAndPortNumber[0]; + portNumber = parseInt(hostAndPortNumber[1]); + } + if (!isNaN(portNumber)) { + webSocketPort = portNumber; + } + withWebSocket = true; + dlog(`WebSocket host & port number: ${webSocketHost}:${webSocketPort}`); + } + + const rescriptWatchBuildArgs = verbose + ? ["build", "-no-deps", "-verbose"] + : ["build", "-no-deps"]; + + function notifyClients() { + wsClients = wsClients.filter(x => !x.closed && !x.socket.destroyed); + var wsClientsLen = wsClients.length; + dlog(`Alive sockets number: ${wsClientsLen}`); + var data = '{"LAST_SUCCESS_BUILD_STAMP":' + LAST_SUCCESS_BUILD_STAMP + "}"; + for (var i = 0; i < wsClientsLen; ++i) { + // in reverse order, the last pushed get notified earlier + var client = wsClients[wsClientsLen - i - 1]; + if (!client.closed) { + client.sendText(data); + } + } + } + + function setUpWebSocket() { + var WebSocket = require("../lib/minisocket.js").MiniWebSocket; + var id = setInterval(notifyClients, 3000); + require("http") + .createServer() + .on("upgrade", function (req, socket, upgradeHead) { + dlog("connection opened"); + var ws = new WebSocket(req, socket, upgradeHead); + socket.on("error", function (err) { + dlog(`Socket Error ${err}`); + }); + wsClients.push(ws); + }) + .on("error", function (err) { + // @ts-ignore + if (err !== undefined && err.code === "EADDRINUSE") { + var error = isTtyStd ? `\x1b[1;31mERROR:\x1b[0m` : `ERROR:`; + console.error(`${error} The websocket port number ${webSocketPort} is in use. +Please pick a different one using the \`-ws [host:]port\` flag from bsb.`); + } else { + console.error(err); + } + process.exit(2); + }) + .listen(webSocketPort, webSocketHost); + } + + /** + * @param {ProjectFiles} projectFiles + */ + function watchBuild(projectFiles) { + var watchFiles = projectFiles.dirs; + watchGenerated = projectFiles.generated; + // close and remove all unused watchers + watchers = watchers.filter(function (watcher) { + if (watcher.dir === resConfig) { + return true; + } else if (watchFiles.indexOf(watcher.dir) < 0) { + dlog(`${watcher.dir} is no longer watched`); + watcher.watcher.close(); + return false; + } else { + return true; + } + }); + + // adding new watchers + for (var i = 0; i < watchFiles.length; ++i) { + var dir = watchFiles[i]; + if ( + !watchers.find(function (watcher) { + return watcher.dir === dir; + }) + ) { + dlog(`watching dir ${dir} now`); + var watcher = fs.watch(dir, onChange); + watchers.push({ dir: dir, watcher: watcher }); + } else { + // console.log(dir, 'already watched') + } + } + } + + /** + * @param {string | null} fileName + */ + function checkIsRebuildReason(fileName) { + // Return true if filename is nil, filename is only provided on Linux, macOS, Windows, and AIX. + // On other systems, we just have to assume that any change is valid. + // This could cause problems if source builds (generating js files in the same directory) are supported. + if (!fileName) return true; + + return ( + ((fileName.endsWith(".res") || + fileName.endsWith(".resi") || + fileName.endsWith(".ml") || + fileName.endsWith(".mli")) && + !watchGenerated.includes(fileName)) || + fileName === resConfig + ); + } + + /** + * @return {boolean} + */ + function needRebuild() { + return reasonsToRebuild.length !== 0; + } + + /** + * @param {number} code + */ + function buildFinishedCallback(code) { + if (code === 0) { + LAST_SUCCESS_BUILD_STAMP = Date.now(); + notifyClients(); + } + logFinishCompiling(code); + releaseBuild(); + if (needRebuild()) { + build(0); + } else { + watchBuild(getProjectFiles(sourcedirs)); + } + } + + /** + * TODO: how to make it captured by vscode + * @param error {string} + * @param highlight {string} + */ + function outputError(error, highlight) { + if (isTtyError && highlight) { + process.stderr.write( + error.replace(highlight, "\x1b[1;31m" + highlight + "\x1b[0m") + ); + } else { + process.stderr.write(error); + } + } + + // Note this function filters the error output + // it relies on the fact that ninja will merege stdout and stderr + // of the compiler output, if it does not + // then we should have a way to not filter the compiler output + /** + * + * @param {number} depth + */ + function build(depth) { + if (reasonsToRebuild.length === 0) { + dlog("No need to rebuild"); + return; + } else { + dlog(`Rebuilding since ${reasonsToRebuild}`); + } + if (acquireBuild()) { + logStartCompiling(); + child_process + .spawn(rescript_exe, rescriptWatchBuildArgs, { + stdio: ["inherit", "inherit", "pipe"], + }) + // @ts-ignore + .on("data", function (s) { + outputError(s, "ninja: error"); + }) + .on("exit", buildFinishedCallback) + .stderr.setEncoding("utf8"); + // This is important to clean up all + // previous queued events + reasonsToRebuild = []; + LAST_BUILD_START = Date.now(); + } + // if acquiring lock failed, no need retry here + // since buildFinishedCallback will try again + // however this is no longer the case for multiple-process + // it could fail due to other issues like .bsb.lock + else { + dlog( + `Acquire lock failed, do the build later ${depth} : ${reasonsToRebuild}` + ); + const waitTime = Math.pow(2, depth) * 40; + setTimeout(() => { + build(Math.min(depth + 1, 5)); + }, waitTime); + } + } + + /** + * + * @param {fs.WatchEventType} event + * @param {string | null} reason + */ + function onChange(event, reason) { + var eventTime = Date.now(); + var timeDiff = eventTime - LAST_BUILD_START; + var eventDiff = eventTime - LAST_FIRED_EVENT; + dlog(`Since last build: ${timeDiff} -- ${eventDiff}`); + if (timeDiff < 5 || eventDiff < 5) { + // for 5ms, we could think that the ninja not get + // kicked yet, so there is really no need + // to send more events here + + // note reasonsToRebuild also + // helps avoid redundant build, but this will + // save the event loop call `setImmediate` + return; + } + if (checkIsRebuildReason(reason)) { + dlog(`\nEvent ${event} ${reason}`); + LAST_FIRED_EVENT = eventTime; + reasonsToRebuild.push([event, reason || ""]); + // Some editors are using temporary files to store edits. + // This results in two sync change events: change + rename and two sync builds. + // Using setImmediate will ensure that only one build done. + setImmediate(() => { + if (needRebuild()) { + if (process.env.BS_WATCH_CLEAR && console.clear) { + console.clear(); + } + build(0); + } + }); + } + } + + /** + * + * @param {boolean} withWebSocket + */ + function startWatchMode(withWebSocket) { + if (withWebSocket) { + setUpWebSocket(); + } + // for column one based error message + + process.stdin.on("close", exitProcess); + // close when stdin stops + if (os.platform() !== "win32") { + process.stdin.on("end", exitProcess); + process.stdin.resume(); + } + + watchers.push({ watcher: fs.watch(resConfig, onChange), dir: resConfig }); + } + + logStartCompiling(); + delegate(["build", ...args], _ => { + startWatchMode(withWebSocket); + buildFinishedCallback(0); + }); +} + +/** + * @param {Array} args + */ +function build(args) { + // We want to show the compile time for build + // But bsb might show a help message when --help or invalid arguments are passed + // We don't want to show the compile time in that case + // But since we don't have a proper parsing, + // we can be sure about that only when building without any additional args + if (args.length === 0) { + logStartCompiling(); + delegate(["build"], exitCode => { + logFinishCompiling(exitCode); + process.exit(exitCode); + }); + return; + } + if (args.includes("-w")) { + watch(args); + return; + } + delegate(["build", ...args]); +} + +exports.releaseBuild = releaseBuild; +exports.info = info; +exports.clean = clean; +exports.build = build;