@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/src/openapi.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
import type { ContractLike } from './contract.js';
|
|
14
|
+
import { HTTP_ERROR_CODES, HttpConfigError } from './error.js';
|
|
15
|
+
|
|
16
|
+
export type OpenApiInfo = {
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly version: string;
|
|
19
|
+
readonly description?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type JsonObject = Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert a contract schema to JSON Schema. Schema erasure seam:
|
|
26
|
+
* {@link ContractLike} types schemas as `parse`-only, but contracts built
|
|
27
|
+
* via `route.*` always hold zod schemas (see `SchemaLike` docs) — the
|
|
28
|
+
* cast re-attaches what the erased view dropped.
|
|
29
|
+
*/
|
|
30
|
+
function toJsonSchema(schema: { parse(input: unknown): unknown }): JsonObject {
|
|
31
|
+
const json = z.toJSONSchema(schema as z.ZodType) as JsonObject;
|
|
32
|
+
// The dialect header is noise when embedding into OpenAPI 3.1.
|
|
33
|
+
const { $schema: _dialect, ...rest } = json;
|
|
34
|
+
return rest;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pathParamNames(path: string): readonly string[] {
|
|
38
|
+
return [...path.matchAll(/:(\w+)/g)]
|
|
39
|
+
.map(match => match[1])
|
|
40
|
+
.filter((name): name is string => name !== undefined);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function openApiPath(path: string): string {
|
|
44
|
+
return path.replaceAll(/:(\w+)/g, '{$1}');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function queryParameters(schema: { parse(input: unknown): unknown }): JsonObject[] {
|
|
48
|
+
const json = toJsonSchema(schema);
|
|
49
|
+
const properties = (json['properties'] ?? {}) as Readonly<Record<string, unknown>>;
|
|
50
|
+
const required = new Set(json['required'] as readonly string[] | undefined);
|
|
51
|
+
return Object.entries(properties).map(([name, propertySchema]) => ({
|
|
52
|
+
name,
|
|
53
|
+
in: 'query',
|
|
54
|
+
required: required.has(name),
|
|
55
|
+
schema: propertySchema,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildOperation(contract: ContractLike): JsonObject {
|
|
60
|
+
const parameters: JsonObject[] = pathParamNames(contract.path).map(name => ({
|
|
61
|
+
name,
|
|
62
|
+
in: 'path',
|
|
63
|
+
required: true,
|
|
64
|
+
schema: { type: 'string' },
|
|
65
|
+
}));
|
|
66
|
+
if (contract.query !== undefined) parameters.push(...queryParameters(contract.query));
|
|
67
|
+
|
|
68
|
+
const operation: JsonObject = { operationId: contract.id };
|
|
69
|
+
if (contract.summary !== undefined) operation['summary'] = contract.summary;
|
|
70
|
+
if (parameters.length > 0) operation['parameters'] = parameters;
|
|
71
|
+
if (contract.body !== undefined) {
|
|
72
|
+
operation['requestBody'] = {
|
|
73
|
+
required: true,
|
|
74
|
+
content: { 'application/json': { schema: toJsonSchema(contract.body) } },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (contract.kind === 'sse') {
|
|
79
|
+
operation['responses'] = {
|
|
80
|
+
'200': {
|
|
81
|
+
description: 'event stream',
|
|
82
|
+
content: {
|
|
83
|
+
'text/event-stream':
|
|
84
|
+
contract.event === undefined ? {} : { schema: toJsonSchema(contract.event) },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
} else if (contract.output === undefined) {
|
|
89
|
+
operation['responses'] = { '200': { description: 'success' } };
|
|
90
|
+
} else {
|
|
91
|
+
operation['responses'] = {
|
|
92
|
+
'200': {
|
|
93
|
+
description: 'success',
|
|
94
|
+
content: { 'application/json': { schema: toJsonSchema(contract.output) } },
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return operation;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render an OpenAPI 3.1 document from contracts. Paths and operations
|
|
104
|
+
* appear in contract order; duplicate method+path pairs fail loudly with
|
|
105
|
+
* `ARKI_HTTP_E002` (the same collision `buildEngine` rejects at boot).
|
|
106
|
+
*/
|
|
107
|
+
export function toOpenApi(contracts: readonly ContractLike[], info: OpenApiInfo): JsonObject {
|
|
108
|
+
const paths: Record<string, JsonObject> = {};
|
|
109
|
+
for (const contract of contracts) {
|
|
110
|
+
const path = openApiPath(contract.path);
|
|
111
|
+
const entry = (paths[path] ??= {});
|
|
112
|
+
const method = contract.method.toLowerCase();
|
|
113
|
+
if (entry[method] !== undefined) {
|
|
114
|
+
throw new HttpConfigError({
|
|
115
|
+
code: HTTP_ERROR_CODES.duplicateRoute,
|
|
116
|
+
message: `[http] duplicate route ${contract.method} ${contract.path}: "${contract.id}" collides with an earlier contract.`,
|
|
117
|
+
remediation: 'Two contracts claim the same method and path. Change one path, or drop the duplicate.',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
entry[method] = buildOperation(contract);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
openapi: '3.1.0',
|
|
124
|
+
info: {
|
|
125
|
+
title: info.title,
|
|
126
|
+
version: info.version,
|
|
127
|
+
...(info.description === undefined ? {} : { description: info.description }),
|
|
128
|
+
},
|
|
129
|
+
paths,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The DOT `registerRoute` argument derived from a contract. */
|
|
134
|
+
export type RouteMeta = {
|
|
135
|
+
readonly id: string;
|
|
136
|
+
readonly method: string;
|
|
137
|
+
readonly path: string;
|
|
138
|
+
readonly transport: 'http';
|
|
139
|
+
readonly description?: string;
|
|
140
|
+
readonly input?: { readonly query?: JsonObject; readonly body?: JsonObject };
|
|
141
|
+
readonly output?: JsonObject;
|
|
142
|
+
readonly streaming?: boolean;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Manifest metadata for a contract — what `registerRoutes` (from
|
|
147
|
+
* `@arki/http/dot`) feeds into `ctx.registerRoute` so that
|
|
148
|
+
* `dot explain --openapi` can render without booting. SSE contracts mark
|
|
149
|
+
* `streaming` and carry the per-event schema as `output`.
|
|
150
|
+
*/
|
|
151
|
+
export function contractMeta(contract: ContractLike): RouteMeta {
|
|
152
|
+
const input: { query?: JsonObject; body?: JsonObject } = {};
|
|
153
|
+
if (contract.query !== undefined) input.query = toJsonSchema(contract.query);
|
|
154
|
+
if (contract.body !== undefined) input.body = toJsonSchema(contract.body);
|
|
155
|
+
|
|
156
|
+
const outputSchema = contract.kind === 'sse' ? contract.event : contract.output;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
id: contract.id,
|
|
160
|
+
method: contract.method,
|
|
161
|
+
path: contract.path,
|
|
162
|
+
transport: 'http',
|
|
163
|
+
...(contract.summary === undefined ? {} : { description: contract.summary }),
|
|
164
|
+
...(Object.keys(input).length === 0 ? {} : { input }),
|
|
165
|
+
...(outputSchema === undefined ? {} : { output: toJsonSchema(outputSchema) }),
|
|
166
|
+
...(contract.kind === 'sse' ? { streaming: true } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
|
|
11
|
+
import type { ContractLike } from './contract.js';
|
|
12
|
+
import { describeInternalError, HTTP_ERROR_CODES } from './error.js';
|
|
13
|
+
|
|
14
|
+
const SSE_HEADERS = {
|
|
15
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
16
|
+
'cache-control': 'no-cache',
|
|
17
|
+
// Disable proxy buffering (nginx and friends) — events must flush.
|
|
18
|
+
'x-accel-buffering': 'no',
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export function sseResponse(iterable: AsyncIterable<unknown>, contract: ContractLike, signal: AbortSignal): Response {
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
24
|
+
let heartbeat: NodeJS.Timeout | undefined;
|
|
25
|
+
let finished = false;
|
|
26
|
+
|
|
27
|
+
const cleanup = (): void => {
|
|
28
|
+
finished = true;
|
|
29
|
+
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const stopIterator = async (): Promise<void> => {
|
|
33
|
+
try {
|
|
34
|
+
await iterator.return?.();
|
|
35
|
+
} catch {
|
|
36
|
+
// Generator cleanup failures have no caller to surface to here; the
|
|
37
|
+
// generator's own resources are the pip's responsibility.
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
42
|
+
start(controller): void {
|
|
43
|
+
if (contract.heartbeatMs !== undefined && contract.heartbeatMs > 0) {
|
|
44
|
+
heartbeat = setInterval(() => {
|
|
45
|
+
if (!finished && (controller.desiredSize ?? 0) > 0) {
|
|
46
|
+
controller.enqueue(encoder.encode(':keepalive\n\n'));
|
|
47
|
+
}
|
|
48
|
+
}, contract.heartbeatMs);
|
|
49
|
+
heartbeat.unref();
|
|
50
|
+
}
|
|
51
|
+
signal.addEventListener(
|
|
52
|
+
'abort',
|
|
53
|
+
() => {
|
|
54
|
+
cleanup();
|
|
55
|
+
void stopIterator();
|
|
56
|
+
},
|
|
57
|
+
{ once: true },
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
async pull(controller): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
const next = await iterator.next();
|
|
63
|
+
if (finished) return;
|
|
64
|
+
if (next.done === true) {
|
|
65
|
+
cleanup();
|
|
66
|
+
controller.close();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const value = contract.event === undefined ? next.value : contract.event.parse(next.value);
|
|
70
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (finished) return;
|
|
73
|
+
cleanup();
|
|
74
|
+
const payload = {
|
|
75
|
+
error: {
|
|
76
|
+
code: HTTP_ERROR_CODES.internal,
|
|
77
|
+
message: describeInternalError(error),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify(payload)}\n\n`));
|
|
81
|
+
controller.close();
|
|
82
|
+
void stopIterator();
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
cancel(): void {
|
|
86
|
+
cleanup();
|
|
87
|
+
void stopIterator();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
|
92
|
+
}
|