@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 CHANGED
@@ -1,139 +1,92 @@
1
- # framework
1
+ # @devxcrew/framework
2
2
 
3
- Own reusable configuration validation and native HTTP primitives.
3
+ Shared Node and TypeScript runtime for Codexsun apps. It provides module lifecycle, HTTP, validation, logging, health, security, and a browser API client. Business rules stay in each app module. Platform Core owns identity, roles, and tenancy.
4
4
 
5
- Use the sibling workspace layout. Shared framework and UI keep their existing public exports and
6
- build contracts.
5
+ ## Install
7
6
 
8
- ## Run
9
-
10
- Use Node 26.10 or newer and the package manifest requirements. Clone the sibling tools and
11
- mcp-governance repositories along with this repository.
7
+ Use Node 26.10 or newer.
12
8
 
13
9
  ```powershell
14
- npm install
15
- npm run check
10
+ npm install @devxcrew/framework
16
11
  ```
17
12
 
18
- Cxsun runs npm run build:framework using its compiler to build this package.
13
+ Import server exports from `@devxcrew/framework`. Import the browser client from `@devxcrew/framework/client`.
14
+
15
+ ## Owner modules and routes
16
+
17
+ Each app module exposes a public provider contract. The app composition root connects providers and passes their routes to `createApiRouter`. Keep Zod schemas, controllers, services, and persistence inside the owner module.
18
+
19
+ ```ts
20
+ import {
21
+ composeModules,
22
+ createApiRouter,
23
+ createApplicationServer,
24
+ createModuleToken,
25
+ defineModuleProvider,
26
+ type ApiRoute,
27
+ } from "@devxcrew/framework";
28
+
29
+ const statusToken = createModuleToken<{ routes: ApiRoute[] }>("status");
30
+ const status = defineModuleProvider({
31
+ token: statusToken,
32
+ dependencies: [],
33
+ create: () => ({
34
+ routes: [
35
+ {
36
+ method: "GET",
37
+ path: "/api/v1/status",
38
+ handler(_request, response) {
39
+ response.setHeader("Content-Type", "application/json");
40
+ response.end(JSON.stringify({ data: { ready: true } }));
41
+ },
42
+ },
43
+ ],
44
+ }),
45
+ });
46
+
47
+ const modules = composeModules([status]);
48
+ await modules.start();
49
+ const server = createApplicationServer({
50
+ config: {
51
+ name: "example",
52
+ port: 3000,
53
+ url: "http://localhost:3000",
54
+ host: "localhost",
55
+ mode: "development",
56
+ },
57
+ frontendDirectory: "dist/web",
58
+ apiHandler: createApiRouter([modules.get(statusToken)]),
59
+ });
60
+ server.listen(3000);
61
+ ```
19
62
 
20
- ## Repository records
63
+ `createApiRouter` matches methods and paths. Static paths take priority over `:id` paths. Owners validate route IDs, query strings, and JSON bodies. The router does not implement CRUD or business rules.
21
64
 
22
- - `AGENTS.md`: repository instructions and ownership rules.
23
- - `agent/SKILLS.md`: repository capabilities.
24
- - `agent/TASK.md`: current task and status.
25
- - `agent/PLAN.md`: next steps.
26
- - `agent/CHANGELOG.md`: versioned changes and validation results.
65
+ `composeModules` also supports the older string-based `ModuleProvider` contract. New modules can use tokens for typed dependencies and runtime token checks.
27
66
 
28
- ## Shared guidance
67
+ ## HTTP and validation
29
68
 
30
- Retrieve shared documentation and rules only from `https://mcp.codexsun.com/mcp` using `npm run mcp:connect`.
31
- A successful authenticated connection is required before repository work. Stop and report connection failures.
32
- Do not use local guides or cached instructions as fallback. Instruction retrieval does not authorize actions.
69
+ - `readJsonBody` limits bytes and returns `unknown`. Validate it with an owner Zod schema through `parseWithSchema`.
70
+ - Validation failures return HTTP 422 with `message` and `errors`. The older `error.fields` shape remains during migration.
71
+ - `parseListQuery` checks page size and allowed sort fields. Owner schemas check business filters.
72
+ - Owners return resource data as `{ data }` and lists as `{ data, meta, links }`.
73
+ - `createApiClient` reads JSON responses and safe field errors. It does not add authentication or retry writes.
33
74
 
