Add a first cut at an express-based backend

This commit is contained in:
Michael Wolf
2025-11-17 10:58:54 -06:00
parent c346a70cce
commit 1a13fd0909
20 changed files with 2102 additions and 0 deletions

1
express/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
out/

120
express/app.ts Normal file
View File

@@ -0,0 +1,120 @@
import express, {
Request as ExpressRequest,
Response as ExpressResponse,
} from "express";
import { match } from "path-to-regexp";
import { contentTypes } from "./content-types";
import { httpCodes } from "./http-codes";
import { routes } from "./routes";
import { services } from "./services";
// import { URLPattern } from 'node:url';
import {
Call,
InternalHandler,
Method,
ProcessedRoute,
Result,
Route,
massageMethod,
methodParser,
} from "./types";
const app = express();
services.logging.log({ source: "logging", text: ["1"] });
const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
GET: [],
POST: [],
PUT: [],
PATCH: [],
DELETE: [],
};
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as any)?.then === "function";
}
routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
// const pattern /*: URLPattern */ = new URLPattern({ pathname: route.path });
const matcher = match<Record<string, string>>(route.path);
const methodList = route.methods;
const handler: InternalHandler = async (
request: ExpressRequest,
): Promise<Result> => {
const method = massageMethod(request.method);
console.log("method", method);
if (!methodList.includes(method)) {
// XXX: Worth asserting this?
}
console.log("request.originalUrl", request.originalUrl);
console.log("beavis");
// const p = new URL(request.originalUrl);
// const path = p.pathname;
// console.log("p, path", p, path)
console.log("ok");
const req: Call = {
pattern: route.path,
// path,
path: request.originalUrl,
method,
parameters: { one: 1, two: 2 },
request,
};
const retval = await route.handler(req);
return retval;
};
for (const [_idx, method] of methodList.entries()) {
const pr: ProcessedRoute = { matcher, method, handler };
processedRoutes[method].push(pr);
}
});
async function handler(
req: ExpressRequest,
_res: ExpressResponse,
): Promise<Result> {
const method = await methodParser.parseAsync(req.method);
const byMethod = processedRoutes[method];
for (const [_idx, pr] of byMethod.entries()) {
const match = pr.matcher(req.url);
if (match) {
console.log("match", match);
const resp = await pr.handler(req);
return resp;
}
}
const retval: Result = {
code: httpCodes.clientErrors.NotFound,
contentType: contentTypes.text.plain,
result: "not found",
};
return retval;
}
app.use(async (req: ExpressRequest, res: ExpressResponse) => {
const result0 = await handler(req, res);
const code = result0.code.code;
const result = result0.result;
console.log(result);
res.status(code).send(result);
});
app.listen(3000);

14
express/check.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -eu
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
check_dir="$DIR"
out_dir="$check_dir/out"
source "$check_dir"/../framework/shims/common
source "$check_dir"/../framework/shims/node.common
$ROOT/cmd pnpm tsc --outDir "$out_dir"

11
express/config.ts Normal file
View File

@@ -0,0 +1,11 @@
const config = {
database: {
user: "abc123",
password: "abc123",
host: "localhost",
port: "5432",
database: "abc123",
},
};
export { config };

40
express/content-types.ts Normal file
View File

@@ -0,0 +1,40 @@
import { Extensible } from "./interfaces";
export type ContentType = string;
// FIXME: Fill this out (get an AI to do it)
const contentTypes = {
text: {
plain: "text/plain",
html: "text/html",
css: "text/css",
javascript: "text/javascript",
xml: "text/xml",
},
image: {
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
svgPlusXml: "image/svg+xml",
webp: "image/webp",
},
audio: {
mpeg: "audio/mpeg",
wav: "audio/wav",
},
video: {
mp4: "video/mp4",
webm: "video/webm",
xMsvideo: "video/x-msvideo",
},
application: {
json: "application/json",
pdf: "application/pdf",
zip: "application/zip",
xWwwFormUrlencoded: "x-www-form-urlencoded",
octetStream: "octet-stream",
},
};
export { contentTypes };

7
express/deps.ts Normal file
View File

@@ -0,0 +1,7 @@
// Database
//export { Client as PostgresClient } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
//export type { ClientOptions as PostgresOptions } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
// Redis
//export { connect as redisConnect } from "https://deno.land/x/redis@v0.37.1/mod.ts";
//export type { Redis } from "https://deno.land/x/redis@v0.37.1/mod.ts";

