Add a first cut at an express-based backend
This commit is contained in:
1
express/.gitignore
vendored
Normal file
1
express/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
out/
|
||||
120
express/app.ts
Normal file
120
express/app.ts
Normal 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
14
express/check.sh
Executable 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
11
express/config.ts
Normal 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
40
express/content-types.ts
Normal 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
7
express/deps.ts
Normal 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
0
express/extensible.ts
Normal file
19
express/handlers.ts
Normal file
19
express/handlers.ts
Normal 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
43
express/http-codes.ts
Normal 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
3
express/interfaces.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
type Brand<K, T> = K & { readonly __brand: T };
|
||||
|
||||
export type Extensible = Brand<"Extensible", {}>;
|
||||
44
express/logging.ts
Normal file
44
express/logging.ts
Normal 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
47
express/package.json
Normal 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
1510
express/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
express/routes.ts
Normal file
77
express/routes.ts
Normal 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
32
express/run.sh
Executable 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
36
express/services.ts
Normal 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
12
express/show-config.sh
Executable 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
13
express/tsconfig.json
Normal 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
59
express/types.ts
Normal 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
14
express/watch.sh
Executable 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
|
||||
Reference in New Issue
Block a user