34
- Retrieve shared documentation and rules only from `https://mcp.codexsun.com/mcp` using `npm run mcp:connect`.
35
- A successful authenticated connection is required before repository work. Stop and report connection failures.
36
- Do not use local guides or cached instructions as fallback. Instruction retrieval does not authorize actions.
75
+ ## Operations
37
76
 
38
- Configure these values with `.env.example`:
77
+ - `createApplicationServer` sets basic security headers. Configure exact CORS origins, trusted proxy addresses, and rate limits for each app.
78
+ - The built-in rate limiter is local to one process. Set `rateLimit.store` to an app-owned atomic shared store when deployment uses multiple instances. Store failures return 503.
79
+ - Set `logger` for request logs. Set `onRequestComplete` to pass request data to a metrics or tracing adapter. Neither receives URL query values.
80
+ - Set `readiness` to a sync or async check. The server bounds it with `readinessTimeoutMs`. `createAsyncHealthProvider` bounds named async dependency checks.
81
+ - API handlers and module start and stop hooks have configurable deadlines. Owners must honor cancellation and release resources.
39
82
 
40
- - `MCP_SERVER_URL`
41
- - `MCP_SERVER_SECRET`
42
- - `APP_ID`
43
- - `APP_USER`
83
+ ## Work on this repository
44
84
 
45
- Keep the secret in ignored `.env` files.
85
+ Connect to live governance before repository work. Stop if the authenticated connection fails. Do not use local or cached guidance as a fallback.
46
86
 
47
87
  ```powershell
48
88
  npm run mcp:connect
49
- npm run mcp:verify
50
- ```
51
-
52
- Use `mcp:connect` to retrieve instructions. Use `mcp:verify` for a strict connection test.
53
- Connection failures do not block application work. Editor registration uses the central connection
54
- template and depends on the editor.
55
-
56
- ## Maintenance
57
-
58
- ```powershell
59
- npm run version-bump -- --dry-run
60
- npm run version-bump -- --title "Release title" --note "Change details"
61
- npm run check:versions
62
- npm run fix:line-endings
63
- npm run lines:check
64
- npm run github:now -- --dry-run
89
+ npm run release:check
65
90
  ```
66
91
 
