@malyuga/aether-cli 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +119 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/check.d.ts +4 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +45 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +62 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/generate.d.ts +15 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +51 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/help.d.ts +6 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +60 -0
- package/dist/commands/help.js.map +1 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +24 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/upgrade.d.ts +5 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/core/ast.d.ts +7 -0
- package/dist/core/ast.d.ts.map +1 -0
- package/dist/core/ast.js +90 -0
- package/dist/core/ast.js.map +1 -0
- package/dist/core/constants.d.ts +133 -0
- package/dist/core/constants.d.ts.map +1 -0
- package/dist/core/constants.js +135 -0
- package/dist/core/constants.js.map +1 -0
- package/dist/core/diff.d.ts +20 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +39 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/errors.d.ts +19 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +37 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/naming.d.ts +10 -0
- package/dist/core/naming.d.ts.map +1 -0
- package/dist/core/naming.js +50 -0
- package/dist/core/naming.js.map +1 -0
- package/dist/core/paths.d.ts +10 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/plan.d.ts +37 -0
- package/dist/core/plan.d.ts.map +1 -0
- package/dist/core/plan.js +126 -0
- package/dist/core/plan.js.map +1 -0
- package/dist/core/project.d.ts +16 -0
- package/dist/core/project.d.ts.map +1 -0
- package/dist/core/project.js +66 -0
- package/dist/core/project.js.map +1 -0
- package/dist/core/render.d.ts +4 -0
- package/dist/core/render.d.ts.map +1 -0
- package/dist/core/render.js +44 -0
- package/dist/core/render.js.map +1 -0
- package/dist/generators/middleware.d.ts +11 -0
- package/dist/generators/middleware.d.ts.map +1 -0
- package/dist/generators/middleware.js +41 -0
- package/dist/generators/middleware.js.map +1 -0
- package/dist/generators/openapi.d.ts +9 -0
- package/dist/generators/openapi.d.ts.map +1 -0
- package/dist/generators/openapi.js +46 -0
- package/dist/generators/openapi.js.map +1 -0
- package/dist/generators/project.d.ts +9 -0
- package/dist/generators/project.d.ts.map +1 -0
- package/dist/generators/project.js +76 -0
- package/dist/generators/project.js.map +1 -0
- package/dist/generators/resource-context.d.ts +18 -0
- package/dist/generators/resource-context.d.ts.map +1 -0
- package/dist/generators/resource-context.js +21 -0
- package/dist/generators/resource-context.js.map +1 -0
- package/dist/generators/resource-file.d.ts +8 -0
- package/dist/generators/resource-file.d.ts.map +1 -0
- package/dist/generators/resource-file.js +9 -0
- package/dist/generators/resource-file.js.map +1 -0
- package/dist/generators/resource-tests.d.ts +5 -0
- package/dist/generators/resource-tests.d.ts.map +1 -0
- package/dist/generators/resource-tests.js +17 -0
- package/dist/generators/resource-tests.js.map +1 -0
- package/dist/generators/resource.d.ts +21 -0
- package/dist/generators/resource.d.ts.map +1 -0
- package/dist/generators/resource.js +64 -0
- package/dist/generators/resource.js.map +1 -0
- package/dist/generators/types.d.ts +7 -0
- package/dist/generators/types.d.ts.map +1 -0
- package/dist/generators/types.js +2 -0
- package/dist/generators/types.js.map +1 -0
- package/dist/help/guide.txt +126 -0
- package/dist/openapi/extract-resources.d.ts +3 -0
- package/dist/openapi/extract-resources.d.ts.map +1 -0
- package/dist/openapi/extract-resources.js +130 -0
- package/dist/openapi/extract-resources.js.map +1 -0
- package/dist/openapi/load-spec.d.ts +3 -0
- package/dist/openapi/load-spec.d.ts.map +1 -0
- package/dist/openapi/load-spec.js +19 -0
- package/dist/openapi/load-spec.js.map +1 -0
- package/dist/openapi/types.d.ts +51 -0
- package/dist/openapi/types.d.ts.map +1 -0
- package/dist/openapi/types.js +2 -0
- package/dist/openapi/types.js.map +1 -0
- package/dist/templates/init/aether.config.json.eta +5 -0
- package/dist/templates/init/astraeus.config.json.eta +5 -0
- package/dist/templates/init/chisel.config.json.eta +5 -0
- package/dist/templates/init/env.eta +8 -0
- package/dist/templates/init/env.example.eta +8 -0
- package/dist/templates/init/gitignore.eta +5 -0
- package/dist/templates/init/package.json.eta +14 -0
- package/dist/templates/init/src-app-composition.ts.eta +6 -0
- package/dist/templates/init/src-app-config.ts.eta +105 -0
- package/dist/templates/init/src-app-http.ts.eta +74 -0
- package/dist/templates/init/src-app-list.ts.eta +35 -0
- package/dist/templates/init/src-app-logger.ts.eta +35 -0
- package/dist/templates/init/src-app-middleware-bearer-auth.ts.eta +60 -0
- package/dist/templates/init/src-app-middleware-body-limit.ts.eta +37 -0
- package/dist/templates/init/src-app-middleware-cors.ts.eta +53 -0
- package/dist/templates/init/src-app-middleware-error-handler.ts.eta +15 -0
- package/dist/templates/init/src-app-middleware-request-logger.ts.eta +8 -0
- package/dist/templates/init/src-app-middleware-types.ts.eta +7 -0
- package/dist/templates/init/src-app-router.ts.eta +72 -0
- package/dist/templates/init/src-app-server.ts.eta +16 -0
- package/dist/templates/init/src-app.ts.eta +36 -0
- package/dist/templates/init/src-health-health.routes.ts.eta +12 -0
- package/dist/templates/init/src-main.ts.eta +37 -0
- package/dist/templates/init/tsconfig.json.eta +14 -0
- package/dist/templates/middleware/middleware.ts.eta +6 -0
- package/dist/templates/resource/controller.ts.eta +135 -0
- package/dist/templates/resource/module.test.ts.eta +16 -0
- package/dist/templates/resource/module.ts.eta +25 -0
- package/dist/templates/resource/repository.fake.ts.eta +33 -0
- package/dist/templates/resource/repository.ts.eta +14 -0
- package/dist/templates/resource/routes.ts.eta +18 -0
- package/dist/templates/resource/service.ts.eta +45 -0
- package/dist/templates/resource/types.ts.eta +12 -0
- package/dist/templates/resource/validate.ts.eta +64 -0
- package/llms.txt +57 -0
- package/package.json +67 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "<%= it.projectName %>",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"build": "tsc",
|
|
6
|
+
"start": "node dist/main.js",
|
|
7
|
+
"dev": "tsx watch src/main.ts"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@types/node": "^24.13.5",
|
|
11
|
+
"tsx": "^4.19.3",
|
|
12
|
+
"typescript": "^7.0.2"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type AppConfig = {
|
|
5
|
+
port: number;
|
|
6
|
+
host: string;
|
|
7
|
+
nodeEnv: "development" | "production" | "test";
|
|
8
|
+
shutdownGraceMs: number;
|
|
9
|
+
corsOrigin: string;
|
|
10
|
+
bodyLimitBytes: number;
|
|
11
|
+
bearerToken: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ALLOWED_NODE_ENV = new Set(["development", "production", "test"]);
|
|
15
|
+
const DEFAULT_PORT = 3000;
|
|
16
|
+
const DEFAULT_HOST = "0.0.0.0";
|
|
17
|
+
const DEFAULT_NODE_ENV = "development";
|
|
18
|
+
const DEFAULT_SHUTDOWN_GRACE_MS = 10000;
|
|
19
|
+
const DEFAULT_BODY_LIMIT_BYTES = 1048576;
|
|
20
|
+
const PORT_MIN = 1;
|
|
21
|
+
const PORT_MAX = 65535;
|
|
22
|
+
|
|
23
|
+
function stripQuotes(value: string): string {
|
|
24
|
+
if (
|
|
25
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
26
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
27
|
+
) {
|
|
28
|
+
return value.slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Loads KEY=VALUE lines from `.env` in cwd; does not override existing process.env. */
|
|
34
|
+
export function loadEnvFile(relativePath = ".env"): void {
|
|
35
|
+
const abs = join(process.cwd(), relativePath);
|
|
36
|
+
if (!existsSync(abs)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const raw = readFileSync(abs, "utf8");
|
|
40
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const eq = trimmed.indexOf("=");
|
|
46
|
+
if (eq <= 0) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const key = trimmed.slice(0, eq).trim();
|
|
50
|
+
const value = stripQuotes(trimmed.slice(eq + 1).trim());
|
|
51
|
+
if (key.length === 0) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (process.env[key] === undefined) {
|
|
55
|
+
process.env[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadConfig(): AppConfig {
|
|
61
|
+
loadEnvFile();
|
|
62
|
+
|
|
63
|
+
const portRaw = process.env.PORT ?? String(DEFAULT_PORT);
|
|
64
|
+
const port = Number(portRaw);
|
|
65
|
+
if (!Number.isInteger(port) || port < PORT_MIN || port > PORT_MAX) {
|
|
66
|
+
throw new Error(`Invalid PORT: ${portRaw}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const host = process.env.HOST ?? DEFAULT_HOST;
|
|
70
|
+
if (host.length === 0) {
|
|
71
|
+
throw new Error("HOST must not be empty");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const nodeEnv = process.env.NODE_ENV ?? DEFAULT_NODE_ENV;
|
|
75
|
+
if (!ALLOWED_NODE_ENV.has(nodeEnv)) {
|
|
76
|
+
throw new Error(`Invalid NODE_ENV: ${nodeEnv}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const graceRaw = process.env.SHUTDOWN_GRACE_MS ?? String(DEFAULT_SHUTDOWN_GRACE_MS);
|
|
80
|
+
const shutdownGraceMs = Number(graceRaw);
|
|
81
|
+
if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
|
|
82
|
+
throw new Error(`Invalid SHUTDOWN_GRACE_MS: ${graceRaw}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const corsOrigin = (process.env.CORS_ORIGIN ?? "").trim();
|
|
86
|
+
const bearerToken = process.env.BEARER_TOKEN ?? "";
|
|
87
|
+
|
|
88
|
+
const bodyLimitRaw = process.env.BODY_LIMIT_BYTES ?? String(DEFAULT_BODY_LIMIT_BYTES);
|
|
89
|
+
const bodyLimitBytes = Number(bodyLimitRaw);
|
|
90
|
+
if (!Number.isInteger(bodyLimitBytes) || bodyLimitBytes < 1) {
|
|
91
|
+
throw new Error(`Invalid BODY_LIMIT_BYTES: ${bodyLimitRaw}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
port,
|
|
96
|
+
host,
|
|
97
|
+
nodeEnv: nodeEnv as AppConfig["nodeEnv"],
|
|
98
|
+
shutdownGraceMs,
|
|
99
|
+
corsOrigin,
|
|
100
|
+
bodyLimitBytes,
|
|
101
|
+
bearerToken,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const config = loadConfig();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { config } from "./config";
|
|
3
|
+
|
|
4
|
+
export const HTTP_STATUS = {
|
|
5
|
+
OK: 200,
|
|
6
|
+
CREATED: 201,
|
|
7
|
+
NO_CONTENT: 204,
|
|
8
|
+
BAD_REQUEST: 400,
|
|
9
|
+
UNAUTHORIZED: 401,
|
|
10
|
+
NOT_FOUND: 404,
|
|
11
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
12
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
13
|
+
NOT_IMPLEMENTED: 501,
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export const HTTP_METHOD = {
|
|
17
|
+
GET: "GET",
|
|
18
|
+
POST: "POST",
|
|
19
|
+
PUT: "PUT",
|
|
20
|
+
PATCH: "PATCH",
|
|
21
|
+
DELETE: "DELETE",
|
|
22
|
+
OPTIONS: "OPTIONS",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type HttpMethod = (typeof HTTP_METHOD)[keyof typeof HTTP_METHOD];
|
|
26
|
+
|
|
27
|
+
export const HTTP_CONTENT_TYPE = {
|
|
28
|
+
JSON: "application/json",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export async function readJson<T = unknown>(
|
|
32
|
+
req: IncomingMessage,
|
|
33
|
+
maxBytes = config.bodyLimitBytes,
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
const chunks: Buffer[] = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
39
|
+
size += buf.length;
|
|
40
|
+
if (size > maxBytes) {
|
|
41
|
+
throw new JsonBodyError("Request body too large", HTTP_STATUS.PAYLOAD_TOO_LARGE);
|
|
42
|
+
}
|
|
43
|
+
chunks.push(buf);
|
|
44
|
+
}
|
|
45
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
46
|
+
if (!raw) {
|
|
47
|
+
throw new JsonBodyError("Request body is empty");
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(raw) as T;
|
|
51
|
+
} catch {
|
|
52
|
+
throw new JsonBodyError("Invalid JSON body");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class JsonBodyError extends Error {
|
|
57
|
+
readonly status: number;
|
|
58
|
+
|
|
59
|
+
constructor(message: string, status: number = HTTP_STATUS.BAD_REQUEST) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "JsonBodyError";
|
|
62
|
+
this.status = status;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
|
67
|
+
res.statusCode = status;
|
|
68
|
+
res.setHeader("Content-Type", HTTP_CONTENT_TYPE.JSON);
|
|
69
|
+
res.end(JSON.stringify(data));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function sendError(res: ServerResponse, status: number, message: string): void {
|
|
73
|
+
sendJson(res, status, { error: message });
|
|
74
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type ListQuery = {
|
|
2
|
+
limit: number;
|
|
3
|
+
offset: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const LIST_DEFAULT_LIMIT = 50;
|
|
7
|
+
export const LIST_MAX_LIMIT = 100;
|
|
8
|
+
|
|
9
|
+
export type ListQueryResult =
|
|
10
|
+
| { ok: true; value: ListQuery }
|
|
11
|
+
| { ok: false; message: string };
|
|
12
|
+
|
|
13
|
+
const INVALID_LIST_QUERY = "Invalid list query";
|
|
14
|
+
|
|
15
|
+
function parseCount(raw: string | null, fallback: number): number | null {
|
|
16
|
+
if (raw === null || raw.length === 0) {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
if (!/^\d+$/.test(raw)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return Number(raw);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseListQuery(search: URLSearchParams): ListQueryResult {
|
|
26
|
+
const limit = parseCount(search.get("limit"), LIST_DEFAULT_LIMIT);
|
|
27
|
+
const offset = parseCount(search.get("offset"), 0);
|
|
28
|
+
if (limit === null || offset === null) {
|
|
29
|
+
return { ok: false, message: INVALID_LIST_QUERY };
|
|
30
|
+
}
|
|
31
|
+
if (limit < 1 || limit > LIST_MAX_LIMIT) {
|
|
32
|
+
return { ok: false, message: INVALID_LIST_QUERY };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, value: { limit, offset } };
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { config } from "./config";
|
|
2
|
+
|
|
3
|
+
type LogMeta = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
function formatMessage(level: string, message: string, meta?: LogMeta): string {
|
|
6
|
+
if (!meta || Object.keys(meta).length === 0) {
|
|
7
|
+
return `[${level}] ${message}`;
|
|
8
|
+
}
|
|
9
|
+
return `[${level}] ${message} ${JSON.stringify(meta)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const logger = {
|
|
13
|
+
info(message: string, meta?: LogMeta): void {
|
|
14
|
+
console.log(formatMessage("INFO", message, meta));
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
error(message: string, err?: unknown): void {
|
|
18
|
+
if (err instanceof Error) {
|
|
19
|
+
console.error(formatMessage("ERROR", message, { name: err.name, message: err.message }));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (err !== undefined) {
|
|
23
|
+
console.error(formatMessage("ERROR", message, { err }));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
console.error(formatMessage("ERROR", message));
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
debug(message: string, meta?: LogMeta): void {
|
|
30
|
+
if (config.nodeEnv === "production") {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
console.debug(formatMessage("DEBUG", message, meta));
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { config } from "../config";
|
|
4
|
+
import { HTTP_METHOD, HTTP_STATUS, sendError } from "../http";
|
|
5
|
+
import type { Middleware } from "./types";
|
|
6
|
+
|
|
7
|
+
function tokensEqual(left: string, right: string): boolean {
|
|
8
|
+
const a = Buffer.from(left);
|
|
9
|
+
const b = Buffer.from(right);
|
|
10
|
+
if (a.length !== b.length) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return timingSafeEqual(a, b);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readBearer(header: string | string[] | undefined): string | undefined {
|
|
17
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
18
|
+
if (!value) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const match = /^Bearer\s+(\S+)/i.exec(value);
|
|
22
|
+
return match?.[1];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requestPath(req: IncomingMessage): string {
|
|
26
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
27
|
+
const pathname = url.pathname;
|
|
28
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
29
|
+
return pathname.slice(0, -1);
|
|
30
|
+
}
|
|
31
|
+
return pathname || "/";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const bearerAuth: Middleware = async (
|
|
35
|
+
req: IncomingMessage,
|
|
36
|
+
res: ServerResponse,
|
|
37
|
+
next,
|
|
38
|
+
) => {
|
|
39
|
+
if (config.bearerToken.length === 0) {
|
|
40
|
+
await next();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const method = (req.method ?? HTTP_METHOD.GET).toUpperCase();
|
|
45
|
+
if (
|
|
46
|
+
method === HTTP_METHOD.OPTIONS ||
|
|
47
|
+
(method === HTTP_METHOD.GET && requestPath(req) === "/health")
|
|
48
|
+
) {
|
|
49
|
+
await next();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const token = readBearer(req.headers.authorization);
|
|
54
|
+
if (!token || !tokensEqual(token, config.bearerToken)) {
|
|
55
|
+
sendError(res, HTTP_STATUS.UNAUTHORIZED, "Unauthorized");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await next();
|
|
60
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { config } from "../config";
|
|
3
|
+
import { HTTP_METHOD, HTTP_STATUS, sendError, type HttpMethod } from "../http";
|
|
4
|
+
import type { Middleware } from "./types";
|
|
5
|
+
|
|
6
|
+
const BODY_METHODS = new Set<HttpMethod>([
|
|
7
|
+
HTTP_METHOD.POST,
|
|
8
|
+
HTTP_METHOD.PUT,
|
|
9
|
+
HTTP_METHOD.PATCH,
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const bodyLimit: Middleware = async (
|
|
13
|
+
req: IncomingMessage,
|
|
14
|
+
res: ServerResponse,
|
|
15
|
+
next,
|
|
16
|
+
) => {
|
|
17
|
+
const method = (req.method ?? HTTP_METHOD.GET).toUpperCase() as HttpMethod;
|
|
18
|
+
if (!BODY_METHODS.has(method)) {
|
|
19
|
+
await next();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const raw = req.headers["content-length"];
|
|
24
|
+
if (raw !== undefined) {
|
|
25
|
+
const length = Number(raw);
|
|
26
|
+
if (!Number.isInteger(length) || length < 0) {
|
|
27
|
+
sendError(res, HTTP_STATUS.BAD_REQUEST, "Invalid Content-Length");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (length > config.bodyLimitBytes) {
|
|
31
|
+
sendError(res, HTTP_STATUS.PAYLOAD_TOO_LARGE, "Request body too large");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await next();
|
|
37
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { config } from "../config";
|
|
3
|
+
import { HTTP_METHOD, HTTP_STATUS } from "../http";
|
|
4
|
+
import type { Middleware } from "./types";
|
|
5
|
+
|
|
6
|
+
const ALLOW_METHODS = [
|
|
7
|
+
HTTP_METHOD.GET,
|
|
8
|
+
HTTP_METHOD.POST,
|
|
9
|
+
HTTP_METHOD.PATCH,
|
|
10
|
+
HTTP_METHOD.PUT,
|
|
11
|
+
HTTP_METHOD.DELETE,
|
|
12
|
+
HTTP_METHOD.OPTIONS,
|
|
13
|
+
].join(", ");
|
|
14
|
+
const ALLOW_HEADERS = "Content-Type, Authorization";
|
|
15
|
+
|
|
16
|
+
function allowedOrigin(requestOrigin: string | undefined): string | undefined {
|
|
17
|
+
const configured = config.corsOrigin;
|
|
18
|
+
if (configured.length === 0) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (configured === "*") {
|
|
22
|
+
return "*";
|
|
23
|
+
}
|
|
24
|
+
if (!requestOrigin) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const allowed = configured.split(",").map((part) => part.trim()).filter(Boolean);
|
|
28
|
+
return allowed.includes(requestOrigin) ? requestOrigin : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const cors: Middleware = async (req: IncomingMessage, res: ServerResponse, next) => {
|
|
32
|
+
const allow = allowedOrigin(req.headers.origin);
|
|
33
|
+
if (allow) {
|
|
34
|
+
res.setHeader("Access-Control-Allow-Origin", allow);
|
|
35
|
+
res.setHeader("Access-Control-Allow-Methods", ALLOW_METHODS);
|
|
36
|
+
res.setHeader("Access-Control-Allow-Headers", ALLOW_HEADERS);
|
|
37
|
+
if (allow !== "*") {
|
|
38
|
+
res.setHeader("Vary", "Origin");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if ((req.method ?? HTTP_METHOD.GET).toUpperCase() === HTTP_METHOD.OPTIONS) {
|
|
43
|
+
if (config.corsOrigin.length === 0) {
|
|
44
|
+
await next();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
res.statusCode = HTTP_STATUS.NO_CONTENT;
|
|
48
|
+
res.end();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await next();
|
|
53
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { logger } from "../logger";
|
|
3
|
+
import { sendError, HTTP_STATUS } from "../http";
|
|
4
|
+
|
|
5
|
+
export function handleRequestError(
|
|
6
|
+
_req: IncomingMessage,
|
|
7
|
+
res: ServerResponse,
|
|
8
|
+
err: unknown,
|
|
9
|
+
): void {
|
|
10
|
+
logger.error("Request failed", err);
|
|
11
|
+
if (res.headersSent) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
sendError(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, "Internal Server Error");
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Middleware } from "./types";
|
|
2
|
+
import { HTTP_METHOD } from "../http";
|
|
3
|
+
import { logger } from "../logger";
|
|
4
|
+
|
|
5
|
+
export const requestLogger: Middleware = async (req, _res, next) => {
|
|
6
|
+
logger.info(`${req.method ?? HTTP_METHOD.GET} ${req.url ?? "/"}`);
|
|
7
|
+
await next();
|
|
8
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { sendError, HTTP_METHOD, HTTP_STATUS, type HttpMethod } from "./http";
|
|
3
|
+
|
|
4
|
+
export type Params = Record<string, string>;
|
|
5
|
+
|
|
6
|
+
export type Handler = (
|
|
7
|
+
req: IncomingMessage,
|
|
8
|
+
res: ServerResponse,
|
|
9
|
+
params: Params,
|
|
10
|
+
) => void | Promise<void>;
|
|
11
|
+
|
|
12
|
+
export type Route = {
|
|
13
|
+
method: HttpMethod;
|
|
14
|
+
path: string;
|
|
15
|
+
handler: Handler;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type CompiledRoute = {
|
|
19
|
+
method: HttpMethod;
|
|
20
|
+
regex: RegExp;
|
|
21
|
+
keys: string[];
|
|
22
|
+
handler: Handler;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function normalizePath(pathname: string): string {
|
|
26
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
27
|
+
return pathname.slice(0, -1);
|
|
28
|
+
}
|
|
29
|
+
return pathname || "/";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compilePath(path: string): { regex: RegExp; keys: string[] } {
|
|
33
|
+
const keys: string[] = [];
|
|
34
|
+
const segments = path.split("/").filter(Boolean);
|
|
35
|
+
const parts = segments.map((seg) => {
|
|
36
|
+
if (seg.startsWith(":")) {
|
|
37
|
+
keys.push(seg.slice(1));
|
|
38
|
+
return "([^/]+)";
|
|
39
|
+
}
|
|
40
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
|
+
});
|
|
42
|
+
const pattern = "^/" + parts.join("/") + "$";
|
|
43
|
+
return { regex: new RegExp(pattern), keys };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createRouter(routes: Route[]): Handler {
|
|
47
|
+
const compiled: CompiledRoute[] = routes.map((r) => {
|
|
48
|
+
const { regex, keys } = compilePath(r.path);
|
|
49
|
+
return { method: r.method, regex, keys, handler: r.handler };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return async (req, res, _params) => {
|
|
53
|
+
const method = (req.method ?? HTTP_METHOD.GET).toUpperCase() as HttpMethod;
|
|
54
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
55
|
+
const pathname = normalizePath(url.pathname);
|
|
56
|
+
|
|
57
|
+
for (const route of compiled) {
|
|
58
|
+
if (route.method !== method) continue;
|
|
59
|
+
const match = pathname.match(route.regex);
|
|
60
|
+
if (!match) continue;
|
|
61
|
+
|
|
62
|
+
const params: Params = {};
|
|
63
|
+
route.keys.forEach((key, i) => {
|
|
64
|
+
params[key] = decodeURIComponent(match[i + 1] ?? "");
|
|
65
|
+
});
|
|
66
|
+
await route.handler(req, res, params);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
sendError(res, HTTP_STATUS.NOT_FOUND, "Not Found");
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { handleRequestError } from "./middleware/error-handler";
|
|
4
|
+
|
|
5
|
+
export type RequestListener = (
|
|
6
|
+
req: IncomingMessage,
|
|
7
|
+
res: ServerResponse,
|
|
8
|
+
) => void | Promise<void>;
|
|
9
|
+
|
|
10
|
+
export function createServer(listener: RequestListener): Server {
|
|
11
|
+
return createHttpServer((req, res) => {
|
|
12
|
+
void Promise.resolve(listener(req, res)).catch((err: unknown) => {
|
|
13
|
+
handleRequestError(req, res, err);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { createRouter, type Route } from "./app/router";
|
|
3
|
+
import type { Middleware } from "./app/middleware/types";
|
|
4
|
+
import { buildAppRoutes } from "./app/composition";
|
|
5
|
+
import { handleRequestError } from "./app/middleware/error-handler";
|
|
6
|
+
import { requestLogger } from "./app/middleware/request-logger";
|
|
7
|
+
import { cors } from "./app/middleware/cors";
|
|
8
|
+
import { bodyLimit } from "./app/middleware/body-limit";
|
|
9
|
+
import { bearerAuth } from "./app/middleware/bearer-auth";
|
|
10
|
+
|
|
11
|
+
const middleware: Middleware[] = [cors, bodyLimit, requestLogger, bearerAuth];
|
|
12
|
+
const routes: Route[] = buildAppRoutes();
|
|
13
|
+
|
|
14
|
+
export function createApp(): (
|
|
15
|
+
req: IncomingMessage,
|
|
16
|
+
res: ServerResponse,
|
|
17
|
+
) => void | Promise<void> {
|
|
18
|
+
const router = createRouter(routes);
|
|
19
|
+
|
|
20
|
+
return async (req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
let index = 0;
|
|
23
|
+
const run = async (): Promise<void> => {
|
|
24
|
+
if (index >= middleware.length) {
|
|
25
|
+
await router(req, res, {});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const mw = middleware[index++]!;
|
|
29
|
+
await mw(req, res, run);
|
|
30
|
+
};
|
|
31
|
+
await run();
|
|
32
|
+
} catch (err) {
|
|
33
|
+
handleRequestError(req, res, err);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Route } from "../app/router";
|
|
2
|
+
import { HTTP_METHOD, sendJson, HTTP_STATUS } from "../app/http";
|
|
3
|
+
|
|
4
|
+
export const healthRoutes: Route[] = [
|
|
5
|
+
{
|
|
6
|
+
method: HTTP_METHOD.GET,
|
|
7
|
+
path: "/health",
|
|
8
|
+
handler: (_req, res) => {
|
|
9
|
+
sendJson(res, HTTP_STATUS.OK, { status: "ok" });
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createServer } from "./app/server";
|
|
2
|
+
import { createApp } from "./app";
|
|
3
|
+
import { config } from "./app/config";
|
|
4
|
+
import { logger } from "./app/logger";
|
|
5
|
+
|
|
6
|
+
const listener = createApp();
|
|
7
|
+
const server = createServer(listener);
|
|
8
|
+
|
|
9
|
+
server.listen(config.port, config.host, () => {
|
|
10
|
+
logger.info(`Server listening on http://${config.host}:${config.port}`);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
let shuttingDown = false;
|
|
14
|
+
|
|
15
|
+
function shutdown(signal: string): void {
|
|
16
|
+
if (shuttingDown) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
shuttingDown = true;
|
|
20
|
+
logger.info(`Received ${signal}, shutting down...`);
|
|
21
|
+
|
|
22
|
+
const forceTimer = setTimeout(() => {
|
|
23
|
+
server.closeAllConnections();
|
|
24
|
+
}, config.shutdownGraceMs);
|
|
25
|
+
forceTimer.unref();
|
|
26
|
+
|
|
27
|
+
server.close((err) => {
|
|
28
|
+
clearTimeout(forceTimer);
|
|
29
|
+
if (err) {
|
|
30
|
+
logger.error("Shutdown error", err);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
37
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|