@devxcrew/framework 0.1.8 → 0.1.9
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 +23 -0
- package/dist/http/server.d.ts +2 -0
- package/dist/http/server.js +21 -7
- package/dist/index.d.ts +6 -2
- 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 +76 -0
- package/dist/modules/health/health.provider.d.ts +7 -0
- package/dist/modules/health/health.provider.js +22 -0
- package/dist/modules/http/http-security.provider.d.ts +22 -0
- package/dist/modules/http/http-security.provider.js +190 -0
- package/dist/modules/logger/logger.provider.d.ts +22 -0
- package/dist/modules/logger/logger.provider.js +88 -0
- package/dist/modules/validation/validation.provider.d.ts +6 -0
- package/dist/modules/validation/validation.provider.js +18 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -106,6 +106,29 @@ Receive and header timeouts are configurable positive millisecond values. They d
|
|
|
106
106
|
`createRequestContext` supplies a server-generated request ID and an abort signal.
|
|
107
107
|
Identity, tenant scope, transactions, and domain rules remain with their owner providers.
|
|
108
108
|
|
|
109
|
+
## Owner providers
|
|
110
|
+
|
|
111
|
+
Use `parseWithSchema` or `createValidationProvider().parse` to validate untrusted server input with a Zod schema.
|
|
112
|
+
Validation failures throw `HttpError` with status 422 and safe field messages. Keep schemas and domain rules in the application module.
|
|
113
|
+
|
|
114
|
+
Use `createLogger` for JSON logs. Set the minimum level and pass a sink for a logging adapter.
|
|
115
|
+
Child loggers keep request context. The logger masks values under secret-like field names, bearer tokens, URL credentials, and common secret query parameters.
|
|
116
|
+
|
|
117
|
+
Use `createHealthProvider` with named, synchronous dependency checks.
|
|
118
|
+
`isReady()` returns false when any check fails. `snapshot()` reports check names and results. Expose that detail only through an authorized operations route.
|
|
119
|
+
|
|
120
|
+
`createApplicationServer` applies `X-Content-Type-Options`, `X-Frame-Options`, and `Referrer-Policy` by default.
|
|
121
|
+
Set `security.allowedOrigins` to enable exact-origin CORS. A configured allowlist rejects requests with other Origin values.
|
|
122
|
+
Set `security.headers` for application-specific headers. Configure HSTS only when the public connection uses HTTPS.
|
|
123
|
+
Set `security.trustedProxyAddresses` to exact proxy IP addresses before trusting `X-Forwarded-For`.
|
|
124
|
+
Set `security.rateLimit` to enable an in-memory fixed-window limit by client address. It applies to all requests in one process.
|
|
125
|
+
Use a shared rate-limit service when multiple app processes must share a limit.
|
|
126
|
+
|
|
127
|
+
Import the browser-safe client from `@devxcrew/framework/client`.
|
|
128
|
+
`createApiClient` accepts a base URL, optional common headers, and an optional Fetch implementation.
|
|
129
|
+
Its `request` method passes cancellation through, returns JSON responses, and throws `ApiClientError` for non-success responses.
|
|
130
|
+
The client reads only the Framework safe error envelope. It does not add authentication or retry mutations.
|
|
131
|
+
|
|
109
132
|
Provider factories must only compose values. Acquire connections and other resources inside start hooks so failed startup can release them.
|
|
110
133
|
Stop during startup is rejected. Concurrent stop calls share one cleanup operation.
|
|
111
134
|
Oversized streamed JSON is drained without destroying the response socket, allowing a safe 413 response.
|
package/dist/http/server.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { type RequestListener } from "node:http";
|
|
2
2
|
import type { ApplicationConfig } from "../runtime/config.js";
|
|
3
3
|
import { createRequestContext } from "../modules/http/http.provider.js";
|
|
4
|
+
import { type HttpSecurityOptions } from "../modules/http/http-security.provider.js";
|
|
4
5
|
export interface ApplicationServerOptions {
|
|
5
6
|
config: ApplicationConfig;
|
|
6
7
|
frontendDirectory: string;
|
|
7
8
|
developmentHandler?: RequestListener;
|
|
8
9
|
apiHandler?: (request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse, context: ReturnType<typeof createRequestContext>) => void | Promise<void>;
|
|
9
10
|
readiness?: () => boolean;
|
|
11
|
+
security?: HttpSecurityOptions;
|
|
10
12
|
requestTimeoutMs?: number;
|
|
11
13
|
headersTimeoutMs?: number;
|
|
12
14
|
handlerTimeoutMs?: number;
|
package/dist/http/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { readFile, realpath } from "node:fs/promises";
|
|
3
3
|
import { extname, resolve, sep } from "node:path";
|
|
4
|
-
import { createRequestContext, writeJsonError, HttpError } from "../modules/http/http.provider.js";
|
|
4
|
+
import { createRequestContext, writeJsonError, HttpError, } from "../modules/http/http.provider.js";
|
|
5
|
+
import { createHttpSecurityProvider, } from "../modules/http/http-security.provider.js";
|
|
5
6
|
const contentTypes = {
|
|
6
7
|
".html": "text/html; charset=utf-8",
|
|
7
8
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -14,7 +15,10 @@ const contentTypes = {
|
|
|
14
15
|
};
|
|
15
16
|
export function createApplicationServer(options) {
|
|
16
17
|
const handlerTimeoutMs = positiveTimeout(options.handlerTimeoutMs ?? 30_000);
|
|
18
|
+
const security = createHttpSecurityProvider(options.security);
|
|
17
19
|
const server = createServer((request, response) => {
|
|
20
|
+
if (security.handle(request, response))
|
|
21
|
+
return;
|
|
18
22
|
if (options.readiness && request.url === "/health/ready") {
|
|
19
23
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
20
24
|
response.writeHead(405, { Allow: "GET, HEAD" });
|
|
@@ -28,7 +32,10 @@ export function createApplicationServer(options) {
|
|
|
28
32
|
catch {
|
|
29
33
|
ready = false;
|
|
30
34
|
}
|
|
31
|
-
response.writeHead(ready ? 200 : 503, {
|
|
35
|
+
response.writeHead(ready ? 200 : 503, {
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"Cache-Control": "no-store",
|
|
38
|
+
});
|
|
32
39
|
response.end(request.method === "HEAD" ? undefined : JSON.stringify({ ready }));
|
|
33
40
|
return;
|
|
34
41
|
}
|
|
@@ -43,9 +50,12 @@ export function createApplicationServer(options) {
|
|
|
43
50
|
response.once("finish", () => clearTimeout(deadline));
|
|
44
51
|
response.once("close", () => clearTimeout(deadline));
|
|
45
52
|
response.setHeader("X-Request-ID", context.requestId);
|
|
46
|
-
response.once("close", () => {
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
response.once("close", () => {
|
|
54
|
+
if (!response.writableFinished)
|
|
55
|
+
request.emit("aborted");
|
|
56
|
+
});
|
|
57
|
+
void Promise.resolve()
|
|
58
|
+
.then(() => options.apiHandler(request, response, context))
|
|
49
59
|
.catch((error) => {
|
|
50
60
|
if (!response.writableEnded && !response.destroyed)
|
|
51
61
|
writeJsonError(response, error, context.requestId);
|
|
@@ -95,8 +105,12 @@ async function serveFrontend(root, requestUrl, method, response) {
|
|
|
95
105
|
}
|
|
96
106
|
const target = extname(pathname) ? file : resolve(root, "index.html");
|
|
97
107
|
try {
|
|
98
|
-
const [actualRoot, actualTarget] = await Promise.all([
|
|
99
|
-
|
|
108
|
+
const [actualRoot, actualTarget] = await Promise.all([
|
|
109
|
+
realpath(root),
|
|
110
|
+
realpath(target),
|
|
111
|
+
]);
|
|
112
|
+
if (actualTarget !== actualRoot &&
|
|
113
|
+
!actualTarget.startsWith(`${actualRoot}${sep}`)) {
|
|
100
114
|
response.writeHead(403);
|
|
101
115
|
response.end("Forbidden");
|
|
102
116
|
return;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { readApplicationConfig, type ApplicationConfig, } from "./runtime/config.js";
|
|
2
2
|
export { createApplicationServer, type ApplicationServerOptions, } from "./http/server.js";
|
|
3
|
-
export { composeModules, type ModuleProvider } from "./modules/runtime/runtime.provider.js";
|
|
4
|
-
export { HttpError, createRequestContext, writeJsonError, parseListQuery, readJsonBody } from "./modules/http/http.provider.js";
|
|
3
|
+
export { composeModules, type ModuleProvider, } from "./modules/runtime/runtime.provider.js";
|
|
4
|
+
export { HttpError, createRequestContext, writeJsonError, parseListQuery, readJsonBody, } from "./modules/http/http.provider.js";
|
|
5
|
+
export { createValidationProvider, parseWithSchema, type ValidationProvider, } from "./modules/validation/validation.provider.js";
|
|
6
|
+
export { createLogger, type Logger, type LoggerOptions, type LogFields, type LogLevel, type LogRecord, } from "./modules/logger/logger.provider.js";
|
|
7
|
+
export { createHttpSecurityProvider, type HttpSecurityOptions, type HttpSecurityProvider, type RateLimitOptions, } from "./modules/http/http-security.provider.js";
|
|
8
|
+
export { createHealthProvider, type HealthCheck, type HealthProvider, type HealthSnapshot, } from "./modules/health/health.provider.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { readApplicationConfig, } from "./runtime/config.js";
|
|
2
2
|
export { createApplicationServer, } from "./http/server.js";
|
|
3
|
-
export { composeModules } from "./modules/runtime/runtime.provider.js";
|
|
4
|
-
export { HttpError, createRequestContext, writeJsonError, parseListQuery, readJsonBody } from "./modules/http/http.provider.js";
|
|
3
|
+
export { composeModules, } from "./modules/runtime/runtime.provider.js";
|
|
4
|
+
export { HttpError, createRequestContext, writeJsonError, parseListQuery, readJsonBody, } from "./modules/http/http.provider.js";
|
|
5
|
+
export { createValidationProvider, parseWithSchema, } from "./modules/validation/validation.provider.js";
|
|
6
|
+
export { createLogger, } from "./modules/logger/logger.provider.js";
|
|
7
|
+
export { createHttpSecurityProvider, } from "./modules/http/http-security.provider.js";
|
|
8
|
+
export { createHealthProvider, } from "./modules/health/health.provider.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ApiClientOptions {
|
|
2
|
+
baseUrl: string | URL;
|
|
3
|
+
headers?: HeadersInit;
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
export interface ApiClient {
|
|
7
|
+
request<T>(path: string | URL, init?: RequestInit): Promise<T>;
|
|
8
|
+
}
|
|
9
|
+
export declare class ApiClientError extends Error {
|
|
10
|
+
readonly status: number;
|
|
11
|
+
readonly code: string;
|
|
12
|
+
readonly requestId?: string | undefined;
|
|
13
|
+
readonly fields?: Record<string, string[]> | undefined;
|
|
14
|
+
constructor(message: string, status: number, code: string, requestId?: string | undefined, fields?: Record<string, string[]> | undefined);
|
|
15
|
+
}
|
|
16
|
+
export declare function createApiClient(options: ApiClientOptions): ApiClient;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export class ApiClientError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
requestId;
|
|
5
|
+
fields;
|
|
6
|
+
constructor(message, status, code, requestId, fields) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.requestId = requestId;
|
|
11
|
+
this.fields = fields;
|
|
12
|
+
this.name = "ApiClientError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function createApiClient(options) {
|
|
16
|
+
const baseUrl = options.baseUrl instanceof URL ? options.baseUrl : new URL(options.baseUrl);
|
|
17
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
18
|
+
if (typeof fetcher !== "function")
|
|
19
|
+
throw new Error("A Fetch implementation is required.");
|
|
20
|
+
return {
|
|
21
|
+
async request(path, init = {}) {
|
|
22
|
+
const url = path instanceof URL ? path : new URL(path, baseUrl);
|
|
23
|
+
const headers = new Headers(options.headers);
|
|
24
|
+
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
|
25
|
+
const response = await fetcher(url, { ...init, headers });
|
|
26
|
+
if (response.status === 204 || init.method?.toUpperCase() === "HEAD")
|
|
27
|
+
return undefined;
|
|
28
|
+
const payload = await readPayload(response);
|
|
29
|
+
if (!response.ok)
|
|
30
|
+
throw toApiError(response, payload);
|
|
31
|
+
return payload;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
async function readPayload(response) {
|
|
36
|
+
if (!/application\/(?:[a-z0-9.+-]+\+)?json/i.test(response.headers.get("content-type") ?? ""))
|
|
37
|
+
return undefined;
|
|
38
|
+
try {
|
|
39
|
+
return await response.json();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function toApiError(response, payload) {
|
|
46
|
+
const envelope = asRecord(payload);
|
|
47
|
+
const error = asRecord(envelope?.error);
|
|
48
|
+
const code = stringValue(error?.code) ?? "request_failed";
|
|
49
|
+
const message = stringValue(error?.message) ?? "The request could not be completed.";
|
|
50
|
+
return new ApiClientError(message, response.status, code, stringValue(error?.requestId) ??
|
|
51
|
+
stringValue(envelope?.requestId) ??
|
|
52
|
+
response.headers.get("x-request-id") ??
|
|
53
|
+
undefined, readFields(error?.fields));
|
|
54
|
+
}
|
|
55
|
+
function asRecord(value) {
|
|
56
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
57
|
+
? value
|
|
58
|
+
: undefined;
|
|
59
|
+
}
|
|
60
|
+
function stringValue(value) {
|
|
61
|
+
return typeof value === "string" ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
function readFields(value) {
|
|
64
|
+
const source = asRecord(value);
|
|
65
|
+
if (!source)
|
|
66
|
+
return undefined;
|
|
67
|
+
const fields = Object.create(null);
|
|
68
|
+
for (const [name, messages] of Object.entries(source)) {
|
|
69
|
+
if (Array.isArray(messages) &&
|
|
70
|
+
messages.every((message) => typeof message === "string"))
|
|
71
|
+
fields[name] = messages;
|
|
72
|
+
}
|
|
73
|
+
return Object.keys(fields).length
|
|
74
|
+
? Object.fromEntries(Object.entries(fields))
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type HealthCheck = () => boolean;
|
|
2
|
+
export type HealthSnapshot = Readonly<Record<string, boolean>>;
|
|
3
|
+
export interface HealthProvider {
|
|
4
|
+
isReady(): boolean;
|
|
5
|
+
snapshot(): HealthSnapshot;
|
|
6
|
+
}
|
|
7
|
+
export declare function createHealthProvider(checks: Readonly<Record<string, HealthCheck>>): HealthProvider;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function createHealthProvider(checks) {
|
|
2
|
+
const entries = Object.entries(checks);
|
|
3
|
+
if (entries.some(([name, check]) => !name.trim() || typeof check !== "function")) {
|
|
4
|
+
throw new Error("Health checks require a name and function.");
|
|
5
|
+
}
|
|
6
|
+
return {
|
|
7
|
+
isReady() {
|
|
8
|
+
return entries.every(([, check]) => runCheck(check));
|
|
9
|
+
},
|
|
10
|
+
snapshot() {
|
|
11
|
+
return Object.fromEntries(entries.map(([name, check]) => [name, runCheck(check)]));
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function runCheck(check) {
|
|
16
|
+
try {
|
|
17
|
+
return check();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
export interface RateLimitOptions {
|
|
3
|
+
limit: number;
|
|
4
|
+
windowMs: number;
|
|
5
|
+
maxEntries?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface HttpSecurityOptions {
|
|
8
|
+
allowedOrigins?: readonly string[];
|
|
9
|
+
allowedMethods?: readonly string[];
|
|
10
|
+
allowedHeaders?: readonly string[];
|
|
11
|
+
exposedHeaders?: readonly string[];
|
|
12
|
+
allowCredentials?: boolean;
|
|
13
|
+
preflightMaxAgeSeconds?: number;
|
|
14
|
+
headers?: Readonly<Record<string, string>>;
|
|
15
|
+
trustedProxyAddresses?: readonly string[];
|
|
16
|
+
rateLimit?: RateLimitOptions;
|
|
17
|
+
}
|
|
18
|
+
export interface HttpSecurityProvider {
|
|
19
|
+
handle(request: IncomingMessage, response: ServerResponse): boolean;
|
|
20
|
+
clientAddress(request: IncomingMessage): string;
|
|
21
|
+
}
|
|
22
|
+
export declare function createHttpSecurityProvider(options?: HttpSecurityOptions): HttpSecurityProvider;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { validateHeaderName, validateHeaderValue, } from "node:http";
|
|
3
|
+
const defaultMethods = [
|
|
4
|
+
"GET",
|
|
5
|
+
"HEAD",
|
|
6
|
+
"POST",
|
|
7
|
+
"PUT",
|
|
8
|
+
"PATCH",
|
|
9
|
+
"DELETE",
|
|
10
|
+
"OPTIONS",
|
|
11
|
+
];
|
|
12
|
+
const defaultHeaders = ["Content-Type", "Authorization", "X-Request-ID"];
|
|
13
|
+
export function createHttpSecurityProvider(options = {}) {
|
|
14
|
+
const origins = new Set((options.allowedOrigins ?? []).map(validateOrigin));
|
|
15
|
+
const methods = validateTokens(options.allowedMethods ?? defaultMethods, "method", true);
|
|
16
|
+
const headers = validateTokens(options.allowedHeaders ?? defaultHeaders, "header");
|
|
17
|
+
const exposedHeaders = validateTokens(options.exposedHeaders ?? [], "header");
|
|
18
|
+
const trustedProxies = new Set((options.trustedProxyAddresses ?? []).map(validateAddress));
|
|
19
|
+
const maxAge = options.preflightMaxAgeSeconds ?? 600;
|
|
20
|
+
if (!Number.isSafeInteger(maxAge) || maxAge < 0 || maxAge > 86_400)
|
|
21
|
+
throw new Error("Invalid CORS max age.");
|
|
22
|
+
if (options.allowCredentials && origins.size === 0)
|
|
23
|
+
throw new Error("Credentialed CORS requires allowed origins.");
|
|
24
|
+
const customHeaders = Object.entries(options.headers ?? {});
|
|
25
|
+
for (const [name, value] of customHeaders) {
|
|
26
|
+
validateHeaderName(name);
|
|
27
|
+
validateHeaderValue(name, value);
|
|
28
|
+
}
|
|
29
|
+
const limiter = options.rateLimit
|
|
30
|
+
? createLimiter(options.rateLimit)
|
|
31
|
+
: undefined;
|
|
32
|
+
const clientAddress = (request) => resolveClientAddress(request, trustedProxies);
|
|
33
|
+
return {
|
|
34
|
+
handle(request, response) {
|
|
35
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
36
|
+
response.setHeader("X-Frame-Options", "DENY");
|
|
37
|
+
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
38
|
+
for (const [name, value] of customHeaders)
|
|
39
|
+
response.setHeader(name, value);
|
|
40
|
+
if (limiter) {
|
|
41
|
+
const retryAfter = limiter(clientAddress(request));
|
|
42
|
+
if (retryAfter > 0) {
|
|
43
|
+
response.writeHead(429, {
|
|
44
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
45
|
+
"Cache-Control": "no-store",
|
|
46
|
+
"Retry-After": String(retryAfter),
|
|
47
|
+
});
|
|
48
|
+
response.end(JSON.stringify({
|
|
49
|
+
error: {
|
|
50
|
+
code: "rate_limit_exceeded",
|
|
51
|
+
message: "Too many requests.",
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const origin = request.headers.origin;
|
|
58
|
+
if (!origins.size || !origin)
|
|
59
|
+
return false;
|
|
60
|
+
response.setHeader("Vary", appendVary(response.getHeader("Vary"), "Origin"));
|
|
61
|
+
if (!origins.has(origin)) {
|
|
62
|
+
response.writeHead(403, { "Cache-Control": "no-store" });
|
|
63
|
+
response.end();
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
response.setHeader("Access-Control-Allow-Origin", origin);
|
|
67
|
+
if (options.allowCredentials)
|
|
68
|
+
response.setHeader("Access-Control-Allow-Credentials", "true");
|
|
69
|
+
if (exposedHeaders.length)
|
|
70
|
+
response.setHeader("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
71
|
+
const requestedMethod = request.headers["access-control-request-method"];
|
|
72
|
+
if (request.method === "OPTIONS" && requestedMethod) {
|
|
73
|
+
response.setHeader("Vary", appendVary(appendVary(response.getHeader("Vary"), "Access-Control-Request-Method"), "Access-Control-Request-Headers"));
|
|
74
|
+
const requestedHeaders = (request.headers["access-control-request-headers"] ?? "")
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((value) => value.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
if (!methods.includes(requestedMethod) ||
|
|
79
|
+
requestedHeaders.some((name) => !headers.some((allowed) => allowed.toLowerCase() === name.toLowerCase()))) {
|
|
80
|
+
response.writeHead(403, { "Cache-Control": "no-store" });
|
|
81
|
+
response.end();
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
response.setHeader("Access-Control-Allow-Methods", methods.join(", "));
|
|
85
|
+
if (requestedHeaders.length)
|
|
86
|
+
response.setHeader("Access-Control-Allow-Headers", requestedHeaders.join(", "));
|
|
87
|
+
response.setHeader("Access-Control-Max-Age", String(maxAge));
|
|
88
|
+
response.writeHead(204);
|
|
89
|
+
response.end();
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (!methods.includes(request.method ?? "GET")) {
|
|
93
|
+
response.writeHead(405, { Allow: methods.join(", ") });
|
|
94
|
+
response.end();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
},
|
|
99
|
+
clientAddress,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function resolveClientAddress(request, trustedProxies) {
|
|
103
|
+
const peer = normalizeAddress(request.socket.remoteAddress ?? "");
|
|
104
|
+
if (!peer || !trustedProxies.has(peer))
|
|
105
|
+
return peer || "unknown";
|
|
106
|
+
const forwarded = request.headers["x-forwarded-for"];
|
|
107
|
+
if (typeof forwarded !== "string")
|
|
108
|
+
return peer;
|
|
109
|
+
const chain = forwarded
|
|
110
|
+
.split(",")
|
|
111
|
+
.map((value) => normalizeAddress(value.trim()));
|
|
112
|
+
if (!chain.length || chain.some((value) => !value))
|
|
113
|
+
return peer;
|
|
114
|
+
for (const candidate of chain.reverse()) {
|
|
115
|
+
if (!trustedProxies.has(candidate))
|
|
116
|
+
return candidate;
|
|
117
|
+
}
|
|
118
|
+
return peer;
|
|
119
|
+
}
|
|
120
|
+
function createLimiter(options) {
|
|
121
|
+
const maxEntries = options.maxEntries ?? 10_000;
|
|
122
|
+
if (!Number.isSafeInteger(options.limit) ||
|
|
123
|
+
options.limit < 1 ||
|
|
124
|
+
!Number.isSafeInteger(options.windowMs) ||
|
|
125
|
+
options.windowMs < 1 ||
|
|
126
|
+
!Number.isSafeInteger(maxEntries) ||
|
|
127
|
+
maxEntries < 1 ||
|
|
128
|
+
maxEntries > 100_000) {
|
|
129
|
+
throw new Error("Invalid rate limit.");
|
|
130
|
+
}
|
|
131
|
+
const clients = new Map();
|
|
132
|
+
return (address) => {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
for (const [key, record] of clients)
|
|
135
|
+
if (record.resetAt <= now)
|
|
136
|
+
clients.delete(key);
|
|
137
|
+
let record = clients.get(address);
|
|
138
|
+
if (!record) {
|
|
139
|
+
if (clients.size >= maxEntries)
|
|
140
|
+
return Math.ceil(options.windowMs / 1000);
|
|
141
|
+
record = { count: 0, resetAt: now + options.windowMs };
|
|
142
|
+
clients.set(address, record);
|
|
143
|
+
}
|
|
144
|
+
if (record.count >= options.limit)
|
|
145
|
+
return Math.max(1, Math.ceil((record.resetAt - now) / 1000));
|
|
146
|
+
record.count++;
|
|
147
|
+
return 0;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function validateOrigin(value) {
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = new URL(value);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw new Error("Invalid allowed origin.");
|
|
157
|
+
}
|
|
158
|
+
if (!(["http:", "https:"].includes(parsed.protocol) && parsed.origin === value))
|
|
159
|
+
throw new Error("Allowed origins must be exact HTTP origins.");
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
function validateTokens(values, kind, uppercase = false) {
|
|
163
|
+
for (const value of values) {
|
|
164
|
+
if (!value ||
|
|
165
|
+
(uppercase
|
|
166
|
+
? !/^[A-Z]+$/.test(value)
|
|
167
|
+
: !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(value)))
|
|
168
|
+
throw new Error(`Invalid CORS ${kind}.`);
|
|
169
|
+
}
|
|
170
|
+
return [...new Set(values)];
|
|
171
|
+
}
|
|
172
|
+
function validateAddress(value) {
|
|
173
|
+
const address = normalizeAddress(value);
|
|
174
|
+
if (!address)
|
|
175
|
+
throw new Error("Trusted proxies must use IP addresses.");
|
|
176
|
+
return address;
|
|
177
|
+
}
|
|
178
|
+
function normalizeAddress(value) {
|
|
179
|
+
const address = value.toLowerCase().replace(/^::ffff:/, "");
|
|
180
|
+
return isIP(address) ? address : "";
|
|
181
|
+
}
|
|
182
|
+
function appendVary(existing, value) {
|
|
183
|
+
const values = (Array.isArray(existing) ? existing.join(",") : String(existing ?? ""))
|
|
184
|
+
.split(",")
|
|
185
|
+
.map((item) => item.trim())
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
if (!values.some((item) => item.toLowerCase() === value.toLowerCase()))
|
|
188
|
+
values.push(value);
|
|
189
|
+
return values.join(", ");
|
|
190
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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", "The submitted data is invalid.", Object.fromEntries(Object.entries(fields)));
|
|
16
|
+
}
|
|
17
|
+
return result.data;
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devxcrew/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
8
|
"default": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"./client": {
|
|
11
|
+
"types": "./dist/modules/api-client/api-client.provider.d.ts",
|
|
12
|
+
"default": "./dist/modules/api-client/api-client.provider.js"
|
|
9
13
|
}
|
|
10
14
|
},
|
|
11
15
|
"files": [
|
|
@@ -20,10 +24,13 @@
|
|
|
20
24
|
"url": "git+https://github.com/devxcrew/framework.git"
|
|
21
25
|
},
|
|
22
26
|
"devDependencies": {
|
|
23
|
-
"@devxcrew/tools": "0.1.
|
|
27
|
+
"@devxcrew/tools": "0.1.8",
|
|
24
28
|
"@types/node": "^26.0.0",
|
|
25
29
|
"typescript": "6.0.3"
|
|
26
30
|
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"zod": "4.6.5"
|
|
33
|
+
},
|
|
27
34
|
"scripts": {
|
|
28
35
|
"github:now": "devxcrew-tools github:now",
|
|
29
36
|
"version:update": "devxcrew-tools version:bump",
|
|
@@ -44,7 +51,7 @@
|
|
|
44
51
|
"build": "tsc -p tsconfig.json",
|
|
45
52
|
"prepack": "npm run build",
|
|
46
53
|
"release:check": "npm run check && npm run build && npm pack --dry-run --ignore-scripts",
|
|
47
|
-
"test": "npm run build && node --test
|
|
54
|
+
"test": "npm run build && node --test"
|
|
48
55
|
},
|
|
49
56
|
"publishConfig": {
|
|
50
57
|
"access": "public",
|