67
- Version bumps align `package.json`, `package-lock.json`, and `agent/CHANGELOG.md`. Record changes
68
- and validation before committing.
69
-
70
- Commit subjects use `#<patch> - <release title>`. For example:
71
- `#5 - Central governance and repository agent layout`.
72
-
73
- Review the changed files before an authorized `npm run github:now`. Do not bump again when the
74
- release version is already prepared.
75
-
76
- ## Tools source and publication
77
-
78
- Workspace maintenance delegates to `shared/tools`. The installed npm package remains pinned at
79
- `0.1.3` until agent changelog support is published.
80
-
81
- GitHub source releases use `github:now`. Npm publication requires separate authorization.
82
-
83
- ## npm package
84
-
85
- Framework publishes compiled ESM JavaScript and TypeScript declarations. Run npm run build before local development consumption.
86
-
87
- Run `npm run release:check`, then `npm publish --access public` from this repository.
88
- Only public exports are supported. App manifests use npm versions.
89
-
90
- ## Public runtime contracts
91
-
92
- `composeModules` registers owner providers in dependency order. Factories receive only their declared dependencies.
93
- `start` runs lifecycle hooks. Failed startup calls stop hooks in reverse order, including the failed module.
94
- `stop` attempts every cleanup hook and reports cleanup failures together.
95
-
96
- `createApplicationServer` keeps the existing static and development behavior.
97
- Optional `apiHandler` receives the request, response, and request context for `/api` paths.
98
- The handler owns routing and independent Zod validation before service execution.
99
- Unexpected handler failures return a safe JSON error with a generated request ID.
100
- Optional `readiness` exposes `/health/ready`. Readiness reports the consumer's actual dependency state.
101
- Receive and header timeouts are configurable positive millisecond values. They do not bound handler execution.
102
-
103
- `readJsonBody` checks content type and limits bytes. Its result is unknown until the owner validates it.
104
- `parseListQuery` validates pagination and allowlisted sort fields. Owners validate domain filters independently.
105
- `HttpError` carries safe transport errors and optional field messages. Do not place secrets in these messages.
106
- `createRequestContext` supplies a server-generated request ID and an abort signal.
107
- Identity, tenant scope, transactions, and domain rules remain with their owner providers.
108
-
109
- Provider factories must only compose values. Acquire connections and other resources inside start hooks so failed startup can release them.
110
- Stop during startup is rejected. Concurrent stop calls share one cleanup operation.
111
- Oversized streamed JSON is drained without destroying the response socket, allowing a safe 413 response.
112
-
113
- ## Handler and shutdown deadlines
114
-
115
- Set `handlerTimeoutMs` on `createApplicationServer` to bound API response time. The default is 30000 milliseconds.
116
- Request contexts expose `deadlineAt` and `signal`. At the deadline, the server aborts the signal.
117
- It returns a safe 504 before response headers or destroys an incomplete streamed response.
118
- Handlers must honor cancellation and check response state before writing late results.
119
- A deadline cannot stop synchronous JavaScript or forcibly cancel an uncooperative dependency.
120
-
121
- Set `shutdownTimeoutMs` in the second argument of `composeModules`. The default total budget is 30000 milliseconds.
122
- Stop attempts every hook in reverse dependency order. The budget bounds awaited asynchronous cleanup.
123
- A timed-out hook can continue running. State becomes `failed`, and stop rejects with cleanup failures.
124
- Consumers must report failed cleanup and apply their process termination policy.
125
-
126
- ## Startup deadline
127
-
128
- Set startupTimeoutMs in composeModules options. The default total startup budget is 30000 milliseconds.
129
- Start hooks receive the owner provider and an AbortSignal. Timeout aborts the signal and rolls back started modules.
130
- An uncooperative hook can continue. Owners must honor cancellation before acquiring or retaining resources.
131
-
132
- ## Consumer transaction and cancellation contract
133
-
134
- Framework owns request deadlines and cancellation signals. The module that owns a mutation owns its database transaction.
135
- A module validates authorization and input before persistence. It checks cancellation before a write and before committing.
136
- Cancellation after a successful commit does not roll back committed data. A client must reload before retrying an uncertain mutation.
137
- Existing identity updates use expectedVersion for stale-write detection. Token completion uses a single database claim.
138
- Do not retry POST mutations automatically. Add an idempotency key only when a real consumer requires repeatable retries.
139
- Synchronous identity operations need no generic event bus or queue. External delivery stays outside a database transaction.
92
+ Keep secrets in ignored environment files. Commit and publish through the repository release workflow only when authorized.
@@ -1,12 +1,26 @@
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";
5
+ import type { Logger } from "../modules/logger/logger.provider.js";
6
+ export interface RequestCompletion {
7
+ requestId: string;
8
+ method: string;
9
+ path: string;
10
+ statusCode: number;
11
+ durationMs: number;
12
+ aborted: boolean;
13
+ }
4
14
  export interface ApplicationServerOptions {
5
15
  config: ApplicationConfig;
6
16
  frontendDirectory: string;
7
17
  developmentHandler?: RequestListener;
8
18
  apiHandler?: (request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse, context: ReturnType<typeof createRequestContext>) => void | Promise<void>;
9
- readiness?: () => boolean;
19
+ readiness?: () => boolean | Promise<boolean>;
20
+ readinessTimeoutMs?: number;
21
+ security?: HttpSecurityOptions;
22
+ logger?: Logger;
23
+ onRequestComplete?: (event: RequestCompletion) => void;
10
24
  requestTimeoutMs?: number;
11
25
  headersTimeoutMs?: number;
12
26
  handlerTimeoutMs?: number;
@@ -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,52 +15,134 @@ const contentTypes = {
14
15
  };
15
16
  export function createApplicationServer(options) {
16
17
  const handlerTimeoutMs = positiveTimeout(options.handlerTimeoutMs ?? 30_000);
18
+ const readinessTimeoutMs = positiveTimeout(options.readinessTimeoutMs ?? 2_000);
19
+ const security = createHttpSecurityProvider(options.security);
17
20
  const server = createServer((request, response) => {
18
- if (options.readiness && request.url === "/health/ready") {
19
- if (request.method !== "GET" && request.method !== "HEAD") {
20
- response.writeHead(405, { Allow: "GET, HEAD" });
21
- response.end();
22
- return;
23
- }
24
- let ready = false;
25
- try {
26
- ready = options.readiness();
27
- }
28
- catch {
29
- ready = false;
30
- }
31
- response.writeHead(ready ? 200 : 503, { "Content-Type": "application/json", "Cache-Control": "no-store" });
32
- response.end(request.method === "HEAD" ? undefined : JSON.stringify({ ready }));
33
- return;
34
- }
35
- if (options.apiHandler && /^\/api(?:\/|\?|$)/.test(request.url ?? "")) {
36
- const context = createRequestContext(request, Date.now() + handlerTimeoutMs);
37
- const deadline = setTimeout(() => {
38
- const error = new HttpError(504, "request_timeout", "Request deadline exceeded.");
39
- context.abort(error);
40
- if (!response.writableEnded && !response.destroyed)
41
- writeJsonError(response, error, context.requestId);
42
- }, handlerTimeoutMs);
43
- response.once("finish", () => clearTimeout(deadline));
44
- response.once("close", () => clearTimeout(deadline));
45
- response.setHeader("X-Request-ID", context.requestId);
46
- response.once("close", () => { if (!response.writableFinished)
47
- request.emit("aborted"); });
48
- void Promise.resolve().then(() => options.apiHandler(request, response, context))
49
- .catch((error) => {
50
- if (!response.writableEnded && !response.destroyed)
51
- writeJsonError(response, error, context.requestId);
52
- });
53
- return;
54
- }
55
- if (options.developmentHandler)
56
- return options.developmentHandler(request, response);
57
- void serveFrontend(options.frontendDirectory, request.url || "/", request.method || "GET", response);
21
+ const isApi = /^\/api(?:\/|\?|$)/.test(request.url ?? "");
22
+ const context = createRequestContext(request);
23
+ response.setHeader("X-Request-ID", context.requestId);
24
+ reportRequest(request, response, context.requestId, options);
25
+ void security
26
+ .handleAsync(request, response)
27
+ .then((handled) => {
28
+ if (!handled && !response.destroyed)
29
+ serveRequest(request, response, context, isApi, options, handlerTimeoutMs, readinessTimeoutMs);
30
+ })
31
+ .catch(() => {
32
+ if (!response.writableEnded && !response.destroyed)
33
+ writeJsonError(response, new HttpError(503, "security_unavailable", "Request checks are unavailable."), context.requestId);
34
+ });
58
35
  });
59
36
  server.requestTimeout = positiveTimeout(options.requestTimeoutMs ?? 30_000);
60
37
  server.headersTimeout = positiveTimeout(options.headersTimeoutMs ?? 10_000);
61
38
  return server;
62
39
  }
40
+ function serveRequest(request, response, context, isApi, options, handlerTimeoutMs, readinessTimeoutMs) {
41
+ if (options.readiness && request.url === "/health/ready") {
42
+ void serveReadiness(request, response, options.readiness, readinessTimeoutMs);
43
+ return;
44
+ }
45
+ if (options.apiHandler && isApi) {
46
+ context.deadlineAt = Date.now() + handlerTimeoutMs;
47
+ const deadline = setTimeout(() => {
48
+ const error = new HttpError(504, "request_timeout", "Request deadline exceeded.");
49
+ context.abort(error);
50
+ if (!response.writableEnded && !response.destroyed)
51
+ writeJsonError(response, error, context.requestId);
52
+ }, handlerTimeoutMs);
53
+ response.once("finish", () => clearTimeout(deadline));
54
+ response.once("close", () => clearTimeout(deadline));
55
+ response.once("close", () => {
56
+ if (!response.writableFinished)
57
+ request.emit("aborted");
58
+ });
59
+ void Promise.resolve()
60
+ .then(() => options.apiHandler(request, response, context))
61
+ .catch((error) => {
62
+ if (!response.writableEnded && !response.destroyed)
63
+ writeJsonError(response, error, context.requestId);
64
+ });
65
+ return;
66
+ }
67
+ if (options.developmentHandler) {
68
+ options.developmentHandler(request, response);
69
+ return;
70
+ }
71
+ void serveFrontend(options.frontendDirectory, request.url || "/", request.method || "GET", response);
72
+ }
73
+ async function serveReadiness(request, response, readiness, timeoutMs) {
74
+ if (request.method !== "GET" && request.method !== "HEAD") {
75
+ response.writeHead(405, { Allow: "GET, HEAD" });
76
+ response.end();
77
+ return;
78
+ }
79
+ let timer;
80
+ let ready = false;
81
+ try {
82
+ ready = await Promise.race([
83
+ Promise.resolve().then(readiness),
84
+ new Promise((resolve) => {
85
+ timer = setTimeout(() => resolve(false), timeoutMs);
86
+ }),
87
+ ]);
88
+ }
89
+ catch {
90
+ ready = false;
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ }
95
+ if (response.destroyed)
96
+ return;
97
+ response.writeHead(ready ? 200 : 503, {
98
+ "Content-Type": "application/json",
99
+ "Cache-Control": "no-store",
100
+ });
101
+ response.end(request.method === "HEAD" ? undefined : JSON.stringify({ ready }));
102
+ }
103
+ function reportRequest(request, response, requestId, options) {
104
+ if (!options.logger && !options.onRequestComplete)
105
+ return;
106
+ const startedAt = Date.now();
107
+ let reported = false;
108
+ const report = () => {
109
+ if (reported)
110
+ return;
111
+ reported = true;
112
+ const event = {
113
+ requestId,
114
+ method: request.method ?? "GET",
115
+ path: safePath(request.url),
116
+ statusCode: response.statusCode,
117
+ durationMs: Date.now() - startedAt,
118
+ aborted: !response.writableFinished,
119
+ };
120
+ try {
121
+ options.onRequestComplete?.(event);
122
+ }
123
+ catch {
124
+ /* A diagnostics sink must not affect the response. */
125
+ }
126
+ if (options.logger) {
127
+ const level = event.aborted || event.statusCode >= 500
128
+ ? "error"
129
+ : event.statusCode >= 400
130
+ ? "warn"
131
+ : "info";
132
+ options.logger[level]("HTTP request completed", { ...event });
133
+ }
134
+ };
135
+ response.once("finish", report);
136
+ response.once("close", report);
137
+ }
138
+ function safePath(url) {
139
+ try {
140
+ return new URL(url ?? "/", "http://localhost").pathname;
141
+ }
142
+ catch {
143
+ return "/";
144
+ }
145
+ }
63
146
  function positiveTimeout(value) {
64
147
  if (!Number.isSafeInteger(value) || value < 1)
65
148
  throw new Error("Invalid HTTP timeout.");
@@ -95,8 +178,12 @@ async function serveFrontend(root, requestUrl, method, response) {
95
178
  }
96
179
  const target = extname(pathname) ? file : resolve(root, "index.html");
97
180
  try {
98
- const [actualRoot, actualTarget] = await Promise.all([realpath(root), realpath(target)]);
99
- if (actualTarget !== actualRoot && !actualTarget.startsWith(`${actualRoot}${sep}`)) {
181
+ const [actualRoot, actualTarget] = await Promise.all([
182
+ realpath(root),
183
+ realpath(target),
184
+ ]);
185
+ if (actualTarget !== actualRoot &&
186
+ !actualTarget.startsWith(`${actualRoot}${sep}`)) {
100
187
  response.writeHead(403);
101
188
  response.end("Forbidden");
102
189
  return;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { readApplicationConfig, type ApplicationConfig, } from "./runtime/config.js";
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";
2
+ export { createApplicationServer, type ApplicationServerOptions, type RequestCompletion, } from "./http/server.js";
3
+ export { composeModules, createModuleToken, defineModuleProvider, type ModuleProvider, type ModuleToken, type TypedModuleDefinition, } from "./modules/runtime/runtime.provider.js";
4
+ export { HttpError, createRequestContext, writeJsonError, parseListQuery, readJsonBody, createApiRouter, type ApiMethod, type ApiRoute, type ApiRouteProvider, } 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, type RateLimitStore, } from "./modules/http/http-security.provider.js";
8
+ export { createHealthProvider, createAsyncHealthProvider, type HealthCheck, type HealthProvider, type HealthSnapshot, type AsyncHealthCheck, type AsyncHealthProvider, } 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, createModuleToken, defineModuleProvider, } from "./modules/runtime/runtime.provider.js";
4
+ export { HttpError, createRequestContext, writeJsonError, parseListQuery, readJsonBody, createApiRouter, } 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, createAsyncHealthProvider, } 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,79 @@
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) ??
49
+ (response.status === 422 ? "validation_failed" : "request_failed");
50
+ const message = stringValue(error?.message) ??
51
+ stringValue(envelope?.message) ??
52
+ "The request could not be completed.";
53
+ return new ApiClientError(message, response.status, code, stringValue(error?.requestId) ??
54
+ stringValue(envelope?.requestId) ??
55
+ response.headers.get("x-request-id") ??
56
+ undefined, readFields(error?.fields) ?? readFields(envelope?.errors));
57
+ }
58
+ function asRecord(value) {
59
+ return value !== null && typeof value === "object" && !Array.isArray(value)
60
+ ? value
61
+ : undefined;
62
+ }
63
+ function stringValue(value) {
64
+ return typeof value === "string" ? value : undefined;
65
+ }
66
+ function readFields(value) {
67
+ const source = asRecord(value);
68
+ if (!source)
69
+ return undefined;
70
+ const fields = Object.create(null);
71
+ for (const [name, messages] of Object.entries(source)) {
72
+ if (Array.isArray(messages) &&
73
+ messages.every((message) => typeof message === "string"))
74
+ fields[name] = messages;
75
+ }
76
+ return Object.keys(fields).length
77
+ ? Object.fromEntries(Object.entries(fields))
78
+ : undefined;
79
+ }
@@ -0,0 +1,13 @@
1
+ export type HealthCheck = () => boolean;
2
+ export type HealthSnapshot = Readonly<Record<string, boolean>>;
3
+ export type AsyncHealthCheck = () => boolean | Promise<boolean>;
4
+ export interface HealthProvider {
5
+ isReady(): boolean;
6
+ snapshot(): HealthSnapshot;
7
+ }
8
+ export interface AsyncHealthProvider {
9
+ isReady(): Promise<boolean>;
10
+ snapshot(): Promise<HealthSnapshot>;
11
+ }
12
+ export declare function createHealthProvider(checks: Readonly<Record<string, HealthCheck>>): HealthProvider;
13
+ export declare function createAsyncHealthProvider(checks: Readonly<Record<string, AsyncHealthCheck>>, timeoutMs?: number): AsyncHealthProvider;
@@ -0,0 +1,53 @@
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
+ export function createAsyncHealthProvider(checks, timeoutMs = 2_000) {
16
+ const entries = Object.entries(checks);
17
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
18
+ throw new Error("Invalid health timeout.");
19
+ if (entries.some(([name, check]) => !name.trim() || typeof check !== "function"))
20
+ throw new Error("Health checks require a name and function.");
21
+ const snapshot = async () => Object.fromEntries(await Promise.all(entries.map(async ([name, check]) => [name, await runAsyncCheck(check, timeoutMs)])));
22
+ return {
23
+ snapshot,
24
+ async isReady() {
25
+ return Object.values(await snapshot()).every(Boolean);
26
+ },
27
+ };
28
+ }
29
+ async function runAsyncCheck(check, timeoutMs) {
30
+ let timer;
31
+ try {
32
+ return await Promise.race([
33
+ Promise.resolve().then(check),
34
+ new Promise((resolve) => {
35
+ timer = setTimeout(() => resolve(false), timeoutMs);
36
+ }),
37
+ ]);
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+ function runCheck(check) {
47
+ try {
48
+ return check();
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
@@ -0,0 +1,3 @@
1
+ import type { RateLimitOptions } from "./http-security.provider.js";
2
+ export declare function createLimiter(options: RateLimitOptions): (address: string) => number;
3
+ export declare function validateRateLimit(options: RateLimitOptions): void;