libmodulor
Version:
A TypeScript library to create platform-agnostic applications
272 lines (271 loc) • 8.82 kB
JavaScript
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { CustomError } from '../../../error/index.js';
import { ucTransportType, } from '../../../uc/index.js';
import { defaultStreamOnClose, fmtSingleDataMsg, fmtSSEError, isError, SSE_HEADERS, } from '../../../utils/index.js';
import { DEFAULT_RES_HEADERS } from './consts.js';
export function assertIncomingMessageEnhanced(req) {
if (!('bodyRaw' in req)) {
throw new Error('IncomingMessage not enhanced');
}
}
export function buildHandler(appManifest, ucd, contract, serverRequestHandler, ucManager) {
const { envelope } = contract.http;
const handler = async (req, res) => {
const transportType = ucTransportType(ucd.ext);
let execOpts;
switch (transportType) {
case 'standard':
// Nothing to do
break;
case 'stream': {
let streamedOnce = false;
execOpts = {
stream: {
onClose: defaultStreamOnClose(streamedOnce),
onData: async (output) => {
streamedOnce = true;
if (!output) {
return;
}
res.write(fmtSingleDataMsg(output));
},
onDone: async () => {
res.end();
},
},
};
for (const [k, v] of SSE_HEADERS) {
res.setHeader(k, v);
}
res.flushHeaders();
res.on('close', async () => {
await execOpts?.stream?.onClose();
});
break;
}
default:
transportType;
}
const { body, status } = await serverRequestHandler.exec({
appManifest,
envelope,
execOpts,
req: toReq(req),
res: toRes(res),
ucd,
ucManager,
});
if (!body) {
res.writeHead(status, DEFAULT_RES_HEADERS).end();
return;
}
switch (transportType) {
case 'standard':
res.writeHead(status, DEFAULT_RES_HEADERS).end(buildRes(body));
return;
case 'stream': {
if (isError(status)) {
res.end(fmtSSEError({
message: body.message,
status,
}));
}
return;
}
default:
transportType;
}
};
return handler;
}
export function buildRes(res) {
if (res instanceof CustomError) {
return JSON.stringify(res.toObj());
}
if (typeof res === 'string') {
return res;
}
return JSON.stringify(res);
}
export async function enhanceIncomingMessage(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(Buffer.from(chunk));
}
req.bodyRaw =
Buffer.concat(chunks).toString('utf-8');
// @ts-expect-error : not typed correctly (the field is present only when https, but https doesn't expose a dedicated IncomingRequest)
const secure = req.socket.encrypted !== undefined;
const protocol = secure ? 'https' : 'http';
req.fullURL = new URL(`${protocol}://${req.headers.host ?? 'localhost'}${req.url}`);
req.secure = secure;
}
export function init() {
// TODO : Setup some middlewares if possible
}
export function mountHandler(contract, router, handler) {
const { method, path, pathAliases } = contract.http;
const keys = routeKeys(method, [path, ...pathAliases]);
for (const routeKey of keys) {
router[routeKey] = handler;
}
}
export async function mountStaticDir(dirPath, router) {
// This is obviously very hacky.
// TODO : Improve static dir mounting
const publicFiles = await readPublicFiles(dirPath);
for (const f of publicFiles) {
const handler = async (_req, res) => {
let contentType = 'text/html';
if (f.endsWith('.js')) {
contentType = 'text/javascript';
}
else if (f.endsWith('.png')) {
contentType = 'image/png';
}
res.writeHead(200, {
...DEFAULT_RES_HEADERS,
'Content-Type': contentType,
}).end(await readFile(f));
};
const path = f.replace(dirPath, '');
router[`GET_${path}`] = handler;
if (path === '/index.html') {
router['GET_/'] = handler;
}
}
}
export function routeKey(method, path) {
return `${method}_${path}`;
}
export function routeKeys(method, paths) {
return paths.map((p) => routeKey(method, p));
}
export function toReq(req) {
const cookies = readCookies(req);
return {
bodyFromFormData: async () => readFormData(req),
bodyFromJSON: async () => (req.bodyRaw ? JSON.parse(req.bodyRaw) : {}),
bodyFromQueryParams: async () => readQueryParams(req),
bodyRaw: req.bodyRaw ?? null,
cookie: async (name) => cookies[name],
header: async (name) => {
const h = req.headers[name.toLowerCase()];
if (Array.isArray(h)) {
return h[0];
}
return h;
},
method: req.method,
secure: req.secure ?? false,
url: req.fullURL?.toString(),
};
}
export function toRes(res) {
return {
clearCookie: async (name) => {
res.appendHeader('Set-Cookie', `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict`);
},
redirect: async (location) => {
res.writeHead(302, {
Location: location,
}).end();
},
setCookie: async ({ name, opts, val }) => {
res.appendHeader('Set-Cookie', serializeCookie(name, val, opts));
},
};
}
function readCookies(req) {
const header = req.headers.cookie;
const cookies = {};
if (!header) {
return cookies;
}
for (const cookie of header.split(';')) {
const [name, ...rest] = cookie.trim().split('=');
if (!name) {
continue;
}
cookies[name] = decodeURIComponent(rest.join('='));
}
return cookies;
}
async function readFormData(req) {
const { bodyRaw } = req;
if (!bodyRaw) {
return {};
}
const type = req.headers['content-type'] ?? '';
const boundary = `--${type.split('boundary=')[1]}`;
const result = {};
for (const part of bodyRaw.split(boundary).slice(1, -1)) {
const [head, value] = part.split('\r\n\r\n');
if (!head || !value) {
continue;
}
let name = head.match(/name="([^"]+)"/)?.[1];
if (!name) {
continue;
}
const multiple = name.endsWith('[]');
if (multiple) {
name = name.slice(0, -2);
}
const filename = head.match(/filename="([^"]*)"/)?.[1];
let file;
if (filename) {
const type = head.match(/Content-Type:\s*(.+)/)?.[1] ??
'application/octet-stream';
file = new File([Buffer.from(value.slice(0, -2))], filename, {
type,
});
}
const data = file ?? value.slice(0, -2);
if (multiple) {
if (Array.isArray(result[name])) {
result[name].push(data);
}
else {
result[name] = [data];
}
}
else {
result[name] = data;
}
}
return result;
}
async function readPublicFiles(dirPath) {
const entries = await readdir(dirPath, {
withFileTypes: true,
});
const files = await Promise.all(entries.map((entry) => {
const fullPath = join(dirPath, entry.name);
return entry.isDirectory() ? readPublicFiles(fullPath) : fullPath;
}));
return files.flat();
}
function readQueryParams(req) {
if (!req.fullURL) {
return {};
}
return Object.fromEntries(req.fullURL.searchParams.entries());
}
function serializeCookie(name, value, options) {
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
if (options.expires) {
cookie += `; Expires=${options.expires.toUTCString()}`;
}
if (options.httpOnly) {
cookie += '; HttpOnly';
}
if (options.secure) {
cookie += '; Secure';
}
if (options.sameSite) {
cookie += `; SameSite=${options.sameSite}`;
}
return cookie;
}