@devxcrew/framework 0.1.8 → 0.1.10
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/README.md +72 -119
- package/dist/http/server.d.ts +15 -1
- package/dist/http/server.js +130 -43
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -2
- package/dist/modules/api-client/api-client.provider.d.ts +16 -0
- package/dist/modules/api-client/api-client.provider.js +79 -0
- package/dist/modules/health/health.provider.d.ts +13 -0
- package/dist/modules/health/health.provider.js +53 -0
- package/dist/modules/http/http-rate-limit.d.ts +3 -0
- package/dist/modules/http/http-rate-limit.js +32 -0
- package/dist/modules/http/http-security.provider.d.ts +28 -0
- package/dist/modules/http/http-security.provider.js +188 -0
- package/dist/modules/http/http.provider.d.ts +10 -0
- package/dist/modules/http/http.provider.js +99 -7
- package/dist/modules/logger/logger.provider.d.ts +22 -0
- package/dist/modules/logger/logger.provider.js +88 -0
- package/dist/modules/runtime/runtime.provider.d.ts +24 -1
- package/dist/modules/runtime/runtime.provider.js +42 -7
- package/dist/modules/validation/validation.provider.d.ts +6 -0
- package/dist/modules/validation/validation.provider.js +18 -0
- package/package.json +10 -3
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function createLimiter(options) {
|
|
2
|
+
const maxEntries = options.maxEntries ?? 10_000;
|
|
3
|
+
const clients = new Map();
|
|
4
|
+
return (address) => {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
for (const [key, record] of clients)
|
|
7
|
+
if (record.resetAt <= now)
|
|
8
|
+
clients.delete(key);
|
|
9
|
+
let record = clients.get(address);
|
|
10
|
+
if (!record) {
|
|
11
|
+
if (clients.size >= maxEntries)
|
|
12
|
+
return Math.ceil(options.windowMs / 1000);
|
|
13
|
+
record = { count: 0, resetAt: now + options.windowMs };
|
|
14
|
+
clients.set(address, record);
|
|
15
|
+
}
|
|
16
|
+
if (record.count >= options.limit)
|
|
17
|
+
return Math.max(1, Math.ceil((record.resetAt - now) / 1000));
|
|
18
|
+
record.count++;
|
|
19
|
+
return 0;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function validateRateLimit(options) {
|
|
23
|
+
const maxEntries = options.maxEntries ?? 10_000;
|
|
24
|
+
if (!Number.isSafeInteger(options.limit) ||
|
|
25
|
+
options.limit < 1 ||
|
|
26
|
+
!Number.isSafeInteger(options.windowMs) ||
|
|
27
|
+
options.windowMs < 1 ||
|
|
28
|
+
!Number.isSafeInteger(maxEntries) ||
|
|
29
|
+
maxEntries < 1 ||
|
|
30
|
+
maxEntries > 100_000)
|
|
31
|
+
throw new Error("Invalid rate limit.");
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
export interface RateLimitOptions {
|
|
3
|
+
limit: number;
|
|
4
|
+
windowMs: number;
|
|
5
|
+
maxEntries?: number;
|
|
6
|
+
store?: RateLimitStore;
|
|
7
|
+
storeTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface RateLimitStore {
|
|
10
|
+
consume(address: string, limit: number, windowMs: number): Promise<number>;
|
|
11
|
+
}
|
|
12
|
+
export interface HttpSecurityOptions {
|
|
13
|
+
allowedOrigins?: readonly string[];
|
|
14
|
+
allowedMethods?: readonly string[];
|
|
15
|
+
allowedHeaders?: readonly string[];
|
|
16
|
+
exposedHeaders?: readonly string[];
|
|
17
|
+
allowCredentials?: boolean;
|
|
18
|
+
preflightMaxAgeSeconds?: number;
|
|
19
|
+
headers?: Readonly<Record<string, string>>;
|
|
20
|
+
trustedProxyAddresses?: readonly string[];
|
|
21
|
+
rateLimit?: RateLimitOptions;
|
|
22
|
+
}
|
|
23
|
+
export interface HttpSecurityProvider {
|
|
24
|
+
handle(request: IncomingMessage, response: ServerResponse): boolean;
|
|
25
|
+
handleAsync(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
|
|
26
|
+
clientAddress(request: IncomingMessage): string;
|
|
27
|
+
}
|
|
28
|
+
export declare function createHttpSecurityProvider(options?: HttpSecurityOptions): HttpSecurityProvider;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { validateHeaderName, validateHeaderValue, } from "node:http";
|
|
3
|
+
import { createLimiter, validateRateLimit } from "./http-rate-limit.js";
|
|
4
|
+
const defaultMethods = [
|
|
5
|
+
"GET",
|
|
6
|
+
"HEAD",
|
|
7
|
+
"POST",
|
|
8
|
+
"PUT",
|
|
9
|
+
"PATCH",
|
|
10
|
+
"DELETE",
|
|
11
|
+
"OPTIONS",
|
|
12
|
+
];
|
|
13
|
+
const defaultHeaders = ["Content-Type", "Authorization", "X-Request-ID"];
|
|
14
|
+
export function createHttpSecurityProvider(options = {}) {
|
|
15
|
+
const origins = new Set((options.allowedOrigins ?? []).map(validateOrigin));
|
|
16
|
+
const methods = validateTokens(options.allowedMethods ?? defaultMethods, "method", true);
|
|
17
|
+
const headers = validateTokens(options.allowedHeaders ?? defaultHeaders, "header");
|
|
18
|
+
const exposedHeaders = validateTokens(options.exposedHeaders ?? [], "header");
|
|
19
|
+
const trustedProxies = new Set((options.trustedProxyAddresses ?? []).map(validateAddress));
|
|
20
|
+
const maxAge = options.preflightMaxAgeSeconds ?? 600;
|
|
21
|
+
if (!Number.isSafeInteger(maxAge) || maxAge < 0 || maxAge > 86_400)
|
|
22
|
+
throw new Error("Invalid CORS max age.");
|
|
23
|
+
if (options.allowCredentials && origins.size === 0)
|
|
24
|
+
throw new Error("Credentialed CORS requires allowed origins.");
|
|
25
|
+
const customHeaders = Object.entries(options.headers ?? {});
|
|
26
|
+
for (const [name, value] of customHeaders) {
|
|
27
|
+
validateHeaderName(name);
|
|
28
|
+
validateHeaderValue(name, value);
|
|
29
|
+
}
|
|
30
|
+
const rateLimit = options.rateLimit;
|
|
31
|
+
if (rateLimit)
|
|
32
|
+
validateRateLimit(rateLimit);
|
|
33
|
+
const limiter = rateLimit && !rateLimit.store ? createLimiter(rateLimit) : undefined;
|
|
34
|
+
const storeTimeoutMs = rateLimit?.storeTimeoutMs ?? 2_000;
|
|
35
|
+
if (!Number.isSafeInteger(storeTimeoutMs) || storeTimeoutMs < 1)
|
|
36
|
+
throw new Error("Invalid rate limit store timeout.");
|
|
37
|
+
const clientAddress = (request) => resolveClientAddress(request, trustedProxies);
|
|
38
|
+
const applyHeaders = (response) => {
|
|
39
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
40
|
+
response.setHeader("X-Frame-Options", "DENY");
|
|
41
|
+
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
42
|
+
for (const [name, value] of customHeaders)
|
|
43
|
+
response.setHeader(name, value);
|
|
44
|
+
};
|
|
45
|
+
const processRequest = (request, response, retryAfter) => {
|
|
46
|
+
applyHeaders(response);
|
|
47
|
+
if (retryAfter > 0) {
|
|
48
|
+
response.writeHead(429, {
|
|
49
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
50
|
+
"Cache-Control": "no-store",
|
|
51
|
+
"Retry-After": String(retryAfter),
|
|
52
|
+
});
|
|
53
|
+
response.end(JSON.stringify({
|
|
54
|
+
error: { code: "rate_limit_exceeded", message: "Too many requests." },
|
|
55
|
+
}));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const origin = request.headers.origin;
|
|
59
|
+
if (!origins.size || !origin)
|
|
60
|
+
return false;
|
|
61
|
+
response.setHeader("Vary", appendVary(response.getHeader("Vary"), "Origin"));
|
|
62
|
+
if (!origins.has(origin)) {
|
|
63
|
+
response.writeHead(403, { "Cache-Control": "no-store" });
|
|
64
|
+
response.end();
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
response.setHeader("Access-Control-Allow-Origin", origin);
|
|
68
|
+
if (options.allowCredentials)
|
|
69
|
+
response.setHeader("Access-Control-Allow-Credentials", "true");
|
|
70
|
+
if (exposedHeaders.length)
|
|
71
|
+
response.setHeader("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
72
|
+
const requestedMethod = request.headers["access-control-request-method"];
|
|
73
|
+
if (request.method === "OPTIONS" && requestedMethod) {
|
|
74
|
+
response.setHeader("Vary", appendVary(appendVary(response.getHeader("Vary"), "Access-Control-Request-Method"), "Access-Control-Request-Headers"));
|
|
75
|
+
const requestedHeaders = (request.headers["access-control-request-headers"] ?? "")
|
|
76
|
+
.split(",")
|
|
77
|
+
.map((value) => value.trim())
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
if (!methods.includes(requestedMethod) ||
|
|
80
|
+
requestedHeaders.some((name) => !headers.some((allowed) => allowed.toLowerCase() === name.toLowerCase()))) {
|
|
81
|
+
response.writeHead(403, { "Cache-Control": "no-store" });
|
|
82
|
+
response.end();
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
response.setHeader("Access-Control-Allow-Methods", methods.join(", "));
|
|
86
|
+
if (requestedHeaders.length)
|
|
87
|
+
response.setHeader("Access-Control-Allow-Headers", requestedHeaders.join(", "));
|
|
88
|
+
response.setHeader("Access-Control-Max-Age", String(maxAge));
|
|
89
|
+
response.writeHead(204);
|
|
90
|
+
response.end();
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (!methods.includes(request.method ?? "GET")) {
|
|
94
|
+
response.writeHead(405, { Allow: methods.join(", ") });
|
|
95
|
+
response.end();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
const handle = (request, response) => {
|
|
101
|
+
if (rateLimit?.store)
|
|
102
|
+
throw new Error("A shared rate limit store requires handleAsync.");
|
|
103
|
+
return processRequest(request, response, limiter?.(clientAddress(request)) ?? 0);
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
handle,
|
|
107
|
+
async handleAsync(request, response) {
|
|
108
|
+
if (!rateLimit?.store)
|
|
109
|
+
return handle(request, response);
|
|
110
|
+
applyHeaders(response);
|
|
111
|
+
let timer;
|
|
112
|
+
try {
|
|
113
|
+
const retryAfter = await Promise.race([
|
|
114
|
+
Promise.resolve().then(() => rateLimit.store.consume(clientAddress(request), rateLimit.limit, rateLimit.windowMs)),
|
|
115
|
+
new Promise((_, reject) => {
|
|
116
|
+
timer = setTimeout(() => reject(new Error("Rate limit store timed out.")), storeTimeoutMs);
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
if (!Number.isSafeInteger(retryAfter) || retryAfter < 0)
|
|
120
|
+
throw new Error("Invalid rate limit store result.");
|
|
121
|
+
return processRequest(request, response, retryAfter);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
clientAddress,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function resolveClientAddress(request, trustedProxies) {
|
|
131
|
+
const peer = normalizeAddress(request.socket.remoteAddress ?? "");
|
|
132
|
+
if (!peer || !trustedProxies.has(peer))
|
|
133
|
+
return peer || "unknown";
|
|
134
|
+
const forwarded = request.headers["x-forwarded-for"];
|
|
135
|
+
if (typeof forwarded !== "string")
|
|
136
|
+
return peer;
|
|
137
|
+
const chain = forwarded
|
|
138
|
+
.split(",")
|
|
139
|
+
.map((value) => normalizeAddress(value.trim()));
|
|
140
|
+
if (!chain.length || chain.some((value) => !value))
|
|
141
|
+
return peer;
|
|
142
|
+
for (const candidate of chain.reverse()) {
|
|
143
|
+
if (!trustedProxies.has(candidate))
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
return peer;
|
|
147
|
+
}
|
|
148
|
+
function validateOrigin(value) {
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = new URL(value);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new Error("Invalid allowed origin.");
|
|
155
|
+
}
|
|
156
|
+
if (!(["http:", "https:"].includes(parsed.protocol) && parsed.origin === value))
|
|
157
|
+
throw new Error("Allowed origins must be exact HTTP origins.");
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
function validateTokens(values, kind, uppercase = false) {
|
|
161
|
+
for (const value of values) {
|
|
162
|
+
if (!value ||
|
|
163
|
+
(uppercase
|
|
164
|
+
? !/^[A-Z]+$/.test(value)
|
|
165
|
+
: !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(value)))
|
|
166
|
+
throw new Error(`Invalid CORS ${kind}.`);
|
|
167
|
+
}
|
|
168
|
+
return [...new Set(values)];
|
|
169
|
+
}
|
|
170
|
+
function validateAddress(value) {
|
|
171
|
+
const address = normalizeAddress(value);
|
|
172
|
+
if (!address)
|
|
173
|
+
throw new Error("Trusted proxies must use IP addresses.");
|
|
174
|
+
return address;
|
|
175
|
+
}
|
|
176
|
+
function normalizeAddress(value) {
|
|
177
|
+
const address = value.toLowerCase().replace(/^::ffff:/, "");
|
|
178
|
+
return isIP(address) ? address : "";
|
|
179
|
+
}
|
|
180
|
+
function appendVary(existing, value) {
|
|
181
|
+
const values = (Array.isArray(existing) ? existing.join(",") : String(existing ?? ""))
|
|
182
|
+
.split(",")
|
|
183
|
+
.map((item) => item.trim())
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
if (!values.some((item) => item.toLowerCase() === value.toLowerCase()))
|
|
186
|
+
values.push(value);
|
|
187
|
+
return values.join(", ");
|
|
188
|
+
}
|
|
@@ -12,6 +12,16 @@ export declare function createRequestContext(request: IncomingMessage, deadlineA
|
|
|
12
12
|
abort: (reason?: unknown) => void;
|
|
13
13
|
};
|
|
14
14
|
export declare function writeJsonError(response: ServerResponse, error: unknown, requestId: string): void;
|
|
15
|
+
export type ApiMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
16
|
+
export interface ApiRoute {
|
|
17
|
+
method: ApiMethod;
|
|
18
|
+
path: string;
|
|
19
|
+
handler(request: IncomingMessage, response: ServerResponse, context: ReturnType<typeof createRequestContext>, params: Readonly<Record<string, string>>): void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface ApiRouteProvider {
|
|
22
|
+
routes: readonly ApiRoute[];
|
|
23
|
+
}
|
|
24
|
+
export declare function createApiRouter(providers: readonly ApiRouteProvider[]): (request: IncomingMessage, response: ServerResponse, context: ReturnType<typeof createRequestContext>) => void | Promise<void>;
|
|
15
25
|
export declare function parseListQuery(search: URLSearchParams, allowedSorts: readonly string[], defaults?: {
|
|
16
26
|
perPage: number;
|
|
17
27
|
maxPerPage: number;
|
|
@@ -16,8 +16,12 @@ export class HttpError extends Error {
|
|
|
16
16
|
export function createRequestContext(request, deadlineAt) {
|
|
17
17
|
const controller = new AbortController();
|
|
18
18
|
request.once("aborted", () => controller.abort());
|
|
19
|
-
return {
|
|
20
|
-
|
|
19
|
+
return {
|
|
20
|
+
requestId: randomUUID(),
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
deadlineAt,
|
|
23
|
+
abort: (reason) => controller.abort(reason),
|
|
24
|
+
};
|
|
21
25
|
}
|
|
22
26
|
export function writeJsonError(response, error, requestId) {
|
|
23
27
|
if (response.headersSent) {
|
|
@@ -26,14 +30,95 @@ export function writeJsonError(response, error, requestId) {
|
|
|
26
30
|
}
|
|
27
31
|
const known = error instanceof HttpError;
|
|
28
32
|
response.writeHead(known ? error.status : 500, {
|
|
29
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
33
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
34
|
+
"Cache-Control": "no-store",
|
|
30
35
|
"X-Request-ID": requestId,
|
|
31
36
|
});
|
|
32
|
-
response.end(JSON.stringify({
|
|
37
|
+
response.end(JSON.stringify({
|
|
38
|
+
error: {
|
|
33
39
|
code: known ? error.code : "internal_error",
|
|
34
40
|
message: known ? error.message : "Unable to complete the request.",
|
|
35
41
|
...(known && error.fields ? { fields: error.fields } : {}),
|
|
36
|
-
},
|
|
42
|
+
},
|
|
43
|
+
requestId,
|
|
44
|
+
...(known && error.status === 422 && error.fields
|
|
45
|
+
? { message: error.message, errors: error.fields }
|
|
46
|
+
: {}),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
export function createApiRouter(providers) {
|
|
50
|
+
const registered = providers
|
|
51
|
+
.flatMap((provider) => provider.routes)
|
|
52
|
+
.map(registerRoute);
|
|
53
|
+
const keys = new Set();
|
|
54
|
+
for (const entry of registered) {
|
|
55
|
+
const key = `${entry.route.method} ${entry.segments.map((part) => (part.startsWith(":") ? ":" : part)).join("/")}`;
|
|
56
|
+
if (keys.has(key))
|
|
57
|
+
throw new Error(`Duplicate API route: ${entry.route.path}`);
|
|
58
|
+
keys.add(key);
|
|
59
|
+
}
|
|
60
|
+
registered.sort((left, right) => right.staticCount - left.staticCount);
|
|
61
|
+
return (request, response, context) => {
|
|
62
|
+
let segments;
|
|
63
|
+
try {
|
|
64
|
+
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
65
|
+
segments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
66
|
+
if (segments.some((part) => part.includes("/") || part.includes("\\")))
|
|
67
|
+
throw new Error("Encoded path separator.");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new HttpError(400, "invalid_url", "Invalid URL.");
|
|
71
|
+
}
|
|
72
|
+
const matching = registered
|
|
73
|
+
.map((entry) => ({ entry, params: matchRoute(entry.segments, segments) }))
|
|
74
|
+
.filter(({ params }) => params !== undefined);
|
|
75
|
+
const mostSpecific = matching.filter(({ entry }) => entry.staticCount === matching[0]?.entry.staticCount);
|
|
76
|
+
const selected = mostSpecific.find(({ entry }) => entry.route.method === request.method);
|
|
77
|
+
if (selected)
|
|
78
|
+
return selected.entry.route.handler(request, response, context, selected.params);
|
|
79
|
+
if (mostSpecific.length) {
|
|
80
|
+
response.setHeader("Allow", [...new Set(mostSpecific.map(({ entry }) => entry.route.method))].join(", "));
|
|
81
|
+
throw new HttpError(405, "method_not_allowed", "Method not allowed.");
|
|
82
|
+
}
|
|
83
|
+
throw new HttpError(404, "not_found", "Resource not found.");
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function registerRoute(route) {
|
|
87
|
+
if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].includes(route.method) ||
|
|
88
|
+
typeof route.handler !== "function")
|
|
89
|
+
throw new Error(`Invalid API route: ${route.path}`);
|
|
90
|
+
if (!route.path.startsWith("/api/v1/") ||
|
|
91
|
+
route.path.endsWith("/") ||
|
|
92
|
+
route.path.includes("?"))
|
|
93
|
+
throw new Error(`Invalid API route: ${route.path}`);
|
|
94
|
+
const segments = route.path.split("/").filter(Boolean);
|
|
95
|
+
for (const part of segments) {
|
|
96
|
+
if (part.startsWith(":")) {
|
|
97
|
+
if (!/^:[A-Za-z_][A-Za-z0-9_]*$/.test(part))
|
|
98
|
+
throw new Error(`Invalid API route: ${route.path}`);
|
|
99
|
+
}
|
|
100
|
+
else if (!/^[A-Za-z0-9_-]+$/.test(part)) {
|
|
101
|
+
throw new Error(`Invalid API route: ${route.path}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
route,
|
|
106
|
+
segments,
|
|
107
|
+
staticCount: segments.filter((part) => !part.startsWith(":")).length,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function matchRoute(pattern, path) {
|
|
111
|
+
if (pattern.length !== path.length)
|
|
112
|
+
return undefined;
|
|
113
|
+
const params = Object.create(null);
|
|
114
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
115
|
+
const part = pattern[index];
|
|
116
|
+
if (part.startsWith(":"))
|
|
117
|
+
params[part.slice(1)] = path[index];
|
|
118
|
+
else if (part !== path[index])
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return params;
|
|
37
122
|
}
|
|
38
123
|
export function parseListQuery(search, allowedSorts, defaults = { perPage: 20, maxPerPage: 100 }) {
|
|
39
124
|
const integer = (key, fallback, max) => {
|
|
@@ -47,12 +132,19 @@ export function parseListQuery(search, allowedSorts, defaults = { perPage: 20, m
|
|
|
47
132
|
};
|
|
48
133
|
const sort = search.get("sort") ?? allowedSorts[0];
|
|
49
134
|
const direction = search.get("direction") ?? "asc";
|
|
50
|
-
if ((sort !== undefined && !allowedSorts.includes(sort)) ||
|
|
135
|
+
if ((sort !== undefined && !allowedSorts.includes(sort)) ||
|
|
136
|
+
!["asc", "desc"].includes(direction)) {
|
|
51
137
|
throw new HttpError(422, "invalid_query", "Invalid sorting.");
|
|
52
138
|
}
|
|
53
139
|
const page = integer("page", 1, 1_000_000);
|
|
54
140
|
const perPage = integer("per_page", defaults.perPage, defaults.maxPerPage);
|
|
55
|
-
return {
|
|
141
|
+
return {
|
|
142
|
+
page,
|
|
143
|
+
perPage,
|
|
144
|
+
offset: (page - 1) * perPage,
|
|
145
|
+
sort,
|
|
146
|
+
direction: direction,
|
|
147
|
+
};
|
|
56
148
|
}
|
|
57
149
|
export async function readJsonBody(request, maxBytes = 1_048_576) {
|
|
58
150
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
2
|
+
export type LogFields = Readonly<Record<string, unknown>>;
|
|
3
|
+
export interface LogRecord {
|
|
4
|
+
timestamp: string;
|
|
5
|
+
level: LogLevel;
|
|
6
|
+
message: string;
|
|
7
|
+
requestId?: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface Logger {
|
|
11
|
+
child(fields: LogFields): Logger;
|
|
12
|
+
debug(message: string, fields?: LogFields): void;
|
|
13
|
+
info(message: string, fields?: LogFields): void;
|
|
14
|
+
warn(message: string, fields?: LogFields): void;
|
|
15
|
+
error(message: string, fields?: LogFields): void;
|
|
16
|
+
}
|
|
17
|
+
export interface LoggerOptions {
|
|
18
|
+
level?: LogLevel;
|
|
19
|
+
sink?: (record: LogRecord) => void;
|
|
20
|
+
context?: LogFields;
|
|
21
|
+
}
|
|
22
|
+
export declare function createLogger(options?: LoggerOptions): Logger;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const priorities = {
|
|
2
|
+
debug: 10,
|
|
3
|
+
info: 20,
|
|
4
|
+
warn: 30,
|
|
5
|
+
error: 40,
|
|
6
|
+
};
|
|
7
|
+
const secretField = /(?:secret|password|passwd|token|authorization|cookie|credential|api[-_]?key|private[-_]?key)/i;
|
|
8
|
+
const secretQuery = /([?&](?:access[-_]?token|refresh[-_]?token|client[-_]?secret|token|secret|password|api[-_]?key)=)[^&#\s]*/gi;
|
|
9
|
+
const bearer = /\bBearer\s+[^\s,;]+/gi;
|
|
10
|
+
const urlCredentials = /(\b[a-z][a-z\d+.-]*:\/\/)[^/@\s]+@/gi;
|
|
11
|
+
export function createLogger(options = {}) {
|
|
12
|
+
const level = options.level ?? "info";
|
|
13
|
+
if (!(level in priorities))
|
|
14
|
+
throw new Error("Invalid log level.");
|
|
15
|
+
const sink = options.sink ?? writeConsoleRecord;
|
|
16
|
+
return makeLogger(level, sink, options.context ?? {});
|
|
17
|
+
}
|
|
18
|
+
function makeLogger(level, sink, context) {
|
|
19
|
+
const write = (entryLevel, message, fields = {}) => {
|
|
20
|
+
if (priorities[entryLevel] < priorities[level])
|
|
21
|
+
return;
|
|
22
|
+
const record = redact({
|
|
23
|
+
...context,
|
|
24
|
+
...fields,
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
level: entryLevel,
|
|
27
|
+
message,
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
sink(record);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* Logging failures must not break application work. */
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
child(fields) {
|
|
38
|
+
return makeLogger(level, sink, { ...context, ...fields });
|
|
39
|
+
},
|
|
40
|
+
debug(message, fields) {
|
|
41
|
+
write("debug", message, fields);
|
|
42
|
+
},
|
|
43
|
+
info(message, fields) {
|
|
44
|
+
write("info", message, fields);
|
|
45
|
+
},
|
|
46
|
+
warn(message, fields) {
|
|
47
|
+
write("warn", message, fields);
|
|
48
|
+
},
|
|
49
|
+
error(message, fields) {
|
|
50
|
+
write("error", message, fields);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function writeConsoleRecord(record) {
|
|
55
|
+
const output = JSON.stringify(record);
|
|
56
|
+
if (record.level === "error")
|
|
57
|
+
console.error(output);
|
|
58
|
+
else if (record.level === "warn")
|
|
59
|
+
console.warn(output);
|
|
60
|
+
else if (record.level === "debug")
|
|
61
|
+
console.debug(output);
|
|
62
|
+
else
|
|
63
|
+
console.info(output);
|
|
64
|
+
}
|
|
65
|
+
function redact(value, parentKey = "", seen = new WeakSet()) {
|
|
66
|
+
if (secretField.test(parentKey))
|
|
67
|
+
return "[REDACTED]";
|
|
68
|
+
if (typeof value === "string") {
|
|
69
|
+
return value
|
|
70
|
+
.replace(bearer, "Bearer [REDACTED]")
|
|
71
|
+
.replace(urlCredentials, "$1[REDACTED]@")
|
|
72
|
+
.replace(secretQuery, "$1[REDACTED]");
|
|
73
|
+
}
|
|
74
|
+
if (value instanceof Error)
|
|
75
|
+
return { name: value.name, message: redact(value.message) };
|
|
76
|
+
if (value instanceof URL)
|
|
77
|
+
return redact(value.toString());
|
|
78
|
+
if (typeof value === "bigint")
|
|
79
|
+
return value.toString();
|
|
80
|
+
if (!value || typeof value !== "object")
|
|
81
|
+
return value;
|
|
82
|
+
if (seen.has(value))
|
|
83
|
+
return "[Circular]";
|
|
84
|
+
seen.add(value);
|
|
85
|
+
if (Array.isArray(value))
|
|
86
|
+
return value.map((item) => redact(item, "", seen));
|
|
87
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item, key, seen)]));
|
|
88
|
+
}
|
|
@@ -1,16 +1,39 @@
|
|
|
1
1
|
export interface ModuleProvider<T = unknown> {
|
|
2
2
|
name: string;
|
|
3
3
|
dependencies?: readonly string[];
|
|
4
|
+
token?: ModuleToken<T>;
|
|
5
|
+
dependencyTokens?: readonly ModuleToken<any>[];
|
|
4
6
|
create(providers: ReadonlyMap<string, unknown>): T;
|
|
5
7
|
start?(provider: T, signal: AbortSignal): void | Promise<void>;
|
|
6
8
|
stop?(provider: T): void | Promise<void>;
|
|
7
9
|
}
|
|
10
|
+
export interface ModuleToken<T> {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly key: symbol;
|
|
13
|
+
readonly valueType?: (value: T) => T;
|
|
14
|
+
}
|
|
15
|
+
type ModuleValues<Tokens extends readonly ModuleToken<any>[]> = {
|
|
16
|
+
[Index in keyof Tokens]: Tokens[Index] extends ModuleToken<infer Value> ? Value : never;
|
|
17
|
+
};
|
|
18
|
+
export interface TypedModuleDefinition<T, Tokens extends readonly ModuleToken<any>[]> {
|
|
19
|
+
token: ModuleToken<T>;
|
|
20
|
+
dependencies: Tokens;
|
|
21
|
+
create(...providers: ModuleValues<Tokens>): T;
|
|
22
|
+
start?(provider: T, signal: AbortSignal): void | Promise<void>;
|
|
23
|
+
stop?(provider: T): void | Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export declare function createModuleToken<T>(name: string): ModuleToken<T>;
|
|
26
|
+
export declare function defineModuleProvider<T, const Tokens extends readonly ModuleToken<any>[]>(definition: TypedModuleDefinition<T, Tokens>): ModuleProvider<T>;
|
|
8
27
|
export declare function composeModules(modules: readonly ModuleProvider<any>[], options?: {
|
|
9
28
|
startupTimeoutMs?: number;
|
|
10
29
|
shutdownTimeoutMs?: number;
|
|
11
30
|
}): {
|
|
12
31
|
readonly state: "created" | "starting" | "ready" | "stopping" | "stopped" | "failed";
|
|
13
|
-
get
|
|
32
|
+
get: {
|
|
33
|
+
<T>(token: ModuleToken<T>): T;
|
|
34
|
+
<T>(name: string): T;
|
|
35
|
+
};
|
|
14
36
|
start(): Promise<void>;
|
|
15
37
|
stop(): Promise<void>;
|
|
16
38
|
};
|
|
39
|
+
export {};
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
export function createModuleToken(name) {
|
|
2
|
+
if (!name.trim())
|
|
3
|
+
throw new Error("A module token needs a name.");
|
|
4
|
+
return { name, key: Symbol(name) };
|
|
5
|
+
}
|
|
6
|
+
export function defineModuleProvider(definition) {
|
|
7
|
+
return {
|
|
8
|
+
name: definition.token.name,
|
|
9
|
+
token: definition.token,
|
|
10
|
+
dependencyTokens: definition.dependencies,
|
|
11
|
+
dependencies: definition.dependencies.map((token) => token.name),
|
|
12
|
+
create(providers) {
|
|
13
|
+
const values = definition.dependencies.map((token) => providers.get(token.name));
|
|
14
|
+
return definition.create(...values);
|
|
15
|
+
},
|
|
16
|
+
start: definition.start,
|
|
17
|
+
stop: definition.stop,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
1
20
|
export function composeModules(modules, options = {}) {
|
|
2
21
|
const startupTimeoutMs = options.startupTimeoutMs ?? 30_000;
|
|
3
22
|
if (!Number.isSafeInteger(startupTimeoutMs) || startupTimeoutMs < 1)
|
|
@@ -9,19 +28,34 @@ export function composeModules(modules, options = {}) {
|
|
|
9
28
|
if (pending.size !== modules.length)
|
|
10
29
|
throw new Error("Duplicate module name.");
|
|
11
30
|
const providers = new Map();
|
|
31
|
+
const tokens = new Map();
|
|
12
32
|
const ordered = [];
|
|
13
33
|
while (pending.size) {
|
|
14
34
|
const ready = [...pending.values()].find((module) => (module.dependencies ?? []).every((name) => providers.has(name)));
|
|
15
35
|
if (!ready)
|
|
16
36
|
throw new Error("Missing or cyclic module dependencies.");
|
|
37
|
+
for (const token of ready.dependencyTokens ?? []) {
|
|
38
|
+
if (tokens.get(token.name) !== token)
|
|
39
|
+
throw new Error(`Module contract mismatch: ${token.name}`);
|
|
40
|
+
}
|
|
17
41
|
const dependencies = new Map((ready.dependencies ?? []).map((name) => [name, providers.get(name)]));
|
|
18
42
|
providers.set(ready.name, ready.create(dependencies));
|
|
43
|
+
if (ready.token)
|
|
44
|
+
tokens.set(ready.name, ready.token);
|
|
19
45
|
ordered.push(ready);
|
|
20
46
|
pending.delete(ready.name);
|
|
21
47
|
}
|
|
22
48
|
const started = [];
|
|
23
49
|
let state = "created";
|
|
24
50
|
let cleanupPromise;
|
|
51
|
+
function get(key) {
|
|
52
|
+
const name = typeof key === "string" ? key : key.name;
|
|
53
|
+
if (!providers.has(name))
|
|
54
|
+
throw new Error(`Unknown module: ${name}`);
|
|
55
|
+
if (typeof key !== "string" && tokens.get(name) !== key)
|
|
56
|
+
throw new Error(`Module contract mismatch: ${name}`);
|
|
57
|
+
return providers.get(name);
|
|
58
|
+
}
|
|
25
59
|
async function cleanup() {
|
|
26
60
|
state = "stopping";
|
|
27
61
|
const failures = [];
|
|
@@ -45,12 +79,10 @@ export function composeModules(modules, options = {}) {
|
|
|
45
79
|
throw new AggregateError(failures, "Module cleanup failed.");
|
|
46
80
|
}
|
|
47
81
|
return {
|
|
48
|
-
get state() {
|
|
49
|
-
|
|
50
|
-
if (!providers.has(name))
|
|
51
|
-
throw new Error(`Unknown module: ${name}`);
|
|
52
|
-
return providers.get(name);
|
|
82
|
+
get state() {
|
|
83
|
+
return state;
|
|
53
84
|
},
|
|
85
|
+
get,
|
|
54
86
|
async start() {
|
|
55
87
|
if (state !== "created")
|
|
56
88
|
throw new Error("Modules can only start once.");
|
|
@@ -92,9 +124,12 @@ export function composeModules(modules, options = {}) {
|
|
|
92
124
|
async function withinDeadline(work, milliseconds, name, phase = "shutdown") {
|
|
93
125
|
let timer;
|
|
94
126
|
try {
|
|
95
|
-
await Promise.race([
|
|
127
|
+
await Promise.race([
|
|
128
|
+
work,
|
|
129
|
+
new Promise((_, reject) => {
|
|
96
130
|
timer = setTimeout(() => reject(new Error(`Module ${phase} deadline exceeded: ${name}`)), milliseconds);
|
|
97
|
-
})
|
|
131
|
+
}),
|
|
132
|
+
]);
|
|
98
133
|
}
|
|
99
134
|
finally {
|
|
100
135
|
clearTimeout(timer);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export interface ValidationProvider {
|
|
3
|
+
parse<T extends z.ZodType>(schema: T, input: unknown): z.output<T>;
|
|
4
|
+
}
|
|
5
|
+
export declare function createValidationProvider(): ValidationProvider;
|
|
6
|
+
export declare function parseWithSchema<T extends z.ZodType>(schema: T, input: unknown): z.output<T>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { HttpError } from "../http/http.provider.js";
|
|
2
|
+
export function createValidationProvider() {
|
|
3
|
+
return { parse: parseWithSchema };
|
|
4
|
+
}
|
|
5
|
+
export function parseWithSchema(schema, input) {
|
|
6
|
+
const result = schema.safeParse(input);
|
|
7
|
+
if (!result.success) {
|
|
8
|
+
const fields = Object.create(null);
|
|
9
|
+
for (const issue of result.error.issues) {
|
|
10
|
+
const path = issue.path.length
|
|
11
|
+
? issue.path.map(String).join(".")
|
|
12
|
+
: "_form";
|
|
13
|
+
(fields[path] ??= []).push(issue.message);
|
|
14
|
+
}
|
|
15
|
+
throw new HttpError(422, "validation_failed", "Validation failed", Object.fromEntries(Object.entries(fields)));
|
|
16
|
+
}
|
|
17
|
+
return result.data;
|
|
18
|
+
}
|