diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/api.mjs (renamed from src/api.js) | 12 | ||||
-rwxr-xr-x | src/bin.mjs | 4 | ||||
-rwxr-xr-x | src/cli | 4 | ||||
-rw-r--r-- | src/db.mjs (renamed from src/db.js) | 18 | ||||
-rw-r--r-- | src/escape.mjs | 14 | ||||
-rw-r--r-- | src/hero.mjs | 220 | ||||
-rw-r--r-- | src/ircd.mjs (renamed from src/ircd.js) | 10 | ||||
-rw-r--r-- | src/package.json | 3 | ||||
-rw-r--r-- | src/utils.mjs (renamed from src/utils.js) | 37 | ||||
-rw-r--r-- | src/web.mjs (renamed from src/web.js) | 8 |
10 files changed, 286 insertions, 44 deletions
@@ -1,8 +1,8 @@ -const { eq } = require("./utils.js"); -const ircd = require("./ircd.js"); -const web = require("./web.js"); +import { eq } from "./utils.mjs"; +import * as ircd from "./ircd.mjs"; +import * as web from "./web.mjs"; -const main = async () => { +export const main = async () => { if (process.argv.length === 3 && process.argv[2] === "-V") { console.log("papo 1970-01-01 0.1.0"); return; @@ -22,7 +22,3 @@ const main = async () => { argv: process.argv, }); }; - -module.exports = { - main, -}; diff --git a/src/bin.mjs b/src/bin.mjs new file mode 100755 index 0000000..5dbbb25 --- /dev/null +++ b/src/bin.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { main } from "./api.mjs"; + +main(); diff --git a/src/cli b/src/cli deleted file mode 100755 index 1de3383..0000000 --- a/src/cli +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -const { main } = require("papo"); - -main(); @@ -1,15 +1,17 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; -const sqlite = require("./napi-sqlite.node"); -const { difference, log } = require("./utils"); +import { difference, log } from "./utils.mjs"; -const MIGRATIONS_DIR = __dirname + "/sql/migrations/"; +const DIRNAME = path.dirname(url.fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = DIRNAME + "/sql/migrations/"; const MIGRATION_FILENAMES = fs.readdirSync(MIGRATIONS_DIR, "UTF-8"); let db = null; -const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => { +export const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => { assert(dbName); db = await sqlite.open(dbName); @@ -45,7 +47,3 @@ const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => { } await db.exec("COMMIT TRANSACTION;"); }; - -module.exports = { - init, -}; diff --git a/src/escape.mjs b/src/escape.mjs new file mode 100644 index 0000000..78e08f8 --- /dev/null +++ b/src/escape.mjs @@ -0,0 +1,14 @@ +const FROM = /[&<>'"]/g; + +const ESCAPES = { + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"' +}; + +const mappingFn = c => ESCAPES[c]; + +export const escape = s => + String.prototype.replace.call(s, FROM, mappingFn); diff --git a/src/hero.mjs b/src/hero.mjs new file mode 100644 index 0000000..a8532a8 --- /dev/null +++ b/src/hero.mjs @@ -0,0 +1,220 @@ +import assert from "node:assert"; +import crypto from "node:crypto"; +import http from "node:http"; + +import { assocIn, getIn, first, log } from "./utils.mjs"; + +export const normalizeSegments = segments => + segments.length === 1 && segments[0] === "" ? + segments : + segments.concat([""]); + +export const pathToSegments = path => + normalizeSegments(path + .replace(/^\/*/, "") + .replace(/\/*$/, "") + .replace(/\/+/, "/") + .split("/")); + +export const hasPathParams = segments => + segments.some(s => s.startsWith(":")); + +const HTTP_METHODS = new Set([ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +]); + +const HTTP_METHODS_ARR = [...HTTP_METHODS.keys()].sort(); + +export const addRoute = (table, methods, path, handlerFn) => { + if (methods === "*") { + return addRoute(table, HTTP_METHODS_ARR, path, handlerFn); + } + + if (!Array.isArray(methods)) { + return addRoute(table, [methods], path, handlerFn); + } + + assert.ok(methods.every(m => HTTP_METHODS.has(m))); + + const segments = pathToSegments(path); + const kw = hasPathParams(segments) ? "dynamic" : "static"; + return methods.reduce( + (acc, el) => assocIn(acc, [kw, el].concat(segments), handlerFn), + table, + ); +}; + +export const buildRoutes = routes => + routes.reduce( + (acc, [methods, path, handlerFn]) => + addRoute(acc, methods, path, handlerFn), + {} + ); + +export const findStaticHandler = (table, method, segments) => { + const handlerFn = getIn(table, ["static", method].concat(segments)); + return !handlerFn ? null : { handlerFn, params: {} }; +}; + +/** + * "first" as is: + * - depth-first, as we look for a full match and use it instead of searching + * in parallel; + * - the first param in the same level to show up alphabetically, e.g. + * ":a-user" matches before ":id" does. + */ +export const firstParamMatch = (tree, segments, params) => { + assert.notEqual(segments.length, 0); + + const [seg, ...nextSegments] = segments; + + if (segments.length === 1) { + assert.equal(seg, ""); + const handlerFn = tree[""]; + + return !handlerFn ? null : { handlerFn, params }; + } + + const subtree = tree[seg]; + if (subtree) { + const submatch = firstParamMatch(subtree, nextSegments, params); + // propagation of the end of recursion + if (submatch) { + return submatch; + } + } + + // literal matching failed, we now look for patterns that might match + const paramOptions = Object.keys(tree) + .filter(s => s.startsWith(":")) + .sort(); + return first(paramOptions, param => firstParamMatch(tree[param], nextSegments, { + ...params, + [param.slice(1)]: seg + })); +}; + +export const findDynamicHandler = (table, method, segments) => { + const tree = table?.dynamic?.[method]; + return !tree ? null : firstParamMatch(tree, segments, {}); +}; + +export const findHandler = (table, method, path) => { + const segments = pathToSegments(path); + return ( + findStaticHandler(table, method, segments) || + findDynamicHandler(table, method, segments) + ); +}; + +export const extractQueryParams = s => { + const ret = {}; + for (const [k, v] of new URLSearchParams(s)) { + ret[k] = v; + } + return ret; +}; + +export const handleRequest = async (table, method, url) => { + const [ path, queryParams ] = url.split("?"); + const handler = findHandler(table, method, path); + if (!handler) { + return { + status: 404, + body: "Not Found", + }; + } + + const request = { + params: { + path: handler.params, + query: extractQueryParams(queryParams), + }, + method, + path, + handler: handler.handlerFn, + }; + + return await handler.handlerFn(request); +}; + +export const makeRequestListener = table => async (req, res) => { + const response = await handleRequest(table, req.method, req.url); + res.writeHead(response.status, response.headers); + res.end(response.body); +}; + +export const interceptorsFn = ({ + uuidFn, + logger, +} = { + uuidFn: crypto.randomUUID, + logger: log, +}) => ({ + requestId: (req, next) => next({ ...req, id: uuidFn() }), + logged: async (req, next) => { + const { id, url, method } = req; + logger({ + id, + url, + method, + type: "in-request", + }); + const response = await next(req); + const { status } = response; + logger({ + id, + status, + type: "in-response", + }); + return response; + }, + contentType: async (req, next) => { + const response = await next(req); + const [mimeType, body] = typeof response.body === "string" ? + ["text/html", response.body] : + ["application/json", JSON.stringify(response.body) || ""]; + return { + ...response, + body, + headers: { + "Content-Type": mimeType, + "Content-Length": Buffer.byteLength(body), + ...(response.headers || {}) + }, + }; + }, + serverError: async (req, next) => { + try { + const response = await next(req); + assert.ok("status" in response, `Missing "status"`); + return response; + } catch (error) { + logger({ + id: req.id, + type: "server-error-interceptor", + message: error.message, + }); + return { + status: 500, + body: "Internal Server Error", + }; + } + }, +}); + +export const interceptors = interceptorsFn(); + +export const chainInterceptors = arr => + req => arr.length === 0 ? + req : + arr[0](req, chainInterceptors(arr.slice(1))); + +export const wrapHandler = (fn, arr) => + chainInterceptors(arr.concat([ (req, _next) => fn(req) ])); diff --git a/src/ircd.js b/src/ircd.mjs index 6a02bd6..38903f8 100644 --- a/src/ircd.js +++ b/src/ircd.mjs @@ -1,18 +1,14 @@ -const net = require("node:net"); -const db = require("./db.js"); +import net from "node:net"; +import {} from "./db.mjs"; const server = net.createServer(socket => { socket.write("olar\r\n"); socket.pipe(socket); }); -const app = async udsPath => { +export const app = async udsPath => { await db.init(); server.listen(udsPath, () => { console.log("I'm ircd."); }); }; - -module.exports = { - app, -}; diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..fb3cb61 --- /dev/null +++ b/src/package.json @@ -0,0 +1,3 @@ +{ + "main": "api.mjs" +} diff --git a/src/utils.js b/src/utils.mjs index e1725ef..e0e20a7 100644 --- a/src/utils.js +++ b/src/utils.mjs @@ -1,4 +1,4 @@ -const eq = (a, b) => { +export const eq = (a, b) => { if (a === b) { return true; } @@ -31,14 +31,14 @@ const eq = (a, b) => { return true; }; -const keys = (ks, obj) => +export const keys = (ks, obj) => ks.reduce( (ret, k) => obj.hasOwnProperty(k) ? {...ret, [k]: obj[k]} : ret, {}, ); -const difference = (a, b) => { +export const difference = (a, b) => { const diff = new Set(a); for (const el of b) { diff.delete(el); @@ -46,12 +46,31 @@ const difference = (a, b) => { return diff; }; -const log = o => console.error(JSON.stringify(o)); +export const assocIn = (obj, path, value) => + path.length === 0 ? obj : + path.length === 1 ? { ...obj, [path[0]]: value } : + { + ...obj, + [path[0]]: assocIn( + (obj[path[0]] || {}), + path.slice(1), + value + ) + }; +export const getIn = (obj, path) => + path.length === 0 ? obj : + getIn(obj?.[path[0]], path.slice(1)); -module.exports = { - eq, - keys, - difference, - log, +export const first = (arr, fn) => { + for (const x of arr) { + const ret = fn(x); + if (ret) { + return ret; + } + } + + return null; }; + +export const log = o => console.error(JSON.stringify(o)); @@ -1,4 +1,4 @@ -const http = require("node:http"); +import http from "node:http"; const listProducts = () => {}; const getProduct = () => {}; @@ -23,12 +23,8 @@ const server = http.createServer((req, res) => { res.end("Hello, web!\n"); }); -const app = udsPath => { +export const app = udsPath => { server.listen(udsPath, () => { console.log("I'm web."); }); }; - -module.exports = { - app, -}; |