@arki/http 0.0.1
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 +229 -0
- package/dist/bundle.d.ts +121 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +98 -0
- package/dist/bundle.js.map +1 -0
- package/dist/context.d.ts +29 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +34 -0
- package/dist/context.js.map +1 -0
- package/dist/contract.d.ts +120 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +49 -0
- package/dist/contract.js.map +1 -0
- package/dist/derive.d.ts +58 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +28 -0
- package/dist/derive.js.map +1 -0
- package/dist/dot.d.ts +106 -0
- package/dist/dot.d.ts.map +1 -0
- package/dist/dot.js +210 -0
- package/dist/dot.js.map +1 -0
- package/dist/engine.d.ts +36 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +173 -0
- package/dist/engine.js.map +1 -0
- package/dist/error.d.ts +107 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +122 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/listen.d.ts +23 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +71 -0
- package/dist/listen.js.map +1 -0
- package/dist/openapi.d.ts +45 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +140 -0
- package/dist/openapi.js.map +1 -0
- package/dist/sse.d.ts +12 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +86 -0
- package/dist/sse.js.map +1 -0
- package/package.json +73 -0
- package/src/bundle.ts +221 -0
- package/src/context.ts +39 -0
- package/src/contract.ts +152 -0
- package/src/derive.ts +67 -0
- package/src/dot.ts +282 -0
- package/src/engine.ts +239 -0
- package/src/error.ts +158 -0
- package/src/index.ts +53 -0
- package/src/listen.ts +100 -0
- package/src/openapi.ts +168 -0
- package/src/sse.ts +92 -0
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI generation — falls out of the contracts, doesn't bolt on.
|
|
3
|
+
*
|
|
4
|
+
* Contracts are static data carrying zod schemas, so the document is
|
|
5
|
+
* produced without booting anything, deterministically: same contracts in
|
|
6
|
+
* the same order → byte-identical output (DOT principle 3). No decorator
|
|
7
|
+
* reconstruction, no annotations — the schema IS the validation IS the
|
|
8
|
+
* documentation.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { HTTP_ERROR_CODES, HttpConfigError } from './error.js';
|
|
12
|
+
/**
|
|
13
|
+
* Convert a contract schema to JSON Schema. Schema erasure seam:
|
|
14
|
+
* {@link ContractLike} types schemas as `parse`-only, but contracts built
|
|
15
|
+
* via `route.*` always hold zod schemas (see `SchemaLike` docs) — the
|
|
16
|
+
* cast re-attaches what the erased view dropped.
|
|
17
|
+
*/
|
|
18
|
+
function toJsonSchema(schema) {
|
|
19
|
+
const json = z.toJSONSchema(schema);
|
|
20
|
+
// The dialect header is noise when embedding into OpenAPI 3.1.
|
|
21
|
+
const { $schema: _dialect, ...rest } = json;
|
|
22
|
+
return rest;
|
|
23
|
+
}
|
|
24
|
+
function pathParamNames(path) {
|
|
25
|
+
return [...path.matchAll(/:(\w+)/g)]
|
|
26
|
+
.map(match => match[1])
|
|
27
|
+
.filter((name) => name !== undefined);
|
|
28
|
+
}
|
|
29
|
+
function openApiPath(path) {
|
|
30
|
+
return path.replaceAll(/:(\w+)/g, '{$1}');
|
|
31
|
+
}
|
|
32
|
+
function queryParameters(schema) {
|
|
33
|
+
const json = toJsonSchema(schema);
|
|
34
|
+
const properties = (json['properties'] ?? {});
|
|
35
|
+
const required = new Set(json['required']);
|
|
36
|
+
return Object.entries(properties).map(([name, propertySchema]) => ({
|
|
37
|
+
name,
|
|
38
|
+
in: 'query',
|
|
39
|
+
required: required.has(name),
|
|
40
|
+
schema: propertySchema,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
function buildOperation(contract) {
|
|
44
|
+
const parameters = pathParamNames(contract.path).map(name => ({
|
|
45
|
+
name,
|
|
46
|
+
in: 'path',
|
|
47
|
+
required: true,
|
|
48
|
+
schema: { type: 'string' },
|
|
49
|
+
}));
|
|
50
|
+
if (contract.query !== undefined)
|
|
51
|
+
parameters.push(...queryParameters(contract.query));
|
|
52
|
+
const operation = { operationId: contract.id };
|
|
53
|
+
if (contract.summary !== undefined)
|
|
54
|
+
operation['summary'] = contract.summary;
|
|
55
|
+
if (parameters.length > 0)
|
|
56
|
+
operation['parameters'] = parameters;
|
|
57
|
+
if (contract.body !== undefined) {
|
|
58
|
+
operation['requestBody'] = {
|
|
59
|
+
required: true,
|
|
60
|
+
content: { 'application/json': { schema: toJsonSchema(contract.body) } },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (contract.kind === 'sse') {
|
|
64
|
+
operation['responses'] = {
|
|
65
|
+
'200': {
|
|
66
|
+
description: 'event stream',
|
|
67
|
+
content: {
|
|
68
|
+
'text/event-stream': contract.event === undefined ? {} : { schema: toJsonSchema(contract.event) },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
else if (contract.output === undefined) {
|
|
74
|
+
operation['responses'] = { '200': { description: 'success' } };
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
operation['responses'] = {
|
|
78
|
+
'200': {
|
|
79
|
+
description: 'success',
|
|
80
|
+
content: { 'application/json': { schema: toJsonSchema(contract.output) } },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return operation;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Render an OpenAPI 3.1 document from contracts. Paths and operations
|
|
88
|
+
* appear in contract order; duplicate method+path pairs fail loudly with
|
|
89
|
+
* `ARKI_HTTP_E002` (the same collision `buildEngine` rejects at boot).
|
|
90
|
+
*/
|
|
91
|
+
export function toOpenApi(contracts, info) {
|
|
92
|
+
const paths = {};
|
|
93
|
+
for (const contract of contracts) {
|
|
94
|
+
const path = openApiPath(contract.path);
|
|
95
|
+
const entry = (paths[path] ??= {});
|
|
96
|
+
const method = contract.method.toLowerCase();
|
|
97
|
+
if (entry[method] !== undefined) {
|
|
98
|
+
throw new HttpConfigError({
|
|
99
|
+
code: HTTP_ERROR_CODES.duplicateRoute,
|
|
100
|
+
message: `[http] duplicate route ${contract.method} ${contract.path}: "${contract.id}" collides with an earlier contract.`,
|
|
101
|
+
remediation: 'Two contracts claim the same method and path. Change one path, or drop the duplicate.',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
entry[method] = buildOperation(contract);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
openapi: '3.1.0',
|
|
108
|
+
info: {
|
|
109
|
+
title: info.title,
|
|
110
|
+
version: info.version,
|
|
111
|
+
...(info.description === undefined ? {} : { description: info.description }),
|
|
112
|
+
},
|
|
113
|
+
paths,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Manifest metadata for a contract — what `registerRoutes` (from
|
|
118
|
+
* `@arki/http/dot`) feeds into `ctx.registerRoute` so that
|
|
119
|
+
* `dot explain --openapi` can render without booting. SSE contracts mark
|
|
120
|
+
* `streaming` and carry the per-event schema as `output`.
|
|
121
|
+
*/
|
|
122
|
+
export function contractMeta(contract) {
|
|
123
|
+
const input = {};
|
|
124
|
+
if (contract.query !== undefined)
|
|
125
|
+
input.query = toJsonSchema(contract.query);
|
|
126
|
+
if (contract.body !== undefined)
|
|
127
|
+
input.body = toJsonSchema(contract.body);
|
|
128
|
+
const outputSchema = contract.kind === 'sse' ? contract.event : contract.output;
|
|
129
|
+
return {
|
|
130
|
+
id: contract.id,
|
|
131
|
+
method: contract.method,
|
|
132
|
+
path: contract.path,
|
|
133
|
+
transport: 'http',
|
|
134
|
+
...(contract.summary === undefined ? {} : { description: contract.summary }),
|
|
135
|
+
...(Object.keys(input).length === 0 ? {} : { input }),
|
|
136
|
+
...(outputSchema === undefined ? {} : { output: toJsonSchema(outputSchema) }),
|
|
137
|
+
...(contract.kind === 'sse' ? { streaming: true } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=openapi.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openapi.js","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"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"}
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-sent-events serialization for {@link SseContract} bindings.
|
|
3
|
+
*
|
|
4
|
+
* The bound handler is an async iterable; every yielded value is validated
|
|
5
|
+
* against the contract's event schema and framed as `data: <json>\n\n`.
|
|
6
|
+
* Client disconnect terminates the iterator (its `finally` blocks run);
|
|
7
|
+
* a yielded value that violates the schema — or a generator crash — emits
|
|
8
|
+
* a terminal `event: error` frame with the coded envelope, then closes.
|
|
9
|
+
*/
|
|
10
|
+
import type { ContractLike } from './contract.js';
|
|
11
|
+
export declare function sseResponse(iterable: AsyncIterable<unknown>, contract: ContractLike, signal: AbortSignal): Response;
|
|
12
|
+
//# sourceMappingURL=sse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAUlD,wBAAgB,WAAW,CAAC,QAAQ,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,GAAG,QAAQ,CAuEnH"}
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-sent-events serialization for {@link SseContract} bindings.
|
|
3
|
+
*
|
|
4
|
+
* The bound handler is an async iterable; every yielded value is validated
|
|
5
|
+
* against the contract's event schema and framed as `data: <json>\n\n`.
|
|
6
|
+
* Client disconnect terminates the iterator (its `finally` blocks run);
|
|
7
|
+
* a yielded value that violates the schema — or a generator crash — emits
|
|
8
|
+
* a terminal `event: error` frame with the coded envelope, then closes.
|
|
9
|
+
*/
|
|
10
|
+
import { describeInternalError, HTTP_ERROR_CODES } from './error.js';
|
|
11
|
+
const SSE_HEADERS = {
|
|
12
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
13
|
+
'cache-control': 'no-cache',
|
|
14
|
+
// Disable proxy buffering (nginx and friends) — events must flush.
|
|
15
|
+
'x-accel-buffering': 'no',
|
|
16
|
+
};
|
|
17
|
+
export function sseResponse(iterable, contract, signal) {
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
20
|
+
let heartbeat;
|
|
21
|
+
let finished = false;
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
finished = true;
|
|
24
|
+
if (heartbeat !== undefined)
|
|
25
|
+
clearInterval(heartbeat);
|
|
26
|
+
};
|
|
27
|
+
const stopIterator = async () => {
|
|
28
|
+
try {
|
|
29
|
+
await iterator.return?.();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Generator cleanup failures have no caller to surface to here; the
|
|
33
|
+
// generator's own resources are the pip's responsibility.
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const stream = new ReadableStream({
|
|
37
|
+
start(controller) {
|
|
38
|
+
if (contract.heartbeatMs !== undefined && contract.heartbeatMs > 0) {
|
|
39
|
+
heartbeat = setInterval(() => {
|
|
40
|
+
if (!finished && (controller.desiredSize ?? 0) > 0) {
|
|
41
|
+
controller.enqueue(encoder.encode(':keepalive\n\n'));
|
|
42
|
+
}
|
|
43
|
+
}, contract.heartbeatMs);
|
|
44
|
+
heartbeat.unref();
|
|
45
|
+
}
|
|
46
|
+
signal.addEventListener('abort', () => {
|
|
47
|
+
cleanup();
|
|
48
|
+
void stopIterator();
|
|
49
|
+
}, { once: true });
|
|
50
|
+
},
|
|
51
|
+
async pull(controller) {
|
|
52
|
+
try {
|
|
53
|
+
const next = await iterator.next();
|
|
54
|
+
if (finished)
|
|
55
|
+
return;
|
|
56
|
+
if (next.done === true) {
|
|
57
|
+
cleanup();
|
|
58
|
+
controller.close();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const value = contract.event === undefined ? next.value : contract.event.parse(next.value);
|
|
62
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (finished)
|
|
66
|
+
return;
|
|
67
|
+
cleanup();
|
|
68
|
+
const payload = {
|
|
69
|
+
error: {
|
|
70
|
+
code: HTTP_ERROR_CODES.internal,
|
|
71
|
+
message: describeInternalError(error),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify(payload)}\n\n`));
|
|
75
|
+
controller.close();
|
|
76
|
+
void stopIterator();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
cancel() {
|
|
80
|
+
cleanup();
|
|
81
|
+
void stopIterator();
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=sse.js.map
|
package/dist/sse.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.js","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arki/http",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Typed HTTP for ARKI — route contracts as data, zod-validated handlers, request-scope derivers, SSE streaming, OpenAPI generation, and the DOT http() pip.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/arki-labs/arki.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://arki.dev",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/arki-labs/arki/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"arki",
|
|
16
|
+
"typescript",
|
|
17
|
+
"http",
|
|
18
|
+
"hono",
|
|
19
|
+
"sse",
|
|
20
|
+
"streaming",
|
|
21
|
+
"openapi",
|
|
22
|
+
"dot"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./dot": {
|
|
33
|
+
"types": "./dist/dot.d.ts",
|
|
34
|
+
"import": "./dist/dot.js",
|
|
35
|
+
"default": "./dist/dot.js"
|
|
36
|
+
},
|
|
37
|
+
"./context": {
|
|
38
|
+
"types": "./dist/context.d.ts",
|
|
39
|
+
"import": "./dist/context.js",
|
|
40
|
+
"default": "./dist/context.js"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist/**",
|
|
46
|
+
"src/**",
|
|
47
|
+
"!src/**/*.test.*",
|
|
48
|
+
"!src/**/*.spec.*",
|
|
49
|
+
"!src/**/tests/**",
|
|
50
|
+
"!src/**/test/**",
|
|
51
|
+
"!src/**/__tests__/**",
|
|
52
|
+
"README.md",
|
|
53
|
+
"LICENSE",
|
|
54
|
+
"package.json"
|
|
55
|
+
],
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@arki/log": "0.0.2",
|
|
58
|
+
"@hono/node-server": "^2.0.8",
|
|
59
|
+
"hono": "^4.12.25",
|
|
60
|
+
"zod": "4.3.5"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@arki/dot": "^0.3.0"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"@arki/dot": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route bundles — the dynamic half of an HTTP surface.
|
|
3
|
+
*
|
|
4
|
+
* A bundle binds contracts to handlers. It is assembled inside a pip's
|
|
5
|
+
* `boot` hook — where the pip's `needs` are already injected — so handlers
|
|
6
|
+
* and derivers close over typed singletons:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* export const OrdersRoutes = token<RouteBundle>()('orders.routes');
|
|
10
|
+
*
|
|
11
|
+
* export const ordersPip = pip({
|
|
12
|
+
* name: 'orders',
|
|
13
|
+
* needs: { db: service<Db>() },
|
|
14
|
+
* configure(ctx) {
|
|
15
|
+
* registerRoutes(ctx, [listOrders, createOrder]); // from '@arki/http/dot'
|
|
16
|
+
* },
|
|
17
|
+
* async boot({ db }) {
|
|
18
|
+
* return provide(
|
|
19
|
+
* OrdersRoutes,
|
|
20
|
+
* routes()
|
|
21
|
+
* .bind(listOrders, async ({ query }) => db.orders.list(query))
|
|
22
|
+
* .bind(createOrder, async ({ body }, ctx) => db.orders.create(body, ctx.requestId)),
|
|
23
|
+
* );
|
|
24
|
+
* },
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* The builder accumulates deriver-added context keys in its type — a
|
|
29
|
+
* handler bound after `.derive('principal', …)` sees `ctx.principal`
|
|
30
|
+
* typed. Same accumulation idiom as `defineApp().use()`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { ContractLike, ParamsOf, RouteContract, SseContract } from './contract.js';
|
|
34
|
+
import type { BaseRequestCtx, Deriver, ErasedDeriver } from './derive.js';
|
|
35
|
+
import { BASE_CTX_KEYS } from './derive.js';
|
|
36
|
+
import { HTTP_ERROR_CODES, HttpConfigError } from './error.js';
|
|
37
|
+
|
|
38
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
39
|
+
|
|
40
|
+
/** The validated inputs a bound handler receives. */
|
|
41
|
+
export type RouteInput<TPath extends string, TQuery, TBody> = {
|
|
42
|
+
readonly params: ParamsOf<TPath>;
|
|
43
|
+
readonly query: TQuery;
|
|
44
|
+
readonly body: TBody;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What a JSON handler may return: the contract's output type (serialized
|
|
49
|
+
* and validated), or a raw `Response` as the escape hatch — redirects,
|
|
50
|
+
* files, custom streams. Contracts without an output schema must return
|
|
51
|
+
* a `Response`.
|
|
52
|
+
*/
|
|
53
|
+
export type HandlerResult<TOutput> = [TOutput] extends [undefined] ? Response : TOutput | Response;
|
|
54
|
+
|
|
55
|
+
/** Handler signature inferred from a {@link RouteContract}. */
|
|
56
|
+
export type RouteHandler<TPath extends string, TQuery, TBody, TOutput, TCtx extends BaseRequestCtx> = (
|
|
57
|
+
input: RouteInput<TPath, TQuery, TBody>,
|
|
58
|
+
ctx: TCtx,
|
|
59
|
+
) => MaybePromise<HandlerResult<TOutput>>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Handler signature inferred from an {@link SseContract}: an async
|
|
63
|
+
* iterable (usually an async generator) of events. Each yielded value is
|
|
64
|
+
* validated against the contract's event schema and framed as an SSE
|
|
65
|
+
* `data:` message. Client disconnect aborts `ctx.signal` and terminates
|
|
66
|
+
* the iterator.
|
|
67
|
+
*/
|
|
68
|
+
export type SseHandler<TPath extends string, TQuery, TEvent, TCtx extends BaseRequestCtx> = (
|
|
69
|
+
input: RouteInput<TPath, TQuery, undefined>,
|
|
70
|
+
ctx: TCtx,
|
|
71
|
+
) => AsyncIterable<TEvent>;
|
|
72
|
+
|
|
73
|
+
/** Non-HTTP transports a {@link RouteBundleBuilder.mount} can declare. */
|
|
74
|
+
export type MountTransport = 'orpc' | 'trpc' | 'rpc' | 'custom';
|
|
75
|
+
|
|
76
|
+
export type MountMeta = {
|
|
77
|
+
/** Stable identifier — lands in the manifest like a contract id. */
|
|
78
|
+
readonly id: string;
|
|
79
|
+
/** Manifest transport tag. Defaults to `'custom'`. */
|
|
80
|
+
readonly transport?: MountTransport;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A contract bound to its (generics-erased) handler. */
|
|
84
|
+
export type RouteBinding = {
|
|
85
|
+
readonly contract: ContractLike;
|
|
86
|
+
/**
|
|
87
|
+
* Stored erased (`never` parameters) — typed handlers are assignable
|
|
88
|
+
* via parameter contravariance from the bottom type; the engine crosses
|
|
89
|
+
* the erasure boundary at the call site. Mirrors the DOT kernel's
|
|
90
|
+
* hook-storage pattern.
|
|
91
|
+
*/
|
|
92
|
+
readonly handler: (input: never, ctx: never) => unknown;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** A mounted foreign fetch handler (oRPC/tRPC router, static files, …). */
|
|
96
|
+
export type MountBinding = {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
readonly path: string;
|
|
99
|
+
readonly transport: MountTransport;
|
|
100
|
+
readonly handler: (req: Request) => Response | Promise<Response>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A plain value: derivers + bound contracts + mounts. Published as an
|
|
105
|
+
* ordinary DOT service under a token and collected by the `http()` pip —
|
|
106
|
+
* no new kernel concept.
|
|
107
|
+
*/
|
|
108
|
+
export type RouteBundle = {
|
|
109
|
+
readonly derivers: readonly ErasedDeriver[];
|
|
110
|
+
readonly bindings: readonly RouteBinding[];
|
|
111
|
+
readonly mounts: readonly MountBinding[];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Immutable bundle builder. Every method returns a new builder; deriver
|
|
116
|
+
* keys accumulate in `TCtx` so later `bind` handlers see them typed.
|
|
117
|
+
*/
|
|
118
|
+
export type RouteBundleBuilder<TCtx extends BaseRequestCtx = BaseRequestCtx> = RouteBundle & {
|
|
119
|
+
/** Add a reusable deriver (see {@link Deriver}). */
|
|
120
|
+
derive<K extends string, V>(deriver: Deriver<K, V, TCtx>): RouteBundleBuilder<TCtx & Readonly<Record<K, V>>>;
|
|
121
|
+
/** Add an inline deriver. */
|
|
122
|
+
derive<K extends string, V>(
|
|
123
|
+
key: K,
|
|
124
|
+
fn: (req: Request, ctx: TCtx) => V | Promise<V>,
|
|
125
|
+
): RouteBundleBuilder<TCtx & Readonly<Record<K, V>>>;
|
|
126
|
+
/**
|
|
127
|
+
* Bind a JSON contract to its handler. `NoInfer` pins the input/output
|
|
128
|
+
* generics to the contract — without it, a handler returning the wrong
|
|
129
|
+
* shape would silently widen `TOutput` instead of failing to compile.
|
|
130
|
+
*/
|
|
131
|
+
bind<TPath extends string, TQuery, TBody, TOutput>(
|
|
132
|
+
contract: RouteContract<TPath, TQuery, TBody, TOutput>,
|
|
133
|
+
handler: RouteHandler<TPath, NoInfer<TQuery>, NoInfer<TBody>, NoInfer<TOutput>, TCtx>,
|
|
134
|
+
): RouteBundleBuilder<TCtx>;
|
|
135
|
+
/** Bind an SSE contract to its event generator. */
|
|
136
|
+
bind<TPath extends string, TQuery, TEvent>(
|
|
137
|
+
contract: SseContract<TPath, TQuery, TEvent>,
|
|
138
|
+
handler: SseHandler<TPath, NoInfer<TQuery>, NoInfer<TEvent>, TCtx>,
|
|
139
|
+
): RouteBundleBuilder<TCtx>;
|
|
140
|
+
/**
|
|
141
|
+
* Mount a foreign fetch handler under a path prefix — an oRPC router, a
|
|
142
|
+
* tRPC fetch adapter, a static-file handler. One manifest route, no
|
|
143
|
+
* schemas, derivers do NOT run for mounted handlers.
|
|
144
|
+
*/
|
|
145
|
+
mount(
|
|
146
|
+
path: string,
|
|
147
|
+
handler: (req: Request) => Response | Promise<Response>,
|
|
148
|
+
meta: MountMeta,
|
|
149
|
+
): RouteBundleBuilder<TCtx>;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
type BundleState = {
|
|
153
|
+
readonly derivers: readonly ErasedDeriver[];
|
|
154
|
+
readonly bindings: readonly RouteBinding[];
|
|
155
|
+
readonly mounts: readonly MountBinding[];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function assertFreshDeriverKey(key: string, existing: readonly ErasedDeriver[]): void {
|
|
159
|
+
const reserved = (BASE_CTX_KEYS as readonly string[]).includes(key);
|
|
160
|
+
const duplicate = existing.some(d => d.key === key);
|
|
161
|
+
if (!reserved && !duplicate) return;
|
|
162
|
+
throw new HttpConfigError({
|
|
163
|
+
code: HTTP_ERROR_CODES.duplicateDeriverKey,
|
|
164
|
+
message: reserved
|
|
165
|
+
? `[http] deriver key "${key}" shadows a base request-context key.`
|
|
166
|
+
: `[http] deriver key "${key}" is already derived in this bundle.`,
|
|
167
|
+
remediation: `Pick a different key — base keys (${BASE_CTX_KEYS.join(', ')}) and earlier deriver keys are reserved.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function makeBuilder(state: BundleState): RouteBundleBuilder {
|
|
172
|
+
const impl = {
|
|
173
|
+
derivers: state.derivers,
|
|
174
|
+
bindings: state.bindings,
|
|
175
|
+
mounts: state.mounts,
|
|
176
|
+
derive(keyOrDeriver: string | ErasedDeriver, fn?: (req: Request, ctx: never) => unknown): RouteBundleBuilder {
|
|
177
|
+
let deriver: ErasedDeriver;
|
|
178
|
+
if (typeof keyOrDeriver === 'string') {
|
|
179
|
+
if (fn === undefined) {
|
|
180
|
+
throw new HttpConfigError({
|
|
181
|
+
code: HTTP_ERROR_CODES.duplicateDeriverKey,
|
|
182
|
+
message: `[http] derive("${keyOrDeriver}") is missing its deriver function.`,
|
|
183
|
+
remediation: 'Pass derive(key, fn) or derive(deriverObject).',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
deriver = { key: keyOrDeriver, derive: fn };
|
|
187
|
+
} else {
|
|
188
|
+
deriver = keyOrDeriver;
|
|
189
|
+
}
|
|
190
|
+
assertFreshDeriverKey(deriver.key, state.derivers);
|
|
191
|
+
return makeBuilder({ ...state, derivers: [...state.derivers, deriver] });
|
|
192
|
+
},
|
|
193
|
+
bind(contract: ContractLike, handler: (input: never, ctx: never) => unknown): RouteBundleBuilder {
|
|
194
|
+
return makeBuilder({ ...state, bindings: [...state.bindings, { contract, handler }] });
|
|
195
|
+
},
|
|
196
|
+
mount(path: string, handler: (req: Request) => Response | Promise<Response>, meta: MountMeta): RouteBundleBuilder {
|
|
197
|
+
if (!path.startsWith('/') || path.includes(' ')) {
|
|
198
|
+
throw new HttpConfigError({
|
|
199
|
+
code: HTTP_ERROR_CODES.invalidMount,
|
|
200
|
+
message: `[http] mount path "${path}" is invalid — it must start with "/" and contain no spaces.`,
|
|
201
|
+
remediation: 'Mount under an absolute path prefix, e.g. mount("/rpc", handler, { id: "rpc" }).',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return makeBuilder({
|
|
205
|
+
...state,
|
|
206
|
+
mounts: [...state.mounts, { id: meta.id, path, transport: meta.transport ?? 'custom', handler }],
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
// Erasure seam: the implementation works on erased contracts/handlers/
|
|
211
|
+
// derivers; the public builder type re-attaches the generics. The typed
|
|
212
|
+
// methods' arguments are structurally assignable to the erased
|
|
213
|
+
// parameters, so the only unsafe step is this widening of the return
|
|
214
|
+
// type — same seam as the DOT app builder's makeBuilder.
|
|
215
|
+
return impl as unknown as RouteBundleBuilder;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Start an empty bundle. See the module docs for the authoring pattern. */
|
|
219
|
+
export function routes(): RouteBundleBuilder {
|
|
220
|
+
return makeBuilder({ derivers: [], bindings: [], mounts: [] });
|
|
221
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient request context — the last resort.
|
|
3
|
+
*
|
|
4
|
+
* Explicit `ctx` threading fails in one honest case: code deep in a call
|
|
5
|
+
* stack whose signatures you don't control (log enrichment inside a
|
|
6
|
+
* shared library). For that, this entry exposes the current request's
|
|
7
|
+
* {@link BaseRequestCtx} via `AsyncLocalStorage`.
|
|
8
|
+
*
|
|
9
|
+
* Ambient context is a smell — prefer the explicit `ctx` parameter and
|
|
10
|
+
* derivers everywhere a signature is yours to change. Note that OTel
|
|
11
|
+
* trace context already propagates this way, so span/log correlation
|
|
12
|
+
* needs none of this.
|
|
13
|
+
*
|
|
14
|
+
* This module is deliberately NOT re-exported from the package root.
|
|
15
|
+
* Import it as `@arki/http/context`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
19
|
+
|
|
20
|
+
import type { BaseRequestCtx } from './derive.js';
|
|
21
|
+
|
|
22
|
+
const storage = new AsyncLocalStorage<BaseRequestCtx>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The context of the request currently being handled, or `undefined`
|
|
26
|
+
* outside a request (startup, background jobs, tests that call handlers
|
|
27
|
+
* directly).
|
|
28
|
+
*/
|
|
29
|
+
export function requestContext(): BaseRequestCtx | undefined {
|
|
30
|
+
return storage.getStore();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Engine seam: run `fn` with `ctx` as the ambient request context. Called
|
|
35
|
+
* by the engine around every request — not part of the public surface.
|
|
36
|
+
*/
|
|
37
|
+
export function runWithRequestContext<T>(ctx: BaseRequestCtx, fn: () => T): T {
|
|
38
|
+
return storage.run(ctx, fn);
|
|
39
|
+
}
|