From c36bf8e3577da31cf6d575879c7e92d3e9c7e4f1 Mon Sep 17 00:00:00 2001 From: EuAndreh Date: Fri, 23 Feb 2024 06:05:19 -0300 Subject: Big cleanup - delete all SQLite Node-API code: we'll use the C++ one instead; - implement hero.mjs, with tests! - use ESM all over. --- src/api.js | 28 ------- src/api.mjs | 24 ++++++ src/bin.mjs | 4 + src/cli | 4 - src/db.js | 51 ------------- src/db.mjs | 49 +++++++++++++ src/escape.mjs | 14 ++++ src/hero.mjs | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/ircd.js | 18 ----- src/ircd.mjs | 14 ++++ src/package.json | 3 + src/utils.js | 57 -------------- src/utils.mjs | 76 +++++++++++++++++++ src/web.js | 34 --------- src/web.mjs | 30 ++++++++ 15 files changed, 434 insertions(+), 192 deletions(-) delete mode 100644 src/api.js create mode 100644 src/api.mjs create mode 100755 src/bin.mjs delete mode 100755 src/cli delete mode 100644 src/db.js create mode 100644 src/db.mjs create mode 100644 src/escape.mjs create mode 100644 src/hero.mjs delete mode 100644 src/ircd.js create mode 100644 src/ircd.mjs create mode 100644 src/package.json delete mode 100644 src/utils.js create mode 100644 src/utils.mjs delete mode 100644 src/web.js create mode 100644 src/web.mjs (limited to 'src') diff --git a/src/api.js b/src/api.js deleted file mode 100644 index 7f0991f..0000000 --- a/src/api.js +++ /dev/null @@ -1,28 +0,0 @@ -const { eq } = require("./utils.js"); -const ircd = require("./ircd.js"); -const web = require("./web.js"); - -const main = async () => { - if (process.argv.length === 3 && process.argv[2] === "-V") { - console.log("papo 1970-01-01 0.1.0"); - return; - } - - if (process.argv[2] === "ircd") { - await ircd.app(process.argv[3]); - return; - } - - if (process.argv[2] === "web") { - await web.app(process.argv[3]); - return; - } - - console.log({ - argv: process.argv, - }); -}; - -module.exports = { - main, -}; diff --git a/src/api.mjs b/src/api.mjs new file mode 100644 index 0000000..27a14b1 --- /dev/null +++ b/src/api.mjs @@ -0,0 +1,24 @@ +import { eq } from "./utils.mjs"; +import * as ircd from "./ircd.mjs"; +import * as web from "./web.mjs"; + +export const main = async () => { + if (process.argv.length === 3 && process.argv[2] === "-V") { + console.log("papo 1970-01-01 0.1.0"); + return; + } + + if (process.argv[2] === "ircd") { + await ircd.app(process.argv[3]); + return; + } + + if (process.argv[2] === "web") { + await web.app(process.argv[3]); + return; + } + + console.log({ + argv: process.argv, + }); +}; 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(); diff --git a/src/db.js b/src/db.js deleted file mode 100644 index acbc7b4..0000000 --- a/src/db.js +++ /dev/null @@ -1,51 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); - -const sqlite = require("./napi-sqlite.node"); -const { difference, log } = require("./utils"); - - -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:") => { - assert(dbName); - - db = await sqlite.open(dbName); - await db.exec(` - BEGIN TRANSACTION; - CREATE TABLE IF NOT EXISTS migrations ( - filename TEXT PRIMARY KEY - ); - COMMIT TRANSACTION; - `); - - const done = await db.all(` - SELECT filename FROM migrations; - `).map(row => row.filename); - const pending = difference( - new Set(MIGRATION_FILENAMES), - new Set(done) - ); - const sortedPending = [...pending].sort((a, b) => a.localeCompare(b)); - - await db.exec("BEGIN TRANSACTION;"); - for (const filename of sortedPending) { - log({ - log: "exec-migration", - filename, - }); - const sql = fs.readFileSync(MIGRATIONS_DIR + filename, "UTF-8"); - await db.exec(sql); - await db.run( - `INSERT INTO migrations (filename) VALUES ($filename);`, - { filename }, - ); - } - await db.exec("COMMIT TRANSACTION;"); -}; - -module.exports = { - init, -}; diff --git a/src/db.mjs b/src/db.mjs new file mode 100644 index 0000000..59d6a0e --- /dev/null +++ b/src/db.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; + +import { difference, log } from "./utils.mjs"; + + +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; +export const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => { + assert(dbName); + + db = await sqlite.open(dbName); + await db.exec(` + BEGIN TRANSACTION; + CREATE TABLE IF NOT EXISTS migrations ( + filename TEXT PRIMARY KEY + ); + COMMIT TRANSACTION; + `); + + const done = await db.all(` + SELECT filename FROM migrations; + `).map(row => row.filename); + const pending = difference( + new Set(MIGRATION_FILENAMES), + new Set(done) + ); + const sortedPending = [...pending].sort((a, b) => a.localeCompare(b)); + + await db.exec("BEGIN TRANSACTION;"); + for (const filename of sortedPending) { + log({ + log: "exec-migration", + filename, + }); + const sql = fs.readFileSync(MIGRATIONS_DIR + filename, "UTF-8"); + await db.exec(sql); + await db.run( + `INSERT INTO migrations (filename) VALUES ($filename);`, + { filename }, + ); + } + await db.exec("COMMIT TRANSACTION;"); +}; 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.js deleted file mode 100644 index 6a02bd6..0000000 --- a/src/ircd.js +++ /dev/null @@ -1,18 +0,0 @@ -const net = require("node:net"); -const db = require("./db.js"); - -const server = net.createServer(socket => { - socket.write("olar\r\n"); - socket.pipe(socket); -}); - -const app = async udsPath => { - await db.init(); - server.listen(udsPath, () => { - console.log("I'm ircd."); - }); -}; - -module.exports = { - app, -}; diff --git a/src/ircd.mjs b/src/ircd.mjs new file mode 100644 index 0000000..38903f8 --- /dev/null +++ b/src/ircd.mjs @@ -0,0 +1,14 @@ +import net from "node:net"; +import {} from "./db.mjs"; + +const server = net.createServer(socket => { + socket.write("olar\r\n"); + socket.pipe(socket); +}); + +export const app = async udsPath => { + await db.init(); + server.listen(udsPath, () => { + console.log("I'm ircd."); + }); +}; 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.js deleted file mode 100644 index e1725ef..0000000 --- a/src/utils.js +++ /dev/null @@ -1,57 +0,0 @@ -const eq = (a, b) => { - if (a === b) { - return true; - } - - if (a === null || b === null) { - return false; - } - - if (typeof a != "object" || typeof b != "object") { - return false; - } - - if (Array.isArray(a) !== Array.isArray(b)) { - return false; - } - - if (Object.keys(a).length !== Object.keys(b).length) { - return false; - } - - for (const k in a) { - if (!b.hasOwnProperty(k)) { - return false; - } - if (!eq(a[k], b[k])) { - return false; - } - } - - return true; -}; - -const keys = (ks, obj) => - ks.reduce( - (ret, k) => - obj.hasOwnProperty(k) ? {...ret, [k]: obj[k]} : ret, - {}, - ); - -const difference = (a, b) => { - const diff = new Set(a); - for (const el of b) { - diff.delete(el); - } - return diff; -}; - -const log = o => console.error(JSON.stringify(o)); - - -module.exports = { - eq, - keys, - difference, - log, -}; diff --git a/src/utils.mjs b/src/utils.mjs new file mode 100644 index 0000000..e0e20a7 --- /dev/null +++ b/src/utils.mjs @@ -0,0 +1,76 @@ +export const eq = (a, b) => { + if (a === b) { + return true; + } + + if (a === null || b === null) { + return false; + } + + if (typeof a != "object" || typeof b != "object") { + return false; + } + + if (Array.isArray(a) !== Array.isArray(b)) { + return false; + } + + if (Object.keys(a).length !== Object.keys(b).length) { + return false; + } + + for (const k in a) { + if (!b.hasOwnProperty(k)) { + return false; + } + if (!eq(a[k], b[k])) { + return false; + } + } + + return true; +}; + +export const keys = (ks, obj) => + ks.reduce( + (ret, k) => + obj.hasOwnProperty(k) ? {...ret, [k]: obj[k]} : ret, + {}, + ); + +export const difference = (a, b) => { + const diff = new Set(a); + for (const el of b) { + diff.delete(el); + } + return diff; +}; + +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)); + +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)); diff --git a/src/web.js b/src/web.js deleted file mode 100644 index bfa1807..0000000 --- a/src/web.js +++ /dev/null @@ -1,34 +0,0 @@ -const http = require("node:http"); - -const listProducts = () => {}; -const getProduct = () => {}; - -const routes = { - GET: { - "/products": listProducts, - "/products/:id": getProduct, - }, -}; - -const server = http.createServer((req, res) => { - const { headers ,url, method } = req; - console.log({ - headers, - url, - method, - }); - res.writeHead(200, { - "Content-Type": "text/plain", - }); - res.end("Hello, web!\n"); -}); - -const app = udsPath => { - server.listen(udsPath, () => { - console.log("I'm web."); - }); -}; - -module.exports = { - app, -}; diff --git a/src/web.mjs b/src/web.mjs new file mode 100644 index 0000000..8eed5b4 --- /dev/null +++ b/src/web.mjs @@ -0,0 +1,30 @@ +import http from "node:http"; + +const listProducts = () => {}; +const getProduct = () => {}; + +const routes = { + GET: { + "/products": listProducts, + "/products/:id": getProduct, + }, +}; + +const server = http.createServer((req, res) => { + const { headers ,url, method } = req; + console.log({ + headers, + url, + method, + }); + res.writeHead(200, { + "Content-Type": "text/plain", + }); + res.end("Hello, web!\n"); +}); + +export const app = udsPath => { + server.listen(udsPath, () => { + console.log("I'm web."); + }); +}; -- cgit v1.2.3