UNPKG

elm-pages

Version:

Hybrid Elm framework with full-stack and static routes.

366 lines (349 loc) 10.8 kB
import * as path from "path"; import * as fsPromises from "fs/promises"; import * as kleur from "kleur/colors"; import { default as makeFetchHappenOriginal } from "make-fetch-happen"; const defaultHttpCachePath = "./.elm-pages/http-cache"; /** @typedef {{kind: 'cache-response-path', value: string} | {kind: 'response-json', value: JSON}} Response */ /** * @param {string} mode * @param {{url: string;headers: {[x: string]: string;};method: string;body: Body; }} rawRequest * @param {Record<string, unknown>} portsFile * @returns {Promise<Response>} */ export function lookupOrPerform( portsFile, mode, rawRequest ) { const uniqueTimeId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); const timeStart = (message) => { !rawRequest.quiet && console.time(`${message} ${uniqueTimeId}`); }; const timeEnd = (message) => { !rawRequest.quiet && console.timeEnd(`${message} ${uniqueTimeId}`); }; const makeFetchHappen = makeFetchHappenOriginal.defaults({ cache: mode === "build" ? "no-cache" : "default", }); return new Promise(async (resolve, reject) => { const request = toRequest(rawRequest); let portBackendTask = portsFile; let portBackendTaskImportError = null; try { if (portsFile === undefined) { throw "missing"; } } catch (e) { portBackendTaskImportError = e; } if (request.url === "elm-pages-internal://port") { try { const { input, portName } = rawRequest.body.args[0]; if (portBackendTask === null) { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "MissingCustomBackendTaskFile", }), }); } else if (portBackendTask && portBackendTask.__internalElmPagesError) { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "ErrorInCustomBackendTaskFile", error: portBackendTask.__internalElmPagesError, }), }); } else if (portBackendTask && !portBackendTask[portName]) { if (portBackendTaskImportError === null) { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "CustomBackendTaskNotDefined", }), }); } else if (portBackendTaskImportError === "missing") { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "MissingCustomBackendTaskFile", }), }); } else { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "ErrorInCustomBackendTaskFile", error: (portBackendTaskImportError && portBackendTaskImportError.stack) || "", }), }); } } else if (typeof portBackendTask[portName] !== "function") { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "ExportIsNotFunction", error: typeof portBackendTask[portName], }), }); } else { timeStart(`BackendTask.Custom.run "${portName}"`); let context = { cwd: path.resolve(...rawRequest.dir), quiet: rawRequest.quiet, env: { ...process.env, ...rawRequest.env }, }; try { resolve({ kind: "response-json", value: jsonResponse( toElmJson(await portBackendTask[portName](input, context)) ), }); } catch (portCallError) { if (portCallError instanceof Error) { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "NonJsonException", error: portCallError.message, stack: portCallError.stack || null, }), }); } try { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "CustomBackendTaskException", error: JSON.parse(JSON.stringify(portCallError, null, 0)), }), }); } catch (jsonDecodeError) { resolve({ kind: "response-json", value: jsonResponse({ "elm-pages-internal-error": "NonJsonException", error: portCallError.toString(), }), }); } } timeEnd(`BackendTask.Custom.run "${portName}"`); } } catch (error) { console.trace(error); reject({ title: "BackendTask.Custom Error", message: error.toString(), }); } } else { try { timeStart(`fetch ${request.url}`); const response = await safeFetch(makeFetchHappen, request.url, { method: request.method, body: request.body, headers: { "User-Agent": "request", ...request.headers, }, ...rawRequest.cacheOptions, }); timeEnd(`fetch ${request.url}`); const expectString = request.headers["elm-pages-internal"]; let body; let bodyKind; if (expectString === "ExpectJson") { try { body = await response.buffer(); body = JSON.parse(body.toString("utf-8")); bodyKind = "json"; } catch (error) { body = body.toString("utf8"); bodyKind = "string"; } } else if ( expectString === "ExpectBytes" || expectString === "ExpectBytesResponse" ) { body = await response.buffer(); try { body = body.toString("base64"); bodyKind = "bytes"; } catch (e) { body = body.toString("utf8"); bodyKind = "string"; } } else if (expectString === "ExpectWhatever") { bodyKind = "whatever"; body = null; } else if ( expectString === "ExpectResponse" || expectString === "ExpectString" ) { bodyKind = "string"; body = await response.text(); } else { throw `Unexpected expectString ${expectString}`; } resolve({ kind: "response-json", value: { headers: Object.fromEntries(response.headers.entries()), statusCode: response.status, body, bodyKind, url: response.url, statusText: response.statusText, }, }); } catch (error) { if (error.code === "ECONNREFUSED") { resolve({ kind: "response-json", value: { "elm-pages-internal-error": "NetworkError" }, }); } else if ( error.code === "ETIMEDOUT" || error.code === "ERR_SOCKET_TIMEOUT" ) { resolve({ kind: "response-json", value: { "elm-pages-internal-error": "Timeout" }, }); } else { console.trace("elm-pages unhandled HTTP error", error); resolve({ kind: "response-json", value: { "elm-pages-internal-error": "NetworkError" }, }); } } } }); } /** * @param {unknown} obj * @returns {JSON} */ function toElmJson(obj) { if (Array.isArray(obj)) { return obj.map(toElmJson); } else if (typeof obj === "object") { for (let key in obj) { const value = obj[key]; if (typeof value === "undefined") { obj[key] = null; } else if (value instanceof Date) { obj[key] = { "__elm-pages-normalized__": { kind: "Date", value: Math.floor(value.getTime()), }, }; // } else if (value instanceof Object) { // toElmJson(obj); } } } return obj; } /** * @param {{url: string; headers: {[x: string]: string}; method: string; body: Body } } elmRequest */ function toRequest(elmRequest) { const elmHeaders = Object.fromEntries(elmRequest.headers); let contentType = toContentType(elmRequest.body); let headers = { ...contentType, ...elmHeaders }; return { url: elmRequest.url, method: elmRequest.method, headers, body: toBody(elmRequest.body), }; } /** * @param {Body} body */ function toBody(body) { switch (body.tag) { case "EmptyBody": { return null; } case "StringBody": { return body.args[1]; } case "BytesBody": { return Buffer.from(body.args[1], "base64"); } case "JsonBody": { return JSON.stringify(body.args[0]); } } } /** * @param {Body} body * @returns Object */ function toContentType(body) { switch (body.tag) { case "EmptyBody": { return {}; } case "StringBody": { return { "Content-Type": body.args[0] }; } case "BytesBody": { return { "Content-Type": body.args[0] }; } case "JsonBody": { return { "Content-Type": "application/json" }; } } } /** @typedef { { tag: 'EmptyBody'} |{ tag: 'BytesBody'; args: [string, string] } | { tag: 'StringBody'; args: [string, string] } | {tag: 'JsonBody'; args: [ Object ] } } Body */ function requireUncached(mode, filePath) { if (mode === "dev-server") { // for the build command, we can skip clearing the cache because it won't change while the build is running // in the dev server, we want to clear the cache to get a the latest code each time it runs delete require.cache[require.resolve(filePath)]; } return require(filePath); } /** * @param {unknown} json */ function jsonResponse(json) { return { bodyKind: "json", body: json }; } async function safeFetch(makeFetchHappen, url, options) { const { cachePath, ...optionsWithoutCachePath } = options; const cachePathWithDefault = cachePath || defaultHttpCachePath; if (await canAccess(cachePathWithDefault)) { return await makeFetchHappen(url, { cachePath: cachePathWithDefault, ...options, }); } else { return await makeFetchHappen(url, { cache: "no-store", ...optionsWithoutCachePath, }); } } async function canAccess(filePath) { try { await fsPromises.access( filePath, fsPromises.constants.R_OK | fsPromises.constants.W_OK ); return true; } catch { return false; } }