@devxcrew/framework 0.1.8
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/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/http/server.d.ts +14 -0
- package/dist/http/server.js +116 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/modules/http/http.provider.d.ts +25 -0
- package/dist/modules/http/http.provider.js +80 -0
- package/dist/modules/runtime/runtime.provider.d.ts +16 -0
- package/dist/modules/runtime/runtime.provider.js +102 -0
- package/dist/runtime/config.d.ts +8 -0
- package/dist/runtime/config.js +33 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Devxcrew
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# framework
|
|
2
|
+
|
|
3
|
+
Own reusable configuration validation and native HTTP primitives.
|
|
4
|
+
|
|
5
|
+
Use the sibling workspace layout. Shared framework and UI keep their existing public exports and
|
|
6
|
+
build contracts.
|
|
7
|
+
|
|
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.
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
npm install
|
|
15
|
+
npm run check
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Cxsun runs npm run build:framework using its compiler to build this package.
|
|
19
|
+
|
|
20
|
+
## Repository records
|
|
21
|
+
|
|
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.
|
|
27
|
+
|
|
28
|
+
## Shared guidance
|
|
29
|
+
|
|
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.
|
|
33
|
+
|
|
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.
|
|
37
|
+
|
|
38
|
+
Configure these values with `.env.example`:
|
|
39
|
+
|
|
40
|
+
- `MCP_SERVER_URL`
|
|
41
|
+
- `MCP_SERVER_SECRET`
|
|
42
|
+
- `APP_ID`
|
|
43
|
+
- `APP_USER`
|
|
44
|
+
|
|
45
|
+
Keep the secret in ignored `.env` files.
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
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
|
|
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
|
+
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.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RequestListener } from "node:http";
|
|
2
|
+
import type { ApplicationConfig } from "../runtime/config.js";
|
|
3
|
+
import { createRequestContext } from "../modules/http/http.provider.js";
|
|
4
|
+
export interface ApplicationServerOptions {
|
|
5
|
+
config: ApplicationConfig;
|
|
6
|
+
frontendDirectory: string;
|
|
7
|
+
developmentHandler?: RequestListener;
|
|
8
|
+
apiHandler?: (request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse, context: ReturnType<typeof createRequestContext>) => void | Promise<void>;
|
|
9
|
+
readiness?: () => boolean;
|
|
10
|
+
requestTimeoutMs?: number;
|
|
11
|
+
headersTimeoutMs?: number;
|
|
12
|
+
handlerTimeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function createApplicationServer(options: ApplicationServerOptions): import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import { extname, resolve, sep } from "node:path";
|
|
4
|
+
import { createRequestContext, writeJsonError, HttpError } from "../modules/http/http.provider.js";
|
|
5
|
+
const contentTypes = {
|
|
6
|
+
".html": "text/html; charset=utf-8",
|
|
7
|
+
".js": "text/javascript; charset=utf-8",
|
|
8
|
+
".css": "text/css; charset=utf-8",
|
|
9
|
+
".json": "application/json",
|
|
10
|
+
".svg": "image/svg+xml",
|
|
11
|
+
".png": "image/png",
|
|
12
|
+
".ico": "image/x-icon",
|
|
13
|
+
".woff2": "font/woff2",
|
|
14
|
+
};
|
|
15
|
+
export function createApplicationServer(options) {
|
|
16
|
+
const handlerTimeoutMs = positiveTimeout(options.handlerTimeoutMs ?? 30_000);
|
|
17
|
+
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);
|
|
58
|
+
});
|
|
59
|
+
server.requestTimeout = positiveTimeout(options.requestTimeoutMs ?? 30_000);
|
|
60
|
+
server.headersTimeout = positiveTimeout(options.headersTimeoutMs ?? 10_000);
|
|
61
|
+
return server;
|
|
62
|
+
}
|
|
63
|
+
function positiveTimeout(value) {
|
|
64
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
65
|
+
throw new Error("Invalid HTTP timeout.");
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
async function serveFrontend(root, requestUrl, method, response) {
|
|
69
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
70
|
+
response.writeHead(405, { Allow: "GET, HEAD" });
|
|
71
|
+
response.end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let pathname;
|
|
75
|
+
try {
|
|
76
|
+
pathname = decodeURIComponent(new URL(requestUrl, "http://localhost").pathname);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
response.writeHead(400);
|
|
80
|
+
response.end("Invalid URL");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
84
|
+
response.writeHead(404);
|
|
85
|
+
response.end("No backend APIs are configured.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const resolvedRoot = resolve(root);
|
|
89
|
+
const file = resolve(root, `.${pathname}`);
|
|
90
|
+
if ((file !== resolvedRoot && !file.startsWith(`${resolvedRoot}${sep}`)) ||
|
|
91
|
+
pathname.includes("\\")) {
|
|
92
|
+
response.writeHead(403);
|
|
93
|
+
response.end("Forbidden");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const target = extname(pathname) ? file : resolve(root, "index.html");
|
|
97
|
+
try {
|
|
98
|
+
const [actualRoot, actualTarget] = await Promise.all([realpath(root), realpath(target)]);
|
|
99
|
+
if (actualTarget !== actualRoot && !actualTarget.startsWith(`${actualRoot}${sep}`)) {
|
|
100
|
+
response.writeHead(403);
|
|
101
|
+
response.end("Forbidden");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const content = await readFile(target);
|
|
105
|
+
response.writeHead(200, {
|
|
106
|
+
"Content-Type": contentTypes[extname(target)] || "application/octet-stream",
|
|
107
|
+
"Cache-Control": "no-cache",
|
|
108
|
+
});
|
|
109
|
+
response.end(method === "HEAD" ? undefined : content);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const missing = error.code === "ENOENT";
|
|
113
|
+
response.writeHead(missing ? 404 : 500);
|
|
114
|
+
response.end(missing ? "Not found" : "Unable to load frontend");
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
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";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { readApplicationConfig, } from "./runtime/config.js";
|
|
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";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export declare class HttpError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly fields?: Record<string, string[]> | undefined;
|
|
6
|
+
constructor(status: number, code: string, message: string, fields?: Record<string, string[]> | undefined);
|
|
7
|
+
}
|
|
8
|
+
export declare function createRequestContext(request: IncomingMessage, deadlineAt?: number): {
|
|
9
|
+
requestId: `${string}-${string}-${string}-${string}-${string}`;
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
deadlineAt: number | undefined;
|
|
12
|
+
abort: (reason?: unknown) => void;
|
|
13
|
+
};
|
|
14
|
+
export declare function writeJsonError(response: ServerResponse, error: unknown, requestId: string): void;
|
|
15
|
+
export declare function parseListQuery(search: URLSearchParams, allowedSorts: readonly string[], defaults?: {
|
|
16
|
+
perPage: number;
|
|
17
|
+
maxPerPage: number;
|
|
18
|
+
}): {
|
|
19
|
+
page: number;
|
|
20
|
+
perPage: number;
|
|
21
|
+
offset: number;
|
|
22
|
+
sort: string;
|
|
23
|
+
direction: "asc" | "desc";
|
|
24
|
+
};
|
|
25
|
+
export declare function readJsonBody(request: IncomingMessage, maxBytes?: number): Promise<unknown>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
fields;
|
|
6
|
+
constructor(status, code, message, fields) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.fields = fields;
|
|
11
|
+
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
12
|
+
throw new Error("Invalid HTTP error status.");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function createRequestContext(request, deadlineAt) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
request.once("aborted", () => controller.abort());
|
|
19
|
+
return { requestId: randomUUID(), signal: controller.signal, deadlineAt,
|
|
20
|
+
abort: (reason) => controller.abort(reason) };
|
|
21
|
+
}
|
|
22
|
+
export function writeJsonError(response, error, requestId) {
|
|
23
|
+
if (response.headersSent) {
|
|
24
|
+
response.destroy();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const known = error instanceof HttpError;
|
|
28
|
+
response.writeHead(known ? error.status : 500, {
|
|
29
|
+
"Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store",
|
|
30
|
+
"X-Request-ID": requestId,
|
|
31
|
+
});
|
|
32
|
+
response.end(JSON.stringify({ error: {
|
|
33
|
+
code: known ? error.code : "internal_error",
|
|
34
|
+
message: known ? error.message : "Unable to complete the request.",
|
|
35
|
+
...(known && error.fields ? { fields: error.fields } : {}),
|
|
36
|
+
}, requestId }));
|
|
37
|
+
}
|
|
38
|
+
export function parseListQuery(search, allowedSorts, defaults = { perPage: 20, maxPerPage: 100 }) {
|
|
39
|
+
const integer = (key, fallback, max) => {
|
|
40
|
+
const raw = search.get(key);
|
|
41
|
+
if (raw === null)
|
|
42
|
+
return fallback;
|
|
43
|
+
if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > max) {
|
|
44
|
+
throw new HttpError(422, "invalid_query", `Invalid ${key}.`);
|
|
45
|
+
}
|
|
46
|
+
return Number(raw);
|
|
47
|
+
};
|
|
48
|
+
const sort = search.get("sort") ?? allowedSorts[0];
|
|
49
|
+
const direction = search.get("direction") ?? "asc";
|
|
50
|
+
if ((sort !== undefined && !allowedSorts.includes(sort)) || !["asc", "desc"].includes(direction)) {
|
|
51
|
+
throw new HttpError(422, "invalid_query", "Invalid sorting.");
|
|
52
|
+
}
|
|
53
|
+
const page = integer("page", 1, 1_000_000);
|
|
54
|
+
const perPage = integer("per_page", defaults.perPage, defaults.maxPerPage);
|
|
55
|
+
return { page, perPage, offset: (page - 1) * perPage, sort, direction: direction };
|
|
56
|
+
}
|
|
57
|
+
export async function readJsonBody(request, maxBytes = 1_048_576) {
|
|
58
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
59
|
+
throw new Error("Invalid body limit.");
|
|
60
|
+
if (request.headers["content-type"]?.split(";")[0].trim() !== "application/json") {
|
|
61
|
+
throw new HttpError(415, "unsupported_media_type", "Use application/json.");
|
|
62
|
+
}
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let bytes = 0;
|
|
65
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
66
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
67
|
+
bytes += buffer.length;
|
|
68
|
+
if (bytes > maxBytes) {
|
|
69
|
+
request.resume();
|
|
70
|
+
throw new HttpError(413, "body_too_large", "Request body is too large.");
|
|
71
|
+
}
|
|
72
|
+
chunks.push(buffer);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new HttpError(400, "invalid_json", "Invalid JSON body.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ModuleProvider<T = unknown> {
|
|
2
|
+
name: string;
|
|
3
|
+
dependencies?: readonly string[];
|
|
4
|
+
create(providers: ReadonlyMap<string, unknown>): T;
|
|
5
|
+
start?(provider: T, signal: AbortSignal): void | Promise<void>;
|
|
6
|
+
stop?(provider: T): void | Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function composeModules(modules: readonly ModuleProvider<any>[], options?: {
|
|
9
|
+
startupTimeoutMs?: number;
|
|
10
|
+
shutdownTimeoutMs?: number;
|
|
11
|
+
}): {
|
|
12
|
+
readonly state: "created" | "starting" | "ready" | "stopping" | "stopped" | "failed";
|
|
13
|
+
get<T>(name: string): T;
|
|
14
|
+
start(): Promise<void>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export function composeModules(modules, options = {}) {
|
|
2
|
+
const startupTimeoutMs = options.startupTimeoutMs ?? 30_000;
|
|
3
|
+
if (!Number.isSafeInteger(startupTimeoutMs) || startupTimeoutMs < 1)
|
|
4
|
+
throw new Error("Invalid startup timeout.");
|
|
5
|
+
const shutdownTimeoutMs = options.shutdownTimeoutMs ?? 30_000;
|
|
6
|
+
if (!Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1)
|
|
7
|
+
throw new Error("Invalid shutdown timeout.");
|
|
8
|
+
const pending = new Map(modules.map((module) => [module.name, module]));
|
|
9
|
+
if (pending.size !== modules.length)
|
|
10
|
+
throw new Error("Duplicate module name.");
|
|
11
|
+
const providers = new Map();
|
|
12
|
+
const ordered = [];
|
|
13
|
+
while (pending.size) {
|
|
14
|
+
const ready = [...pending.values()].find((module) => (module.dependencies ?? []).every((name) => providers.has(name)));
|
|
15
|
+
if (!ready)
|
|
16
|
+
throw new Error("Missing or cyclic module dependencies.");
|
|
17
|
+
const dependencies = new Map((ready.dependencies ?? []).map((name) => [name, providers.get(name)]));
|
|
18
|
+
providers.set(ready.name, ready.create(dependencies));
|
|
19
|
+
ordered.push(ready);
|
|
20
|
+
pending.delete(ready.name);
|
|
21
|
+
}
|
|
22
|
+
const started = [];
|
|
23
|
+
let state = "created";
|
|
24
|
+
let cleanupPromise;
|
|
25
|
+
async function cleanup() {
|
|
26
|
+
state = "stopping";
|
|
27
|
+
const failures = [];
|
|
28
|
+
const deadline = Date.now() + shutdownTimeoutMs;
|
|
29
|
+
for (const module of started.splice(0).reverse()) {
|
|
30
|
+
try {
|
|
31
|
+
const work = Promise.resolve(module.stop?.(providers.get(module.name)));
|
|
32
|
+
const remaining = deadline - Date.now();
|
|
33
|
+
if (remaining <= 0) {
|
|
34
|
+
void work.catch(() => { });
|
|
35
|
+
throw new Error(`Module shutdown deadline exceeded: ${module.name}`);
|
|
36
|
+
}
|
|
37
|
+
await withinDeadline(work, remaining, module.name);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
failures.push(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
state = failures.length ? "failed" : "stopped";
|
|
44
|
+
if (failures.length)
|
|
45
|
+
throw new AggregateError(failures, "Module cleanup failed.");
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
get state() { return state; },
|
|
49
|
+
get(name) {
|
|
50
|
+
if (!providers.has(name))
|
|
51
|
+
throw new Error(`Unknown module: ${name}`);
|
|
52
|
+
return providers.get(name);
|
|
53
|
+
},
|
|
54
|
+
async start() {
|
|
55
|
+
if (state !== "created")
|
|
56
|
+
throw new Error("Modules can only start once.");
|
|
57
|
+
state = "starting";
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const deadline = Date.now() + startupTimeoutMs;
|
|
60
|
+
try {
|
|
61
|
+
for (const module of ordered) {
|
|
62
|
+
started.push(module);
|
|
63
|
+
const remaining = deadline - Date.now();
|
|
64
|
+
if (remaining <= 0)
|
|
65
|
+
throw new Error(`Module startup deadline exceeded: ${module.name}`);
|
|
66
|
+
await withinDeadline(Promise.resolve(module.start?.(providers.get(module.name), controller.signal)), remaining, module.name, "startup");
|
|
67
|
+
}
|
|
68
|
+
state = "ready";
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
controller.abort(error);
|
|
72
|
+
try {
|
|
73
|
+
cleanupPromise ??= cleanup();
|
|
74
|
+
await cleanupPromise;
|
|
75
|
+
}
|
|
76
|
+
catch (cleanup) {
|
|
77
|
+
throw new AggregateError([error, cleanup], "Startup and cleanup failed.");
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
async stop() {
|
|
83
|
+
if (state === "starting")
|
|
84
|
+
throw new Error("Cannot stop modules during startup.");
|
|
85
|
+
if (state === "stopped")
|
|
86
|
+
return;
|
|
87
|
+
cleanupPromise ??= cleanup();
|
|
88
|
+
await cleanupPromise;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async function withinDeadline(work, milliseconds, name, phase = "shutdown") {
|
|
93
|
+
let timer;
|
|
94
|
+
try {
|
|
95
|
+
await Promise.race([work, new Promise((_, reject) => {
|
|
96
|
+
timer = setTimeout(() => reject(new Error(`Module ${phase} deadline exceeded: ${name}`)), milliseconds);
|
|
97
|
+
})]);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function readApplicationConfig(env) {
|
|
2
|
+
const name = required(env, "APP_NAME");
|
|
3
|
+
const portValue = required(env, "APP_PORT");
|
|
4
|
+
const port = Number(portValue);
|
|
5
|
+
if (!/^\d+$/.test(portValue) || port < 1 || port > 65535)
|
|
6
|
+
throw new Error("APP_PORT must be an integer between 1 and 65535.");
|
|
7
|
+
const mode = required(env, "APP_MODE");
|
|
8
|
+
if (mode !== "development" && mode !== "production")
|
|
9
|
+
throw new Error("APP_MODE must be development or production.");
|
|
10
|
+
const url = new URL(required(env, "APP_URL"));
|
|
11
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
12
|
+
url.username ||
|
|
13
|
+
url.password ||
|
|
14
|
+
url.pathname !== "/" ||
|
|
15
|
+
url.search ||
|
|
16
|
+
url.hash)
|
|
17
|
+
throw new Error("APP_URL must be an HTTP origin without credentials or a path.");
|
|
18
|
+
if (Number(url.port || (url.protocol === "https:" ? 443 : 80)) !== port)
|
|
19
|
+
throw new Error("APP_URL must use APP_PORT.");
|
|
20
|
+
return {
|
|
21
|
+
name,
|
|
22
|
+
port,
|
|
23
|
+
url: url.origin,
|
|
24
|
+
host: env.APP_HOST?.trim() || url.hostname,
|
|
25
|
+
mode,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function required(env, key) {
|
|
29
|
+
const value = env[key]?.trim();
|
|
30
|
+
if (!value)
|
|
31
|
+
throw new Error(`${key} is required.`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devxcrew/framework",
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=26.10.0"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/devxcrew/framework.git"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@devxcrew/tools": "0.1.7",
|
|
24
|
+
"@types/node": "^26.0.0",
|
|
25
|
+
"typescript": "6.0.3"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"github:now": "devxcrew-tools github:now",
|
|
29
|
+
"version:update": "devxcrew-tools version:bump",
|
|
30
|
+
"fix:line-endings": "devxcrew-tools lines:fix",
|
|
31
|
+
"check:versions": "devxcrew-tools check:versions",
|
|
32
|
+
"version:show": "devxcrew-tools version:show",
|
|
33
|
+
"version:bump": "devxcrew-tools version:bump",
|
|
34
|
+
"changelog:append": "devxcrew-tools changelog:append",
|
|
35
|
+
"changelog:show": "devxcrew-tools changelog:show",
|
|
36
|
+
"tools:lines:fix": "devxcrew-tools lines:fix",
|
|
37
|
+
"lines:check": "devxcrew-tools lines:check",
|
|
38
|
+
"tools:check": "devxcrew-tools dependencies:order && devxcrew-tools check:versions && devxcrew-tools lines:check",
|
|
39
|
+
"check": "npm run tools:check && npm test",
|
|
40
|
+
"mcp:connect": "node agent/connect.mjs",
|
|
41
|
+
"mcp:verify": "node agent/connect.mjs --strict",
|
|
42
|
+
"version-bump": "devxcrew-tools version:bump",
|
|
43
|
+
"prepublishOnly": "npm run release:check",
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"prepack": "npm run build",
|
|
46
|
+
"release:check": "npm run check && npm run build && npm pack --dry-run --ignore-scripts",
|
|
47
|
+
"test": "npm run build && node --test tests/*.test.mjs"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"registry": "https://registry.npmjs.org/"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT"
|
|
54
|
+
}
|