0
express/extensible.ts Normal file
View File

19
express/handlers.ts Normal file
View File

@@ -0,0 +1,19 @@
import { contentTypes } from "./content-types";
import { httpCodes } from "./http-codes";
import { services } from "./services";
import { Call, Handler, Result } from "./types";
const multiHandler: Handler = async (call: Call): Promise<Result> => {
const code = httpCodes.success.OK;
const rn = services.random.randomNumber();
const retval: Result = {
code,
result: `that was ${call.method} (${rn})`,
contentType: contentTypes.text.plain,
};
return retval;
};
export { multiHandler };

43
express/http-codes.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Extensible } from "./interfaces";
export type HttpCode = {
code: number;
name: string;
description?: string;
};
type Group = "success" | "redirection" | "clientErrors" | "serverErrors";
type CodeDefinitions = {
[K in Group]: {
[K: string]: HttpCode;
};
};
// FIXME: Figure out how to brand CodeDefinitions in a way that isn't
// tedious.
const httpCodes: CodeDefinitions = {
success: {
OK: { code: 200, name: "OK", description: "" },
Created: { code: 201, name: "Created" },
Accepted: { code: 202, name: "Accepted" },
NoContent: { code: 204, name: "No content" },
},
redirection: {
// later
},
clientErrors: {
BadRequest: { code: 400, name: "Bad Request" },
Unauthorized: { code: 401, name: "Unauthorized" },
Forbidden: { code: 403, name: "Forbidden" },
NotFound: { code: 404, name: "Not Found" },
MethodNotAllowed: { code: 405, name: "Method Not Allowed" },
NotAcceptable: { code: 406, name: "Not Acceptable" },
// More later
},
serverErrors: {
InternalServerError: { code: 500, name: "Internal Server Error" },
NotImplemented: { code: 500, name: "Not implemented" },
// more later
},
};
export { httpCodes };

3
express/interfaces.ts Normal file
View File

@@ -0,0 +1,3 @@
type Brand<K, T> = K & { readonly __brand: T };
export type Extensible = Brand<"Extensible", {}>;

44
express/logging.ts Normal file
View File

@@ -0,0 +1,44 @@
// internal-logging.ts
// FIXME: Move this to somewhere more appropriate
type AtLeastOne<T> = [T, ...T[]];
type MessageSource = "logging" | "diagnostic" | "user";
type Message = {
// FIXME: number probably isn't what we want here
timestamp?: number;
source: MessageSource;
text: AtLeastOne<string>;
};
const m1: Message = { timestamp: 123, source: "logging", text: ["foo"] };
const m2: Message = {
timestamp: 321,
source: "diagnostic",
text: ["ok", "whatever"],
};
type FilterArgument = {
limit?: number;
before?: number;
after?: number;
// FIXME: add offsets to use instead of or in addition to before/after
match?: (string | RegExp)[];
};
const log = (_message: Message) => {
// WRITEME
};
const getLogs = (filter: FilterArgument) => {
// WRITEME
};
// FIXME: there's scope for more specialized functions although they
// probably should be defined in terms of the basic ones here.
export { getLogs, log };

47
express/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "express",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prettier": "prettier",
"nodemon": "nodemon dist/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.12.4",
"dependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@types/node": "^24.10.1",
"@vercel/ncc": "^0.38.4",
"express": "^5.1.0",
"nodemon": "^3.1.11",
"path-to-regexp": "^8.3.0",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"zod": "^4.1.12"
},
"prettier": {
"arrowParens": "always",
"bracketSpacing": true,
"trailingComma": "all",
"tabWidth": 4,
"semi": true,
"singleQuote": false,
"importOrder": [
"<THIRD_PARTY_MODULES>",
"^[./]"
],
"importOrderCaseSensitive": true,
"plugins": [
"@ianvs/prettier-plugin-sort-imports"
]
},
"devDependencies": {
"@types/express": "^5.0.5"
}
}

1510
express/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

77
express/routes.ts Normal file
View File

