@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.
@@ -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 { requestId: randomUUID(), signal: controller.signal, deadlineAt,
20
- abort: (reason) => controller.abort(reason) };
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", "Cache-Control": "no-store",
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({ error: {
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
- }, requestId }));
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)) || !["asc", "desc"].includes(direction)) {
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 { page, perPage, offset: (page - 1) * perPage, sort, direction: direction };
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<T>(name: string): T;
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() { return state; },
49
- get(name) {
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([work, new Promise((_, reject) => {
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
+ }