UNPKG

elm-pages

Version:

Hybrid Elm framework with full-stack and static routes.

1,322 lines (1,250 loc) 38.9 kB
// @ts-check import * as path from "node:path"; import { default as mm } from "micromatch"; import { default as matter } from "gray-matter"; import * as globby from "globby"; import * as fsPromises from "node:fs/promises"; import * as preRenderHtml from "./pre-render-html.js"; import { lookupOrPerform } from "./request-cache.js"; import * as kleur from "kleur/colors"; import * as cookie from "cookie-signature"; import { compatibilityKey } from "./compatibility-key.js"; import * as fs from "node:fs"; import * as crypto from "node:crypto"; import { restoreColorSafe } from "./error-formatter.js"; import { Spinnies } from "./spinnies/index.js"; import { default as which } from "which"; import * as readline from "readline"; import { spawn as spawnCallback } from "cross-spawn"; import * as consumers from "stream/consumers"; import * as zlib from "node:zlib"; import { Readable, Writable } from "node:stream"; import * as validateStream from "./validate-stream.js"; import { default as makeFetchHappenOriginal } from "make-fetch-happen"; import mergeStreams from "@sindresorhus/merge-streams"; let verbosity = 2; const spinnies = new Spinnies(); process.on("unhandledRejection", (error) => { console.error(error); }); let foundErrors; /** * * @param {string} basePath * @param {Object} elmModule * @param {string} path * @param {{ method: string; hostname: string; query: Record<string, string | undefined>; headers: Record<string, string>; host: string; pathname: string; port: number | null; protocol: string; rawUrl: string; }} request * @param {(pattern: string) => void} addBackendTaskWatcher * @param {boolean} hasFsAccess * @returns */ export async function render( portsFile, basePath, elmModule, mode, path, request, addBackendTaskWatcher, hasFsAccess ) { // const { fs, resetInMemoryFs } = require("./request-cache-fs.js")(hasFsAccess); // resetInMemoryFs(); foundErrors = false; // since init/update are never called in pre-renders, and BackendTask.Http is called using pure NodeJS HTTP fetching // we can provide a fake HTTP instead of xhr2 (which is otherwise needed for Elm HTTP requests from Node) global.XMLHttpRequest = {}; const result = await runElmApp( portsFile, basePath, elmModule, mode, path, request, addBackendTaskWatcher ); return result; } /** * @param {Object} elmModule * @returns * @param {string[]} cliOptions * @param {any} portsFile * @param {string} scriptModuleName * @param {string} versionMessage */ export async function runGenerator( cliOptions, portsFile, elmModule, scriptModuleName, versionMessage ) { global.isRunningGenerator = true; // const { fs, resetInMemoryFs } = require("./request-cache-fs.js")(true); // resetInMemoryFs(); foundErrors = false; // since init/update are never called in pre-renders, and BackendTask.Http is called using pure NodeJS HTTP fetching // we can provide a fake HTTP instead of xhr2 (which is otherwise needed for Elm HTTP requests from Node) global.XMLHttpRequest = {}; try { const result = await runGeneratorAppHelp( cliOptions, portsFile, "", elmModule, scriptModuleName, "production", "", versionMessage ); return result; } catch (error) { process.exitCode = 1; console.log(restoreColorSafe(error)); } } /** * @param {string} basePath * @param {Object} elmModule * @param {string} pagePath * @param {string} mode * @returns {Promise<({is404: boolean;} & ({kind: 'json';contentJson: string;} | {kind: 'html';htmlString: string;} | {kind: 'api-response';body: string;}))>} * @param {string[]} cliOptions * @param {any} portsFile * @param {typeof import("fs") | import("memfs").IFs} fs * @param {string} scriptModuleName * @param {string} versionMessage */ function runGeneratorAppHelp( cliOptions, portsFile, basePath, elmModule, scriptModuleName, mode, pagePath, versionMessage ) { const isDevServer = mode !== "build"; let patternsToWatch = new Set(); let app = null; let killApp; // Handle version flag with early return if ( cliOptions.length === 1 && (cliOptions[0] === "--version" || cliOptions[0] === "-v") ) { console.log(versionMessage); return Promise.resolve(); } return new Promise((resolve, reject) => { const isBytes = pagePath.match(/content\.dat\/?$/); app = elmModule.Elm.ScriptMain.init({ flags: { compatibilityKey, argv: ["", `elm-pages run ${scriptModuleName}`, ...cliOptions], versionMessage: versionMessage || "", }, }); killApp = () => { app.ports.toJsPort.unsubscribe(portHandler); app.die(); app = null; // delete require.cache[require.resolve(compiledElmPath)]; }; async function portHandler(/** @type { FromElm } */ newThing) { let fromElm; let contentDatPayload; fromElm = newThing; if (fromElm.command === "log") { console.log(fromElm.value); } else if (fromElm.tag === "ApiResponse") { // Finished successfully process.exit(0); } else if (fromElm.tag === "PageProgress") { const args = fromElm.args[0]; if (isBytes) { resolve({ kind: "bytes", is404: false, contentJson: JSON.stringify({ staticData: args.contentJson, is404: false, }), statusCode: args.statusCode, headers: args.headers, contentDatPayload, }); } else { resolve( outputString(basePath, fromElm, isDevServer, contentDatPayload) ); } } else if (fromElm.tag === "DoHttp") { app.ports.gotBatchSub.send( Object.fromEntries( await Promise.all( fromElm.args[0].map(([requestHash, requestToPerform]) => { if ( requestToPerform.url !== "elm-pages-internal://port" && requestToPerform.url.startsWith("elm-pages-internal://") ) { return runInternalJob( requestHash, app, requestToPerform, patternsToWatch, portsFile ); } else { return runHttpJob( requestHash, portsFile, mode, requestToPerform ); } }) ) ) ); } else if (fromElm.tag === "Errors") { foundErrors = true; spinnies.stopAll(); reject(fromElm.args[0].errorsJson); } else { console.log(fromElm); } } app.ports.toJsPort.subscribe(portHandler); }).finally(() => { try { killApp(); killApp = null; } catch (error) {} }); } /** * @param {string} basePath * @param {Object} elmModule * @param {string} pagePath * @param {string} mode * @param {{ method: string; hostname: string; query: string; headers: Object; host: string; pathname: string; port: string; protocol: string; rawUrl: string; }} request * @param {(pattern: string) => void} addBackendTaskWatcher * @returns {Promise<({is404: boolean} & ( { kind: 'json'; contentJson: string} | { kind: 'html'; htmlString: string } | { kind: 'api-response'; body: string; }) )>} */ function runElmApp( portsFile, basePath, elmModule, mode, pagePath, request, addBackendTaskWatcher ) { const isDevServer = mode !== "build"; let patternsToWatch = new Set(); let app = null; let killApp; return new Promise((resolve, reject) => { const isBytes = pagePath.match(/content\.dat\/?$/); const route = pagePath .replace(/content\.json\/?$/, "") .replace(/content\.dat\/?$/, ""); const modifiedRequest = { ...request, path: route }; app = elmModule.Elm.Main.init({ flags: { mode, compatibilityKey, request: { payload: modifiedRequest, kind: "single-page", jsonOnly: !!isBytes, }, }, }); killApp = () => { app.ports.toJsPort.unsubscribe(portHandler); app.ports.sendPageData.unsubscribe(portHandler); app.die(); app = null; // delete require.cache[require.resolve(compiledElmPath)]; }; async function portHandler(/** @type { FromElm } */ newThing) { let fromElm; let contentDatPayload; if ("oldThing" in newThing) { fromElm = newThing.oldThing; contentDatPayload = newThing.binaryPageData; } else { fromElm = newThing; } if (fromElm.command === "log") { console.log(fromElm.value); } else if (fromElm.tag === "ApiResponse") { const args = fromElm.args[0]; resolve({ kind: "api-response", is404: args.is404, statusCode: args.statusCode, body: args.body, }); } else if (fromElm.tag === "PageProgress") { const args = fromElm.args[0]; if (isBytes) { resolve({ kind: "bytes", is404: false, contentJson: JSON.stringify({ staticData: args.contentJson, is404: false, }), statusCode: args.statusCode, headers: args.headers, contentDatPayload, }); } else { resolve( outputString(basePath, fromElm, isDevServer, contentDatPayload) ); } } else if (fromElm.tag === "DoHttp") { app.ports.gotBatchSub.send( Object.fromEntries( await Promise.all( fromElm.args[0].map(([requestHash, requestToPerform]) => { if ( requestToPerform.url !== "elm-pages-internal://port" && requestToPerform.url.startsWith("elm-pages-internal://") ) { return runInternalJob( requestHash, app, requestToPerform, patternsToWatch, portsFile ); } else { return runHttpJob( requestHash, portsFile, mode, requestToPerform ); } }) ) ) ); } else if (fromElm.tag === "Errors") { foundErrors = true; spinnies.stopAll(); reject(fromElm.args[0].errorsJson); } else { console.log(fromElm); } } app.ports.toJsPort.subscribe(portHandler); app.ports.sendPageData.subscribe(portHandler); }).finally(() => { addBackendTaskWatcher(patternsToWatch); try { killApp(); killApp = null; } catch (error) {} }); } /** * @param {string} basePath * @param {PageProgress} fromElm * @param {boolean} isDevServer */ async function outputString( basePath, /** @type { PageProgress } */ fromElm, isDevServer, contentDatPayload ) { const args = fromElm.args[0]; let contentJson = {}; contentJson["staticData"] = args.contentJson; contentJson["is404"] = args.is404; contentJson["path"] = args.route; contentJson["statusCode"] = args.statusCode; contentJson["headers"] = args.headers; const normalizedRoute = args.route.replace(/index$/, ""); return { is404: args.is404, route: normalizedRoute, htmlString: preRenderHtml.wrapHtml(basePath, args, contentDatPayload), contentJson: args.contentJson, statusCode: args.statusCode, headers: args.headers, kind: "html", contentDatPayload, }; } /** @typedef { { route : string; contentJson : string; head : SeoTag[]; html: string; } } FromElm */ /** @typedef {HeadTag | JsonLdTag} SeoTag */ /** @typedef {{ name: string; attributes: string[][]; type: 'head' }} HeadTag */ /** @typedef {{ contents: Object; type: 'json-ld' }} JsonLdTag */ /** @typedef { { tag : 'PageProgress'; args : Arg[] } } PageProgress */ /** @typedef { { head: any[]; errors: any[]; contentJson: any[]; html: string; route: string; title: string; } } Arg */ async function runHttpJob(requestHash, portsFile, mode, requestToPerform) { try { const lookupResponse = await lookupOrPerform( portsFile, mode, requestToPerform ); if (lookupResponse.kind === "cache-response-path") { const responseFilePath = lookupResponse.value; return [ requestHash, { request: requestToPerform, response: JSON.parse( (await fs.promises.readFile(responseFilePath, "utf8")).toString() ), }, ]; } else if (lookupResponse.kind === "response-json") { return [ requestHash, { request: requestToPerform, response: lookupResponse.value, }, ]; } else { throw `Unexpected kind ${lookupResponse}`; } } catch (error) { console.log("@@@ERROR", error); // sendError(app, error); } } function stringResponse(request, string) { return { request, response: { bodyKind: "string", body: string }, }; } function jsonResponse(request, json) { return { request, response: { bodyKind: "json", body: json }, }; } /** * @param {any} request * @param {WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>} buffer */ function bytesResponse(request, buffer) { return { request, response: { bodyKind: "bytes", body: Buffer.from(buffer).toString("base64"), }, }; } /** * @param {{ url: string; body: { args: any[] } }} requestToPerform */ async function runInternalJob( requestHash, app, requestToPerform, patternsToWatch, portsFile ) { try { switch (requestToPerform.url) { case "elm-pages-internal://log": return [requestHash, await runLogJob(requestToPerform)]; case "elm-pages-internal://read-file": return [ requestHash, await readFileJobNew(requestToPerform, patternsToWatch), ]; case "elm-pages-internal://read-file-binary": return [ requestHash, await readFileBinaryJobNew(requestToPerform, patternsToWatch), ]; case "elm-pages-internal://glob": return [ requestHash, await runGlobNew(requestToPerform, patternsToWatch), ]; case "elm-pages-internal://randomSeed": return [ requestHash, jsonResponse( requestToPerform, crypto.getRandomValues(new Uint32Array(1))[0] ), ]; case "elm-pages-internal://now": return [requestHash, jsonResponse(requestToPerform, Date.now())]; case "elm-pages-internal://env": return [ requestHash, await runEnvJob(requestToPerform, patternsToWatch), ]; case "elm-pages-internal://encrypt": return [ requestHash, await runEncryptJob(requestToPerform, patternsToWatch), ]; case "elm-pages-internal://decrypt": return [ requestHash, await runDecryptJob(requestToPerform, patternsToWatch), ]; case "elm-pages-internal://write-file": return [requestHash, await runWriteFileJob(requestToPerform)]; case "elm-pages-internal://sleep": return [requestHash, await runSleep(requestToPerform)]; case "elm-pages-internal://which": return [requestHash, await runWhich(requestToPerform)]; case "elm-pages-internal://question": return [requestHash, await runQuestion(requestToPerform)]; case "elm-pages-internal://shell": return [requestHash, await runShell(requestToPerform)]; case "elm-pages-internal://stream": return [requestHash, await runStream(requestToPerform, portsFile)]; case "elm-pages-internal://start-spinner": return [requestHash, runStartSpinner(requestToPerform)]; case "elm-pages-internal://stop-spinner": return [requestHash, runStopSpinner(requestToPerform)]; default: throw `Unexpected internal BackendTask request format: ${kleur.yellow( JSON.stringify(2, null, requestToPerform) )}`; } } catch (error) { sendError(app, error); } } /** * @param {{ dir: string[]; quiet: boolean; env: { [key:string]: string; }; }} requestToPerform */ function getContext(requestToPerform) { const cwd = path.resolve(...requestToPerform.dir); const quiet = requestToPerform.quiet; const env = { ...process.env, ...requestToPerform.env }; return { cwd, quiet, env }; } async function readFileJobNew(req, patternsToWatch) { const { cwd } = getContext(req); // TODO use cwd const filePath = path.resolve(cwd, req.body.args[1]); try { patternsToWatch.add(filePath); const fileContents = (await fsPromises.readFile(filePath)).toString(); // TODO does this throw an error if there is invalid frontmatter? const parsedFile = matter(fileContents); return jsonResponse(req, { parsedFrontmatter: parsedFile.data, withoutFrontmatter: parsedFile.content, rawFile: fileContents, }); } catch (error) { return jsonResponse(req, { errorCode: error.code, }); } } /** * @param {{ url: string; body: { args: any[] } }} req * @param {{ add: (arg0: string) => void; }} patternsToWatch */ async function readFileBinaryJobNew(req, patternsToWatch) { const filePath = req.body.args[1]; try { patternsToWatch.add(filePath); const fileContents = await fsPromises.readFile(filePath); // It's safe to use allocUnsafe here because we're going to overwrite it immediately anyway const buffer = new Uint8Array(4 + fileContents.length); const view = new DataView( buffer.buffer, buffer.byteOffset, buffer.byteLength ); view.setInt32(0, fileContents.length); fileContents.copy(buffer, 4); return bytesResponse(req, buffer); } catch (error) { const buffer = new Int32Array(1); buffer[0] = -1; return bytesResponse(req, buffer); } } function runSleep(req) { const { milliseconds } = req.body.args[0]; return new Promise((resolve) => { setTimeout(() => { resolve(jsonResponse(req, null)); }, milliseconds); }); } async function runWhich(req) { const command = req.body.args[0]; try { return jsonResponse(req, await which(command)); } catch (error) { return jsonResponse(req, null); } } async function runQuestion(req) { return jsonResponse(req, await question(req.body.args[0])); } function runStream(req, portsFile) { return new Promise(async (resolve) => { const context = getContext(req); let metadataResponse = null; let lastStream = null; try { const kind = req.body.args[0].kind; const parts = req.body.args[0].parts; let index = 0; for (const part of parts) { let isLastProcess = index === parts.length - 1; let thisStream; const { stream, metadata } = await pipePartToStream( lastStream, part, context, portsFile, (value) => resolve(jsonResponse(req, value)), isLastProcess, kind ); metadataResponse = metadata; thisStream = stream; lastStream = thisStream; index += 1; } if (kind === "json") { resolve( jsonResponse(req, { body: await consumers.json(lastStream), metadata: await tryCallingFunction(metadataResponse), }) ); } else if (kind === "text") { resolve( jsonResponse(req, { body: await consumers.text(lastStream), metadata: await tryCallingFunction(metadataResponse), }) ); } else if (kind === "none") { if (!lastStream) { // ensure all error handling gets a chance to fire before resolving successfully await tryCallingFunction(metadataResponse); resolve(jsonResponse(req, { body: null })); } else { let resolvedMeta = await tryCallingFunction(metadataResponse); lastStream.once("finish", async () => { resolve( jsonResponse(req, { body: null, metadata: resolvedMeta, }) ); }); lastStream.once("end", async () => { resolve( jsonResponse(req, { body: null, metadata: resolvedMeta, }) ); }); } } else if (kind === "command") { // already handled in parts.forEach } /** * * @param {import('node:stream').Stream?} lastStream * @param {{ name: string }} part * @param {{cwd: string, quiet: boolean, env: object}} param2 * @returns {Promise<{stream: import('node:stream').Stream, metadata?: any}>} */ async function pipePartToStream( lastStream, part, { cwd, quiet, env }, portsFile, resolve, isLastProcess, kind ) { if (verbosity > 1 && !quiet) { } if (part.name === "stdout") { return { stream: pipeIfPossible(lastStream, stdout()) }; } else if (part.name === "stderr") { return { stream: pipeIfPossible(lastStream, stderr()) }; } else if (part.name === "stdin") { return { stream: process.stdin }; } else if (part.name === "fileRead") { const newLocal = fs.createReadStream(path.resolve(cwd, part.path)); newLocal.once("error", (error) => { newLocal.close(); resolve({ error: error.toString() }); }); return { stream: newLocal }; } else if (part.name === "customDuplex") { const newLocal = await portsFile[part.portName](part.input, { cwd, quiet, env, }); if (validateStream.isDuplexStream(newLocal.stream)) { pipeIfPossible(lastStream, newLocal.stream); return newLocal; } else { throw `Expected '${part.portName}' to be a duplex stream!`; } } else if (part.name === "customRead") { return { metadata: null, stream: await portsFile[part.portName](part.input, { cwd, quiet, env, }), }; } else if (part.name === "customWrite") { const newLocal = await portsFile[part.portName](part.input, { cwd, quiet, env, }); if (!validateStream.isWritableStream(newLocal.stream)) { console.error("Expected a writable stream!"); resolve({ error: "Expected a writable stream!" }); } else { pipeIfPossible(lastStream, newLocal.stream); } return newLocal; } else if (part.name === "gzip") { const gzip = zlib.createGzip(); if (!lastStream) { gzip.end(); } return { metadata: null, stream: pipeIfPossible(lastStream, gzip), }; } else if (part.name === "unzip") { return { metadata: null, stream: pipeIfPossible(lastStream, zlib.createUnzip()), }; } else if (part.name === "fileWrite") { const destinationPath = path.resolve(part.path); try { await fsPromises.mkdir(path.dirname(destinationPath), { recursive: true, }); } catch (error) { resolve({ error: error.toString() }); } const newLocal = fs.createWriteStream(destinationPath); newLocal.once("error", (error) => { newLocal.close(); newLocal.removeAllListeners(); resolve({ error: error.toString() }); }); return { metadata: null, stream: pipeIfPossible(lastStream, newLocal), }; } else if (part.name === "httpWrite") { const makeFetchHappen = makeFetchHappenOriginal.defaults({ // cache: mode === "build" ? "no-cache" : "default", cache: "default", }); const response = await makeFetchHappen(part.url, { body: lastStream, duplex: "half", redirect: "follow", method: part.method, headers: part.headers, retry: part.retries, timeout: part.timeoutInMs, }); if (!isLastProcess && !response.ok) { resolve({ error: `HTTP request failed: ${response.status} ${response.statusText}`, }); } else { let metadata = () => { return { headers: Object.fromEntries(response.headers.entries()), statusCode: response.status, // bodyKind, url: response.url, statusText: response.statusText, }; }; return { metadata, stream: response.body }; } } else if (part.name === "command") { const { command, args, allowNon0Status, output } = part; /** @type {'ignore' | 'inherit'} } */ let letPrint = quiet ? "ignore" : "inherit"; let stderrKind = kind === "none" && isLastProcess ? letPrint : "pipe"; if (output === "Ignore") { stderrKind = "ignore"; } else if (output === "Print") { stderrKind = letPrint; } const stdoutKind = (output === "InsteadOfStdout" || kind === "none") && isLastProcess ? letPrint : "pipe"; /** * @type {import('node:child_process').ChildProcess} */ const newProcess = spawnCallback(command, args, { stdio: [ "pipe", // if we are capturing stderr instead of stdout, print out stdout with `inherit` stdoutKind, stderrKind, ], cwd: cwd, env: env, }); pipeIfPossible(lastStream, newProcess.stdin); let newStream; if (output === "MergeWithStdout") { newStream = mergeStreams([newProcess.stdout, newProcess.stderr]); } else if (output === "InsteadOfStdout") { newStream = newProcess.stderr; } else { newStream = newProcess.stdout; } newProcess.once("error", (error) => { newStream && newStream.end(); newProcess.removeAllListeners(); resolve({ error: error.toString() }); }); if (isLastProcess) { return { stream: newStream, metadata: new Promise((resoveMeta) => { newProcess.once("exit", (code) => { if (code !== 0 && !allowNon0Status) { newStream && newStream.end(); resolve({ error: `Command ${command} exited with code ${code}`, }); } resoveMeta({ exitCode: code, }); }); }), }; } else { return { metadata: null, stream: newStream }; } } else if (part.name === "fromString") { return { stream: Readable.from([part.string]), metadata: null }; } else { // console.error(`Unknown stream part: ${part.name}!`); // process.exit(1); throw `Unknown stream part: ${part.name}!`; } } } catch (error) { if (lastStream) { lastStream.destroy(); } resolve(jsonResponse(req, { error: error.toString() })); } }); } /** * @param { import('stream').Stream? } input * @param {import('stream').Writable | import('stream').Duplex} destination */ function pipeIfPossible(input, destination) { if (input) { return input.pipe(destination); } else { return destination; } } function stdout() { return new Writable({ write(chunk, encoding, callback) { process.stdout.write(chunk, callback); }, }); } function stderr() { return new Writable({ write(chunk, encoding, callback) { process.stderr.write(chunk, callback); }, }); } async function tryCallingFunction(func) { if (func) { // if is promise if (func.then) { return await func; } // if is function else if (typeof func === "function") { return await func(); } } else { return func; } } async function runShell(req) { const cwd = path.resolve(...req.dir); const quiet = req.quiet; const env = { ...process.env, ...req.env }; const captureOutput = req.body.args[0].captureOutput; if (req.body.args[0].commands.length === 1) { return jsonResponse( req, await shell({ cwd, quiet, env, captureOutput }, req.body.args[0]) ); } else { return jsonResponse( req, await pipeShells({ cwd, quiet, env, captureOutput }, req.body.args[0]) ); } } function commandAndArgsToString(cwd, commandsAndArgs) { return ( `$ ` + commandsAndArgs.commands .map((commandAndArgs) => { return [commandAndArgs.command, ...commandAndArgs.args].join(" "); }) .join(" | ") ); } export function shell({ cwd, quiet, env, captureOutput }, commandAndArgs) { return new Promise((resolve, reject) => { const command = commandAndArgs.commands[0].command; const args = commandAndArgs.commands[0].args; if (verbosity > 1 && !quiet) { console.log(commandAndArgsToString(cwd, commandAndArgs)); } if (!captureOutput && !quiet) { const subprocess = spawnCallback(command, args, { stdio: quiet ? ["inherit", "ignore", "ignore"] : ["inherit", "inherit", "inherit"], cwd: cwd, env: env, }); subprocess.on("close", async (code) => { resolve({ output: "", errorCode: code, stderrOutput: "", stdoutOutput: "", }); }); } else { const subprocess = spawnCallback(command, args, { stdio: ["pipe", "pipe", "pipe"], cwd: cwd, env: env, }); let commandOutput = ""; let stderrOutput = ""; let stdoutOutput = ""; if (verbosity > 0 && !quiet) { subprocess.stdout.pipe(process.stdout); subprocess.stderr.pipe(process.stderr); } subprocess.stderr.on("data", function (data) { commandOutput += data; stderrOutput += data; }); subprocess.stdout.on("data", function (data) { commandOutput += data; stdoutOutput += data; }); subprocess.on("close", async (code) => { resolve({ output: commandOutput, errorCode: code, stderrOutput, stdoutOutput, }); }); } }); } /** * @typedef {{ command: string, args: string[], timeout: number? }} ElmCommand */ /** * @param {{ commands: ElmCommand[] }} commandsAndArgs */ export function pipeShells( { cwd, quiet, env, captureOutput }, commandsAndArgs ) { return new Promise((resolve, reject) => { if (verbosity > 1 && !quiet) { console.log(commandAndArgsToString(cwd, commandsAndArgs)); } /** * @type {null | import('node:child_process').ChildProcess} */ let previousProcess = null; let currentProcess = null; commandsAndArgs.commands.forEach(({ command, args, timeout }, index) => { let isLastProcess = index === commandsAndArgs.commands.length - 1; /** * @type {import('node:child_process').ChildProcess} */ if (previousProcess === null) { currentProcess = spawnCallback(command, args, { stdio: ["inherit", "pipe", "inherit"], timeout: timeout ? undefined : timeout, cwd: cwd, env: env, }); } else { if (isLastProcess && !captureOutput && false) { currentProcess = spawnCallback(command, args, { stdio: quiet ? ["pipe", "ignore", "ignore"] : ["pipe", "inherit", "inherit"], timeout: timeout ? undefined : timeout, cwd: cwd, env: env, }); } else { currentProcess = spawnCallback(command, args, { stdio: ["pipe", "pipe", "pipe"], timeout: timeout ? undefined : timeout, cwd: cwd, env: env, }); } previousProcess.stdout.pipe(currentProcess.stdin); } previousProcess = currentProcess; }); if (currentProcess === null) { reject(""); } else { let commandOutput = ""; let stderrOutput = ""; let stdoutOutput = ""; if (verbosity > 0 && !quiet) { currentProcess.stdout && currentProcess.stdout.pipe(process.stdout); currentProcess.stderr && currentProcess.stderr.pipe(process.stderr); } currentProcess.stderr && currentProcess.stderr.on("data", function (data) { commandOutput += data; stderrOutput += data; }); currentProcess.stdout && currentProcess.stdout.on("data", function (data) { commandOutput += data; stdoutOutput += data; }); currentProcess.on("close", async (code) => { resolve({ output: commandOutput, errorCode: code, stderrOutput, stdoutOutput, }); }); } }); } export async function question({ prompt }) { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return rl.question(prompt, (answer) => { rl.close(); resolve(answer); }); }); } async function runWriteFileJob(req) { const { cwd } = getContext(req); const data = req.body.args[0]; const filePath = path.resolve(cwd, data.path); try { await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); await fsPromises.writeFile(filePath, data.body); return jsonResponse(req, null); } catch (error) { console.trace(error); throw { title: "BackendTask Error", message: `BackendTask.Generator.writeFile failed for file path: ${kleur.yellow( filePath )}\n${kleur.red(error.toString())}`, }; } } function runStartSpinner(req) { const data = req.body.args[0]; let spinnerId; if (data.spinnerId) { spinnerId = data.spinnerId; // TODO use updateSpinnerState? spinnies.update(spinnerId, { text: data.text, status: "spinning" }); } else { spinnerId = Math.random().toString(36); // spinnies.add(spinnerId, { text: data.text, status: data.immediateStart ? 'spinning' : 'stopped' }); spinnies.add(spinnerId, { text: data.text, status: "spinning" }); // } } return jsonResponse(req, spinnerId); } function runStopSpinner(req) { const data = req.body.args[0]; const { spinnerId, completionText, completionFn } = data; let completeFn; if (completionFn === "succeed") { spinnies.succeed(spinnerId, { text: completionText }); } else if (completionFn === "fail") { spinnies.fail(spinnerId, { text: completionText }); } else { console.log("Unexpected"); } return jsonResponse(req, null); } async function runGlobNew(req, patternsToWatch) { try { const { pattern, options } = req.body.args[0]; const cwd = path.resolve(...req.dir); const matchedPaths = await globby.globby(pattern, { ...options, stats: true, cwd, }); patternsToWatch.add(pattern); return jsonResponse( req, matchedPaths.map((fullPath) => { const stats = fullPath.stats; if (!stats) { return null; } return { fullPath: fullPath.path, captures: mm.capture(pattern, fullPath.path), fileStats: { size: stats.size, atime: Math.round(stats.atime.getTime()), mtime: Math.round(stats.mtime.getTime()), ctime: Math.round(stats.ctime.getTime()), birthtime: Math.round(stats.birthtime.getTime()), fullPath: fullPath.path, isDirectory: stats.isDirectory(), }, }; }) ); } catch (e) { console.log(`Error performing glob '${JSON.stringify(req.body)}'`); throw e; } } async function runLogJob(req) { try { console.log(req.body.args[0].message); return jsonResponse(req, null); } catch (e) { console.log(`Error performing env '${JSON.stringify(req.body)}'`); throw e; } } async function runEnvJob(req, patternsToWatch) { try { const expectedEnv = req.body.args[0]; return jsonResponse(req, process.env[expectedEnv] || null); } catch (e) { console.log(`Error performing env '${JSON.stringify(req.body)}'`); throw e; } } async function runEncryptJob(req, patternsToWatch) { try { return jsonResponse( req, cookie.sign( JSON.stringify(req.body.args[0].values, null, 0), req.body.args[0].secret ) ); } catch (e) { throw { title: "BackendTask Encrypt Error", message: e.toString() + e.stack + "\n\n" + JSON.stringify(rawRequest, null, 2), }; } } async function runDecryptJob(req, patternsToWatch) { try { // TODO if unsign returns `false`, need to have an `Err` in Elm because decryption failed const signed = tryDecodeCookie( req.body.args[0].input, req.body.args[0].secrets ); return jsonResponse(req, JSON.parse(signed || "null")); } catch (e) { throw { title: "BackendTask Decrypt Error", message: e.toString() + e.stack + "\n\n" + JSON.stringify(rawRequest, null, 2), }; } } /** * @param {{ ports: { fromJsPort: { send: (arg0: { tag: string; data: any; }) => void; }; }; }} app * @param {{ message: string; title: string; }} error */ function sendError(app, error) { foundErrors = true; app.ports.fromJsPort.send({ tag: "BuildError", data: error, }); } function tryDecodeCookie(input, secrets) { if (secrets.length > 0) { const signed = cookie.unsign(input, secrets[0]); if (signed) { return signed; } else { return tryDecodeCookie(input, secrets.slice(1)); } } else { return null; } }