@devxcrew/framework 0.1.9 → 0.1.11
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 -142
- package/dist/http/server.d.ts +13 -1
- package/dist/http/server.js +121 -48
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/modules/api-client/api-client.provider.js +6 -3
- package/dist/modules/health/health.provider.d.ts +6 -0
- package/dist/modules/health/health.provider.js +31 -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 +6 -0
- package/dist/modules/http/http-security.provider.js +90 -92
- package/dist/modules/http/http.provider.d.ts +10 -0
- package/dist/modules/http/http.provider.js +99 -7
- 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.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,162 +1,92 @@
|
|
|
1
|
-
# framework
|
|
1
|
+
# @devxcrew/framework
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
build contracts.
|
|
5
|
+
## Install
|
|
7
6
|
|
|
8
|
-
|
|
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
|
-
|
|
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: (): { routes: ApiRoute[] } => ({
|
|
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
|
-
|
|
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
|
-
- `
|
|
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
|
-
##
|
|
67
|
+
## HTTP and validation
|
|
29
68
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
41
|
-
- `MCP_SERVER_SECRET`
|
|
42
|
-
- `APP_ID`
|
|
43
|
-
- `APP_USER`
|
|
83
|
+
## Work on this repository
|
|
44
84
|
|
|
45
|
-
|
|
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
|
|
89
|
+
npm run release:check
|
|
50
90
|
```
|
|
51
91
|
|
|
52
|
-
|
|
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
|
|
65
|
-
```
|
|
66
|
-
|
|
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
|
-
## 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
|
-
|
|
132
|
-
Provider factories must only compose values. Acquire connections and other resources inside start hooks so failed startup can release them.
|
|
133
|
-
Stop during startup is rejected. Concurrent stop calls share one cleanup operation.
|
|
134
|
-
Oversized streamed JSON is drained without destroying the response socket, allowing a safe 413 response.
|
|
135
|
-
|
|
136
|
-
## Handler and shutdown deadlines
|
|
137
|
-
|
|
138
|
-
Set `handlerTimeoutMs` on `createApplicationServer` to bound API response time. The default is 30000 milliseconds.
|
|
139
|
-
Request contexts expose `deadlineAt` and `signal`. At the deadline, the server aborts the signal.
|
|
140
|
-
It returns a safe 504 before response headers or destroys an incomplete streamed response.
|
|
141
|
-
Handlers must honor cancellation and check response state before writing late results.
|
|
142
|
-
A deadline cannot stop synchronous JavaScript or forcibly cancel an uncooperative dependency.
|
|
143
|
-
|
|
144
|
-
Set `shutdownTimeoutMs` in the second argument of `composeModules`. The default total budget is 30000 milliseconds.
|
|
145
|
-
Stop attempts every hook in reverse dependency order. The budget bounds awaited asynchronous cleanup.
|
|
146
|
-
A timed-out hook can continue running. State becomes `failed`, and stop rejects with cleanup failures.
|
|
147
|
-
Consumers must report failed cleanup and apply their process termination policy.
|
|
148
|
-
|
|
149
|
-
## Startup deadline
|
|
150
|
-
|
|
151
|
-
Set startupTimeoutMs in composeModules options. The default total startup budget is 30000 milliseconds.
|
|
152
|
-
Start hooks receive the owner provider and an AbortSignal. Timeout aborts the signal and rolls back started modules.
|
|
153
|
-
An uncooperative hook can continue. Owners must honor cancellation before acquiring or retaining resources.
|
|
154
|
-
|
|
155
|
-
## Consumer transaction and cancellation contract
|
|
156
|
-
|
|
157
|
-
Framework owns request deadlines and cancellation signals. The module that owns a mutation owns its database transaction.
|
|
158
|
-
A module validates authorization and input before persistence. It checks cancellation before a write and before committing.
|
|
159
|
-
Cancellation after a successful commit does not roll back committed data. A client must reload before retrying an uncertain mutation.
|
|
160
|
-
Existing identity updates use expectedVersion for stale-write detection. Token completion uses a single database claim.
|
|
161
|
-
Do not retry POST mutations automatically. Add an idempotency key only when a real consumer requires repeatable retries.
|
|
162
|
-
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.
|
package/dist/http/server.d.ts
CHANGED
|
@@ -2,13 +2,25 @@ 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
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
|
+
}
|
|
5
14
|
export interface ApplicationServerOptions {
|
|
6
15
|
config: ApplicationConfig;
|
|
7
16
|
frontendDirectory: string;
|
|
8
17
|
developmentHandler?: RequestListener;
|
|
9
18
|
apiHandler?: (request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse, context: ReturnType<typeof createRequestContext>) => void | Promise<void>;
|
|
10
|
-
readiness?: () => boolean
|
|
19
|
+
readiness?: () => boolean | Promise<boolean>;
|
|
20
|
+
readinessTimeoutMs?: number;
|
|
11
21
|
security?: HttpSecurityOptions;
|
|
22
|
+
logger?: Logger;
|
|
23
|
+
onRequestComplete?: (event: RequestCompletion) => void;
|
|
12
24
|
requestTimeoutMs?: number;
|
|
13
25
|
headersTimeoutMs?: number;
|
|
14
26
|
handlerTimeoutMs?: number;
|
package/dist/http/server.js
CHANGED
|
@@ -15,61 +15,134 @@ const contentTypes = {
|
|
|
15
15
|
};
|
|
16
16
|
export function createApplicationServer(options) {
|
|
17
17
|
const handlerTimeoutMs = positiveTimeout(options.handlerTimeoutMs ?? 30_000);
|
|
18
|
+
const readinessTimeoutMs = positiveTimeout(options.readinessTimeoutMs ?? 2_000);
|
|
18
19
|
const security = createHttpSecurityProvider(options.security);
|
|
19
20
|
const server = createServer((request, response) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
response.writeHead(ready ? 200 : 503, {
|
|
36
|
-
"Content-Type": "application/json",
|
|
37
|
-
"Cache-Control": "no-store",
|
|
38
|
-
});
|
|
39
|
-
response.end(request.method === "HEAD" ? undefined : JSON.stringify({ ready }));
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (options.apiHandler && /^\/api(?:\/|\?|$)/.test(request.url ?? "")) {
|
|
43
|
-
const context = createRequestContext(request, Date.now() + handlerTimeoutMs);
|
|
44
|
-
const deadline = setTimeout(() => {
|
|
45
|
-
const error = new HttpError(504, "request_timeout", "Request deadline exceeded.");
|
|
46
|
-
context.abort(error);
|
|
47
|
-
if (!response.writableEnded && !response.destroyed)
|
|
48
|
-
writeJsonError(response, error, context.requestId);
|
|
49
|
-
}, handlerTimeoutMs);
|
|
50
|
-
response.once("finish", () => clearTimeout(deadline));
|
|
51
|
-
response.once("close", () => clearTimeout(deadline));
|
|
52
|
-
response.setHeader("X-Request-ID", context.requestId);
|
|
53
|
-
response.once("close", () => {
|
|
54
|
-
if (!response.writableFinished)
|
|
55
|
-
request.emit("aborted");
|
|
56
|
-
});
|
|
57
|
-
void Promise.resolve()
|
|
58
|
-
.then(() => options.apiHandler(request, response, context))
|
|
59
|
-
.catch((error) => {
|
|
60
|
-
if (!response.writableEnded && !response.destroyed)
|
|
61
|
-
writeJsonError(response, error, context.requestId);
|
|
62
|
-
});
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (options.developmentHandler)
|
|
66
|
-
return options.developmentHandler(request, response);
|
|
67
|
-
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
|
+
});
|
|
68
35
|
});
|
|
69
36
|
server.requestTimeout = positiveTimeout(options.requestTimeoutMs ?? 30_000);
|
|
70
37
|
server.headersTimeout = positiveTimeout(options.headersTimeoutMs ?? 10_000);
|
|
71
38
|
return server;
|
|
72
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
|
+
}
|
|
73
146
|
function positiveTimeout(value) {
|
|
74
147
|
if (!Number.isSafeInteger(value) || value < 1)
|
|
75
148
|
throw new Error("Invalid HTTP timeout.");
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +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
5
|
export { createValidationProvider, parseWithSchema, type ValidationProvider, } from "./modules/validation/validation.provider.js";
|
|
6
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";
|
|
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,8 +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
5
|
export { createValidationProvider, parseWithSchema, } from "./modules/validation/validation.provider.js";
|
|
6
6
|
export { createLogger, } from "./modules/logger/logger.provider.js";
|
|
7
7
|
export { createHttpSecurityProvider, } from "./modules/http/http-security.provider.js";
|
|
8
|
-
export { createHealthProvider, } from "./modules/health/health.provider.js";
|
|
8
|
+
export { createHealthProvider, createAsyncHealthProvider, } from "./modules/health/health.provider.js";
|
|
@@ -45,12 +45,15 @@ async function readPayload(response) {
|
|
|
45
45
|
function toApiError(response, payload) {
|
|
46
46
|
const envelope = asRecord(payload);
|
|
47
47
|
const error = asRecord(envelope?.error);
|
|
48
|
-
const code = stringValue(error?.code) ??
|
|
49
|
-
|
|
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.";
|
|
50
53
|
return new ApiClientError(message, response.status, code, stringValue(error?.requestId) ??
|
|
51
54
|
stringValue(envelope?.requestId) ??
|
|
52
55
|
response.headers.get("x-request-id") ??
|
|
53
|
-
undefined, readFields(error?.fields));
|
|
56
|
+
undefined, readFields(error?.fields) ?? readFields(envelope?.errors));
|
|
54
57
|
}
|
|
55
58
|
function asRecord(value) {
|
|
56
59
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export type HealthCheck = () => boolean;
|
|
2
2
|
export type HealthSnapshot = Readonly<Record<string, boolean>>;
|
|
3
|
+
export type AsyncHealthCheck = () => boolean | Promise<boolean>;
|
|
3
4
|
export interface HealthProvider {
|
|
4
5
|
isReady(): boolean;
|
|
5
6
|
snapshot(): HealthSnapshot;
|
|
6
7
|
}
|
|
8
|
+
export interface AsyncHealthProvider {
|
|
9
|
+
isReady(): Promise<boolean>;
|
|
10
|
+
snapshot(): Promise<HealthSnapshot>;
|
|
11
|
+
}
|
|
7
12
|
export declare function createHealthProvider(checks: Readonly<Record<string, HealthCheck>>): HealthProvider;
|
|
13
|
+
export declare function createAsyncHealthProvider(checks: Readonly<Record<string, AsyncHealthCheck>>, timeoutMs?: number): AsyncHealthProvider;
|
|
@@ -12,6 +12,37 @@ export function createHealthProvider(checks) {
|
|
|
12
12
|
},
|
|
13
13
|
};
|
|
14
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
|
+
}
|
|
15
46
|
function runCheck(check) {
|
|
16
47
|
try {
|
|
17
48
|
return check();
|
|
@@ -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
|
+
}
|
|
@@ -3,6 +3,11 @@ export interface RateLimitOptions {
|
|
|
3
3
|
limit: number;
|
|
4
4
|
windowMs: number;
|
|
5
5
|
maxEntries?: number;
|
|
6
|
+
store?: RateLimitStore;
|
|
7
|
+
storeTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface RateLimitStore {
|
|
10
|
+
consume(address: string, limit: number, windowMs: number): Promise<number>;
|
|
6
11
|
}
|
|
7
12
|
export interface HttpSecurityOptions {
|
|
8
13
|
allowedOrigins?: readonly string[];
|
|
@@ -17,6 +22,7 @@ export interface HttpSecurityOptions {
|
|
|
17
22
|
}
|
|
18
23
|
export interface HttpSecurityProvider {
|
|
19
24
|
handle(request: IncomingMessage, response: ServerResponse): boolean;
|
|
25
|
+
handleAsync(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
|
|
20
26
|
clientAddress(request: IncomingMessage): string;
|
|
21
27
|
}
|
|
22
28
|
export declare function createHttpSecurityProvider(options?: HttpSecurityOptions): HttpSecurityProvider;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import { validateHeaderName, validateHeaderValue, } from "node:http";
|
|
3
|
+
import { createLimiter, validateRateLimit } from "./http-rate-limit.js";
|
|
3
4
|
const defaultMethods = [
|
|
4
5
|
"GET",
|
|
5
6
|
"HEAD",
|
|
@@ -26,75 +27,102 @@ export function createHttpSecurityProvider(options = {}) {
|
|
|
26
27
|
validateHeaderName(name);
|
|
27
28
|
validateHeaderValue(name, value);
|
|
28
29
|
}
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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.");
|
|
32
37
|
const clientAddress = (request) => resolveClientAddress(request, trustedProxies);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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()))) {
|
|
62
81
|
response.writeHead(403, { "Cache-Control": "no-store" });
|
|
63
82
|
response.end();
|
|
64
83
|
return true;
|
|
65
84
|
}
|
|
66
|
-
response.setHeader("Access-Control-Allow-
|
|
67
|
-
if (
|
|
68
|
-
response.setHeader("Access-Control-Allow-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return
|
|
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);
|
|
91
122
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
response.end();
|
|
95
|
-
return true;
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timer);
|
|
96
125
|
}
|
|
97
|
-
return false;
|
|
98
126
|
},
|
|
99
127
|
clientAddress,
|
|
100
128
|
};
|
|
@@ -117,36 +145,6 @@ function resolveClientAddress(request, trustedProxies) {
|
|
|
117
145
|
}
|
|
118
146
|
return peer;
|
|
119
147
|
}
|
|
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
148
|
function validateOrigin(value) {
|
|
151
149
|
let parsed;
|
|
152
150
|
try {
|
|
@@ -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)
|
|
@@ -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);
|
|
@@ -12,7 +12,7 @@ export function parseWithSchema(schema, input) {
|
|
|
12
12
|
: "_form";
|
|
13
13
|
(fields[path] ??= []).push(issue.message);
|
|
14
14
|
}
|
|
15
|
-
throw new HttpError(422, "validation_failed", "
|
|
15
|
+
throw new HttpError(422, "validation_failed", "Validation failed", Object.fromEntries(Object.entries(fields)));
|
|
16
16
|
}
|
|
17
17
|
return result.data;
|
|
18
18
|
}
|