@lue-bird/elm-state-interface-experimental
Version:
fast-moving, less tested version of elm-state-interface
518 lines • 22.7 kB
JavaScript
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