@devxcrew/framework 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -119
- package/dist/http/server.d.ts +15 -1
- package/dist/http/server.js +130 -43
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -2
- package/dist/modules/api-client/api-client.provider.d.ts +16 -0
- package/dist/modules/api-client/api-client.provider.js +79 -0
- package/dist/modules/health/health.provider.d.ts +13 -0
- package/dist/modules/health/health.provider.js +53 -0
- package/dist/modules/http/http-rate-limit.d.ts +3 -0
- package/dist/modules/http/http-rate-limit.js +32 -0
- package/dist/modules/http/http-security.provider.d.ts +28 -0
- package/dist/modules/http/http-security.provider.js +188 -0
- package/dist/modules/http/http.provider.d.ts +10 -0
- package/dist/modules/http/http.provider.js +99 -7
- package/dist/modules/logger/logger.provider.d.ts +22 -0
- package/dist/modules/logger/logger.provider.js +88 -0
- package/dist/modules/runtime/runtime.provider.d.ts +24 -1
- package/dist/modules/runtime/runtime.provider.js +42 -7
- package/dist/modules/validation/validation.provider.d.ts +6 -0
- package/dist/modules/validation/validation.provider.js +18 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,139 +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: () => ({
|
|
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
|
|
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
|
-
|
|
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.
|
package/dist/http/server.d.ts
CHANGED
|
@@ -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;
|
package/dist/http/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { readFile, realpath } from "node:fs/promises";
|
|
3
3
|
import { extname, resolve, sep } from "node:path";
|
|
4
|
-
import { createRequestContext, writeJsonError, HttpError } from "../modules/http/http.provider.js";
|
|
4
|
+
import { createRequestContext, writeJsonError, HttpError, } from "../modules/http/http.provider.js";
|
|
5
|
+
import { createHttpSecurityProvider, } from "../modules/http/http-security.provider.js";
|
|
5
6
|
const contentTypes = {
|
|
6
7
|
".html": "text/html; charset=utf-8",
|
|
7
8
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -14,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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
catch {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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([
|
|
99
|
-
|
|
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
|
+
}
|