@@ -0,0 +1,77 @@
/// <reference lib="dom" />
import { contentTypes } from "./content-types";
import { multiHandler } from "./handlers";
import { HttpCode, httpCodes } from "./http-codes";
import { services } from "./services";
import { Call, ProcessedRoute, Result, Route } from "./types";
// FIXME: Obviously put this somewhere else
const okText = (result: string): Result => {
const code = httpCodes.success.OK;
const retval: Result = {
code,
result,
contentType: contentTypes.text.plain,
};
return retval;
};
const routes: Route[] = [
{
path: "/slow",
methods: ["GET"],
handler: async (_call: Call): Promise<Result> => {
console.log("starting slow request");
await services.misc.sleep(2);
console.log("finishing slow request");
const retval = okText("that was slow");
return retval;
},
},
{
path: "/list",
methods: ["GET"],
handler: async (call: Call): Promise<Result> => {
const code = httpCodes.success.OK;
const lr = (rr: Route[]) => {
const ret = rr.map((r: Route) => {
return r.path;
});
return ret;
};
const listing = lr(routes).join(", ");
return {
code,
result: listing + "\n",
contentType: contentTypes.text.plain,
};
},
},
{
path: "/ok",
methods: ["GET", "POST", "PUT"],
handler: multiHandler,
},
{
path: "/alsook",
methods: ["GET"],
handler: async (_req): Promise<Result> => {
const code = httpCodes.success.OK;
return {
code,
result: "it is also ok",
contentType: contentTypes.text.plain,
};
},
},
];
export { routes };

32
express/run.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
# XXX should we default to strict or non-strict here?
set -eu
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
run_dir="$DIR"
source "$run_dir"/../framework/shims/common
source "$run_dir"/../framework/shims/node.common
strict_arg="${1:---no-strict}"
if [[ "$strict_arg" = "--strict" ]] ; then
strict="yes"
else
strict="no"
fi
cmd="tsx"
if [[ "strict" = "yes" ]] ; then
cmd="ts-node"
fi
cd "$run_dir"
"$run_dir"/check.sh
#echo checked
# $ROOT/cmd "$cmd" $run_dir/app.ts
../cmd node "$run_dir"/out/app.js

36
express/services.ts Normal file
View File

@@ -0,0 +1,36 @@
// services.ts
import { config } from "./config";
import { getLogs, log } from "./logging";
//const database = Client({
//})
const database = {};
const logging = {
log,
getLogs,
};
const random = {
randomNumber: () => {
return Math.random();
},
};
const misc = {
sleep: (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
},
};
const services = {
database,
logging,
misc,
random,
};
export { services };

12
express/show-config.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
check_dir="$DIR"
source "$check_dir"/../framework/shims/common
source "$check_dir"/../framework/shims/node.common
$ROOT/cmd pnpm tsc --showConfig

13
express/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"esModuleInterop": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noImplicitAny": true,
"strict": true,
"types": ["node"],
"outDir": "out",
}
}

59
express/types.ts Normal file
View File

@@ -0,0 +1,59 @@
// types.ts
// FIXME: split this up into types used by app developers and types internal
// to the framework.
import {
Request as ExpressRequest,
Response as ExpressResponse,
} from "express";
import { MatchFunction } from "path-to-regexp";
import { z } from "zod";
import { ContentType, contentTypes } from "./content-types";
import { HttpCode, httpCodes } from "./http-codes";
const methodParser = z.union([
z.literal("GET"),
z.literal("POST"),
z.literal("PUT"),
z.literal("PATCH"),
z.literal("DELETE"),
]);
export type Method = z.infer<typeof methodParser>;
const massageMethod = (input: string): Method => {
const r = methodParser.parse(input.toUpperCase());
return r;
};
export type Call = {
pattern: string;
path: string;
method: Method;
parameters: object;
request: ExpressRequest;
};
export type InternalHandler = (req: ExpressRequest) => Promise<Result>;
export type Handler = (call: Call) => Promise<Result>;
export type ProcessedRoute = {
matcher: MatchFunction<Record<string, string>>;
method: Method;
handler: InternalHandler;
};
export type Result = {
code: HttpCode;
contentType: ContentType;
result: string;
};
export type Route = {
path: string;
methods: Method[];
handler: Handler;
interruptable?: boolean;
};
export { methodParser, massageMethod };

14
express/watch.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
check_dir="$DIR"
source "$check_dir"/../framework/shims/common
source "$check_dir"/../framework/shims/node.common
# $ROOT/cmd pnpm tsc --lib ES2023 --esModuleInterop -w $check_dir/app.ts
# $ROOT/cmd pnpm tsc -w $check_dir/app.ts
$ROOT/cmd pnpm tsc -w --project ./tsconfig.json