UNPKG

@lue-bird/elm-state-interface-experimental

Version:
518 lines 22.7 kB
import * as fs from "node:fs"; import * as path from "node:path"; import * as http from "node:http"; import * as timers from "node:timers"; import * as child_process from "node:child_process"; import { default as process } from "node:process"; import { Buffer } from "node:buffer"; function showCursor() { if (process.stdout.isTTY) { process.stdout.write("\u{001B}[?25h"); } } export function programStart(appConfig) { process.addListener("beforeExit", (_event) => { showCursor(); }); process.addListener("SIGINT", (_event) => { abortControllers.forEach((abortController) => { try { abortController.abort(); } catch (errorOnAbort) { console.warn("forceful abort of an operation lead to an error ", errorOnAbort); } }); appConfig.ports.toJs.unsubscribe(listenToElm); showCursor(); }); function listenToElm(fromElm) { function sendToElm(eventData) { const toElm = { id: fromElm.id, eventData: eventData }; appConfig.ports.fromJs.send(toElm); } interfaceDiffImplementation(fromElm.diff.tag, sendToElm, fromElm.id)(fromElm.diff.value); } appConfig.ports.toJs.subscribe(listenToElm); function interfaceDiffImplementation(tag, sendToElm, id) { switch (tag) { case "Add": return (config) => { const abortController = new AbortController(); abortControllers.set(id, abortController); interfaceAddImplementation(config.tag, sendToElm, abortController.signal)(config.value); }; case "Remove": return (_config) => { const abortController = abortControllers.get(id); if (abortController !== undefined) { try { abortController.abort(); } catch (errorOnAbort) { console.warn("Removing an interface aborted an operation which lead to an error:", errorOnAbort, "Try to keep such interfaces alive until you receive confirmation they completed"); } abortControllers.delete(id); } else { notifyOfBug("trying to remove an interface that was already aborted"); } }; } } function interfaceAddImplementation(tag, sendToElm, abortSignal) { switch (tag) { case "StandardInListen": return (_config) => { if (process.stdin.setRawMode !== undefined) { process.stdin.setRawMode(false); } function listen(buffer) { sendToElm(buffer.toString()); } process.stdin.addListener("data", listen); abortSignal.addEventListener("abort", (_event) => { process.stdin.removeListener("data", listen); process.stdin.unref(); }); }; case "StandardInRawListen": return (_config) => { if (process.stdin.setRawMode !== undefined) { process.stdin.setRawMode(true); } function dataListen(buffer) { const stringInput = buffer.toString(); if (stringInput == "\u0003") { abortControllers.forEach((abortController) => { try { abortController.abort(); } catch (errorOnAbort) { console.warn("forceful abort of an operation lead to an error ", errorOnAbort); } }); appConfig.ports.toJs.unsubscribe(listenToElm); showCursor(); } else { sendToElm({ tag: "StreamDataReceived", value: stringInput }); } } function endListen() { sendToElm({ tag: "StreamDataEndReached", value: null }); } process.stdin.addListener("data", dataListen); process.stdin.addListener("end", endListen); abortSignal.addEventListener("abort", (_event) => { process.stdin.removeListener("data", dataListen); process.stdin.removeListener("end", endListen); if (process.stdin.setRawMode !== undefined) { process.stdin.setRawMode(false); } process.stdin.unref(); }); }; case "StandardOutWrite": return (text) => { process.stdout.write(text); }; case "StandardErrWrite": return (text) => { process.stderr.write(text); }; case "WorkingDirectoryPathRequest": return (_config) => { queueAbortable(abortSignal, () => { sendToElm(process.cwd()); }); }; case "LaunchArgumentsRequest": return (_config) => { queueAbortable(abortSignal, () => { sendToElm(process.argv); }); }; case "EnvironmentVariablesRequest": return (_config) => { queueAbortable(abortSignal, () => { sendToElm(process.env); }); }; case "Exit": return (code) => { process.exitCode = code; }; case "ProcessTitleSet": return (newTitle) => { process.title = newTitle; }; case "TerminalSizeRequest": return (_config) => { queueAbortable(abortSignal, () => { sendToElm({ lines: process.stdout.rows, columns: process.stdout.columns }); }); }; case "TerminalSizeChangeListen": return (_config) => { function onResize(_event) { sendToElm({ lines: process.stdout.rows, columns: process.stdout.columns }); } process.stdout.addListener("resize", onResize); abortSignal.addEventListener("abort", (_event) => { process.stdout.removeListener("resize", onResize); }); }; case "HttpRequestSend": return (config) => { httpFetch(config, abortSignal).then(sendToElm); }; case "HttpRequestListen": return (config) => { const server = http.createServer(); server.addListener("request", (request, responseBuilder) => { let dataChunks = []; request.addListener("data", (dataChunk) => { dataChunks.push(dataChunk); }); sendToElm({ tag: "HttpRequestReceived", value: { method: request.method, headers: Object.entries(request.headers) .map(([name, value]) => ({ name: name, value: value })), dataAsciiString: bytesToAsciiString(Buffer.concat(dataChunks)) } }); function sendResponse(response) { responseBuilder.writeHead(response.statusCode, Object.fromEntries(response.headers.map((header) => { const tuple = [header.name, header.value]; return tuple; }))); responseBuilder.write(response.data); responseBuilder.end(); sendToElm({ tag: "HttpResponseSent", value: null }); } const responseAlreadyWaiting = httpResponsesAwaitingRequest.get(config.port); if (responseAlreadyWaiting === undefined) { httpRequestsAwaitingResponse.set(config.port, sendResponse); } else { sendResponse(responseAlreadyWaiting); } }); server.addListener("error", (error) => { sendToElm({ tag: "HttpServerFailed", value: { code: error.code, message: error.message } }); }); server.listen(config.port); sendToElm({ tag: "HttpServerOpened", value: null }); abortSignal.addEventListener("abort", (_event) => { server.close(); }); }; case "HttpResponseSend": return (config) => { const response = { statusCode: config.statusCode, headers: config.headers, data: asciiStringToBytes(config.dataAsciiString) }; const httpRequestAwaitingResponse = httpRequestsAwaitingResponse.get(config.port); if (httpRequestAwaitingResponse === undefined) { httpResponsesAwaitingRequest.set(config.port, response); abortSignal.addEventListener("abort", (_event) => { if (httpResponsesAwaitingRequest.get(config.port) === response) { httpResponsesAwaitingRequest.delete(config.port); } }); } else { httpRequestAwaitingResponse(response); httpRequestsAwaitingResponse.delete(config.port); } }; case "TimePosixRequest": return (_config) => { queueAbortable(abortSignal, () => { sendToElm(Date.now()); }); }; case "TimezoneOffsetRequest": return (_config) => { queueAbortable(abortSignal, () => { sendToElm(new Date().getTimezoneOffset()); }); }; case "TimezoneNameRequest": return (_config) => { queueAbortable(abortSignal, () => { sendToElm(Intl.DateTimeFormat().resolvedOptions().timeZone); }); }; case "TimePeriodicallyListen": return (config) => { const timePeriodicallyListenId = timers.setInterval(() => { sendToElm(Date.now()); }, config.milliSeconds); abortSignal.addEventListener("abort", _event => { timers.clearInterval(timePeriodicallyListenId); }); }; case "TimeOnce": return (config) => { const timeOnceId = timers.setTimeout(() => { sendToElm(Date.now()); }, config.pointInTime - Date.now()); abortSignal.addEventListener("abort", _event => { timers.clearTimeout(timeOnceId); }); }; case "RandomUnsignedInt32sRequest": return (config) => { queueAbortable(abortSignal, () => { sendToElm(Array.from(crypto.getRandomValues(new Uint32Array(config)))); }); }; case "DirectoryMake": return (path) => { fs.promises.mkdir(path, { recursive: true }) .then((_createdPath) => { if (!abortSignal.aborted) { sendToElm({ tag: "Ok", value: null }); } }) .catch((error) => { if (!abortSignal.aborted) { sendToElm({ tag: "Err", value: error }); } }); }; case "FileRemove": return (path) => { fs.promises.unlink(path) .then(() => { }) .catch((error) => { console.warn("failed to unlink file", error); }); }; case "FileWrite": return (write) => { fileWrite(write, sendToElm, abortSignal); }; case "FileRequest": return (path) => { fs.promises.readFile(path, { signal: abortSignal }) .then((contentBuffer) => { sendToElm({ tag: "Ok", value: bytesToAsciiString(contentBuffer) }); }) .catch((error) => { if (!abortSignal.aborted) { sendToElm({ tag: "Err", value: error }); } }); }; case "FileInfoRequest": return (path) => { fs.promises.stat(path) .then((stats) => { if (!abortSignal.aborted) { sendToElm({ byteCount: stats.size, kind: stats.isDirectory() ? "Directory" : "File", lastContentChangePosixMilliseconds: stats.mtimeMs }); } }) .catch((_enoend) => { if (!abortSignal.aborted) { sendToElm(null); } }); }; case "FileChangeListen": return (path) => { const retryIntervalId = timers.setInterval(() => { if (fs.existsSync(path)) { timers.clearInterval(retryIntervalId); watchPath(path, abortSignal, sendToElm); } }, 2000); abortSignal.addEventListener("abort", (_event) => { timers.clearInterval(retryIntervalId); }); }; case "DirectorySubPathsRequest": return (path) => { fs.promises.readdir(path, { recursive: true }) .then((subNames) => { if (!abortSignal.aborted) { sendToElm({ tag: "Ok", value: subNames }); } }) .catch((error) => { if (!abortSignal.aborted) { sendToElm({ tag: "Err", value: error }); } }); }; case "SubProcessSpawn": return (config) => { const subProcess = subProcessGetExistingOrSpawn(config); function exitListen(code) { sendToElm({ tag: "SubProcessExited", value: code }); } subProcess.addListener("exit", exitListen); subProcess.addListener("error", (error) => { if (abortSignal.aborted) { } else { warn("sub-process failed: " + error.message); } }); function standardErrorDataListen(buffer) { sendToElm({ tag: "SubProcessStandardErrEvent", value: { tag: "StreamDataReceived", value: buffer.toString() } }); } function standardErrorEndListen() { sendToElm({ tag: "SubProcessStandardErrEvent", value: { tag: "StreamDataEndReached", value: null } }); } subProcess.stderr.addListener("data", standardErrorDataListen); subProcess.stderr.addListener("end", standardErrorEndListen); function standardOutDataListen(buffer) { sendToElm({ tag: "SubProcessStandardOutEvent", value: { tag: "StreamDataReceived", value: bytesToAsciiString(buffer) } }); } function standardOutEndListen() { sendToElm({ tag: "SubProcessStandardOutEvent", value: { tag: "StreamDataEndReached", value: null } }); } subProcess.stdout.addListener("data", standardOutDataListen); subProcess.stdout.addListener("end", standardOutEndListen); abortSignal.addEventListener("abort", (_event) => { subProcess.removeListener("exit", exitListen); subProcess.stderr.removeListener("data", standardErrorDataListen); subProcess.stderr.removeListener("end", standardErrorEndListen); subProcess.stderr.removeListener("data", standardOutDataListen); subProcess.stderr.removeListener("end", standardOutEndListen); subProcess.kill(); subProcesses.delete(subProcessKey(config)); }); }; case "SubProcessStandardInWrite": return (config) => { const addressedSubProcess = subProcessGetExistingOrSpawn(config); if (addressedSubProcess === undefined) { warn("tried to write to standard in of a sub-process that hasn't been spawned, yet"); } else { addressedSubProcess.stdin.write(asciiStringToBytes(config.data)); } }; default: return (_config) => { notifyOfUnknownMessageKind("Add." + tag); }; } } } const abortControllers = new Map(); const httpRequestsAwaitingResponse = new Map(); const httpResponsesAwaitingRequest = new Map(); const recentlyWrittenToFilePaths = new Set(); const subProcesses = new Map(); function subProcessKey(config) { return JSON.stringify(config); } function subProcessGetExistingOrSpawn(config) { const subProcessAssociatedKey = subProcessKey(config); const existingSubProcess = subProcesses.get(subProcessAssociatedKey); if (existingSubProcess !== undefined) { return existingSubProcess; } else { const spawnedSubProcess = child_process.spawn(config.command, config.arguments, { cwd: config.workingDirectoryPath, env: config.environmentVariables, shell: false, detached: false, }); subProcesses.set(subProcessAssociatedKey, spawnedSubProcess); return spawnedSubProcess; } } function queueAbortable(abortSignal, action) { const immediateId = timers.setImmediate(action); abortSignal.addEventListener("abort", _event => { timers.clearImmediate(immediateId); }); } function fileWrite(write, sendToElm, abortSignal) { recentlyWrittenToFilePaths.add(write.path); fs.promises.writeFile(write.path, asciiStringToBytes(write.contentAsciiString), { signal: abortSignal }) .then(() => { sendToElm({ tag: "Ok", value: null }); timers.setTimeout(() => { recentlyWrittenToFilePaths.delete(write.path); }, 100); }) .catch((error) => { sendToElm({ tag: "Err", value: error }); timers.setTimeout(() => { recentlyWrittenToFilePaths.delete(write.path); }, 100); }); abortSignal.addEventListener("abort", (_event) => { timers.setTimeout(() => { recentlyWrittenToFilePaths.delete(write.path); }, 100); }); } function watchPath(pathToWatch, abortSignal, sendToElm) { let timeoutIdWaitingForLastChunk = null; fs.watch(pathToWatch, { recursive: true, signal: abortSignal }, (_event, fileName) => { if (fileName !== null) { const fullPath = path.basename(pathToWatch) == fileName ? pathToWatch : path.join(pathToWatch, fileName); if (!recentlyWrittenToFilePaths.has(fullPath)) { const currentAttemptTimeoutIdWaitingForLastChunk = timers.setTimeout(() => { if (currentAttemptTimeoutIdWaitingForLastChunk === timeoutIdWaitingForLastChunk) { if (fs.existsSync(fullPath)) { sendToElm({ tag: "AddedOrChanged", value: fullPath }); } else { sendToElm({ tag: "Removed", value: fullPath }); } } }, 25); timeoutIdWaitingForLastChunk = currentAttemptTimeoutIdWaitingForLastChunk; } } }); } function httpFetch(request, abortSignal) { return fetch(request.url, { method: request.method, body: request.bodyAsciiString === null ? null : asciiStringToBytes(request.bodyAsciiString), headers: new Headers(request.headers.map(header => { const tuple = [header.name, header.value]; return tuple; })), signal: abortSignal }) .then((response) => response .arrayBuffer() .then((bodyArrayBuffer) => ({ tag: "Ok", value: { statusCode: response.status, statusText: response.statusText, headers: Array.from(response.headers.entries()) .map(([name, value]) => ({ name: name, value: value })), bodyAsciiString: bytesToAsciiString(new Uint8Array(bodyArrayBuffer)) } }))) .catch((error) => ({ tag: "Err", value: error })); } function asciiStringToBytes(string) { const result = new Uint8Array(string.length); for (let i = 0; i < string.length; i++) { result[i] = string.charCodeAt(i); } return result; } function bytesToAsciiString(bytes) { let result = ""; for (let i = 0; i < bytes.length; i++) { result += String.fromCharCode(bytes[i]); } return result; } function warn(warning) { console.warn(warning + " (lue-bird/elm-state-interface-experimental)"); } function notifyOfUnknownMessageKind(messageTag) { notifyOfBug("unknown message kind " + messageTag + " from elm. The associated js implementation is missing"); } function notifyOfBug(bugDescription) { console.error("bug: " + bugDescription + ". Please open an issue on github.com/lue-bird/elm-state-interface-experimental"); } //# sourceMappingURL=node.js.map