@zmdb/web 1.0.0-beta.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 +674 -0
- package/README.md +49 -0
- package/dist/app/bridge.d.ts +12 -0
- package/dist/app/bridge.d.ts.map +1 -0
- package/dist/app/bridge.js +16 -0
- package/dist/app/bridge.js.map +1 -0
- package/dist/app/index.d.ts +23 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +47 -0
- package/dist/app/index.js.map +1 -0
- package/dist/bench/index.d.ts +68 -0
- package/dist/bench/index.d.ts.map +1 -0
- package/dist/bench/index.js +250 -0
- package/dist/bench/index.js.map +1 -0
- package/dist/compression/index.d.ts +13 -0
- package/dist/compression/index.d.ts.map +1 -0
- package/dist/compression/index.js +211 -0
- package/dist/compression/index.js.map +1 -0
- package/dist/context/index.d.ts +74 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +118 -0
- package/dist/context/index.js.map +1 -0
- package/dist/contract/__fixtures__/http-client.generated.d.ts +70 -0
- package/dist/contract/__fixtures__/http-client.generated.d.ts.map +1 -0
- package/dist/contract/__fixtures__/http-client.generated.js +86 -0
- package/dist/contract/__fixtures__/http-client.generated.js.map +1 -0
- package/dist/contract/__fixtures__/http-convergence.d.ts +396 -0
- package/dist/contract/__fixtures__/http-convergence.d.ts.map +1 -0
- package/dist/contract/__fixtures__/http-convergence.js +425 -0
- package/dist/contract/__fixtures__/http-convergence.js.map +1 -0
- package/dist/contract/compiler/client.d.ts +12 -0
- package/dist/contract/compiler/client.d.ts.map +1 -0
- package/dist/contract/compiler/client.js +1293 -0
- package/dist/contract/compiler/client.js.map +1 -0
- package/dist/contract/compiler/index.d.ts +18 -0
- package/dist/contract/compiler/index.d.ts.map +1 -0
- package/dist/contract/compiler/index.js +1058 -0
- package/dist/contract/compiler/index.js.map +1 -0
- package/dist/contract/index.d.ts +237 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +22 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/csrf/index.d.ts +15 -0
- package/dist/csrf/index.d.ts.map +1 -0
- package/dist/csrf/index.js +218 -0
- package/dist/csrf/index.js.map +1 -0
- package/dist/data/index.d.ts +35 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +58 -0
- package/dist/data/index.js.map +1 -0
- package/dist/devtools/index.d.ts +74 -0
- package/dist/devtools/index.d.ts.map +1 -0
- package/dist/devtools/index.js +552 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/dto-pipes/index.d.ts +45 -0
- package/dist/dto-pipes/index.d.ts.map +1 -0
- package/dist/dto-pipes/index.js +78 -0
- package/dist/dto-pipes/index.js.map +1 -0
- package/dist/gateways/index.d.ts +31 -0
- package/dist/gateways/index.d.ts.map +1 -0
- package/dist/gateways/index.js +138 -0
- package/dist/gateways/index.js.map +1 -0
- package/dist/health/index.d.ts +20 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +148 -0
- package/dist/health/index.js.map +1 -0
- package/dist/http-token.d.ts +2 -0
- package/dist/http-token.d.ts.map +1 -0
- package/dist/http-token.js +21 -0
- package/dist/http-token.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/index.d.ts +11 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +39 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/middleware/errors.d.ts +14 -0
- package/dist/middleware/errors.d.ts.map +1 -0
- package/dist/middleware/errors.js +18 -0
- package/dist/middleware/errors.js.map +1 -0
- package/dist/middleware/index.d.ts +46 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +64 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/modules/__fixtures__/large-graph.d.ts +239 -0
- package/dist/modules/__fixtures__/large-graph.d.ts.map +1 -0
- package/dist/modules/__fixtures__/large-graph.js +483 -0
- package/dist/modules/__fixtures__/large-graph.js.map +1 -0
- package/dist/openapi/__fixtures__/entities.d.ts +8 -0
- package/dist/openapi/__fixtures__/entities.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/entities.js +4 -0
- package/dist/openapi/__fixtures__/entities.js.map +1 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts +38 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.js +172 -0
- package/dist/openapi/__fixtures__/openapi-tools.fixture.js.map +1 -0
- package/dist/openapi/__fixtures__/route-schemas.d.ts +2 -0
- package/dist/openapi/__fixtures__/route-schemas.d.ts.map +1 -0
- package/dist/openapi/__fixtures__/route-schemas.js +17 -0
- package/dist/openapi/__fixtures__/route-schemas.js.map +1 -0
- package/dist/openapi/index.d.ts +61 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +314 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/pipeline/guards.d.ts +9 -0
- package/dist/pipeline/guards.d.ts.map +1 -0
- package/dist/pipeline/guards.js +11 -0
- package/dist/pipeline/guards.js.map +1 -0
- package/dist/pipeline/index.d.ts +141 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +1624 -0
- package/dist/pipeline/index.js.map +1 -0
- package/dist/routing/index.d.ts +40 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +162 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/static/index.d.ts +14 -0
- package/dist/static/index.d.ts.map +1 -0
- package/dist/static/index.js +348 -0
- package/dist/static/index.js.map +1 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/traced-router.d.ts +5 -0
- package/dist/traced-router.d.ts.map +1 -0
- package/dist/traced-router.js +6 -0
- package/dist/traced-router.js.map +1 -0
- package/dist/upload/index.d.ts +28 -0
- package/dist/upload/index.d.ts.map +1 -0
- package/dist/upload/index.js +410 -0
- package/dist/upload/index.js.map +1 -0
- package/dist/versioning/index.d.ts +35 -0
- package/dist/versioning/index.d.ts.map +1 -0
- package/dist/versioning/index.js +66 -0
- package/dist/versioning/index.js.map +1 -0
- package/dist/versioning/runtime.d.ts +5 -0
- package/dist/versioning/runtime.d.ts.map +1 -0
- package/dist/versioning/runtime.js +34 -0
- package/dist/versioning/runtime.js.map +1 -0
- package/package.json +143 -0
- package/src/app/bridge.ts +39 -0
- package/src/app/index.ts +76 -0
- package/src/bench/index.ts +329 -0
- package/src/compression/index.ts +255 -0
- package/src/context/index.ts +181 -0
- package/src/contract/__fixtures__/http-client.generated.ts +73 -0
- package/src/contract/__fixtures__/http-client.generated.ts.map +1 -0
- package/src/contract/__fixtures__/http-convergence.ts +564 -0
- package/src/contract/compiler/client.ts +1518 -0
- package/src/contract/compiler/index.ts +1457 -0
- package/src/contract/index.ts +266 -0
- package/src/csrf/index.ts +264 -0
- package/src/data/index.ts +65 -0
- package/src/devtools/index.ts +727 -0
- package/src/dto-pipes/index.ts +93 -0
- package/src/gateways/index.ts +180 -0
- package/src/health/index.ts +198 -0
- package/src/http-token.ts +22 -0
- package/src/index.ts +167 -0
- package/src/integrations/index.ts +50 -0
- package/src/middleware/errors.ts +17 -0
- package/src/middleware/index.ts +108 -0
- package/src/modules/__fixtures__/large-graph.ts +540 -0
- package/src/openapi/__fixtures__/entities.ts +19 -0
- package/src/openapi/__fixtures__/openapi-tools.fixture.ts +212 -0
- package/src/openapi/__fixtures__/route-schemas.ts +27 -0
- package/src/openapi/index.ts +483 -0
- package/src/pipeline/guards.ts +23 -0
- package/src/pipeline/index.ts +2041 -0
- package/src/routing/index.ts +201 -0
- package/src/static/index.ts +443 -0
- package/src/testing/index.ts +58 -0
- package/src/traced-router.ts +8 -0
- package/src/upload/index.ts +489 -0
- package/src/versioning/index.ts +106 -0
- package/src/versioning/runtime.ts +38 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @zmdb/web — DTO validation & serialization pipes (epic #297, spec ./SPEC.md).
|
|
2
|
+
// Bind routes to schema-derived DTO validation (a Pipe) and entity serialization
|
|
3
|
+
// (an Interceptor), built on the middleware chain. Zero runtime parser (consumer
|
|
4
|
+
// supplies the AOT assert); no `as` on the consumer surface.
|
|
5
|
+
|
|
6
|
+
import type { Chain, Pipe, Interceptor } from '../middleware/index.js';
|
|
7
|
+
import { parseMultipart, type Multipart, type UploadLimits } from '../upload/index.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A validation pipe: runs `validator` (e.g. `assert<CreateDTO<S>>`) on the body,
|
|
11
|
+
* yielding the typed value. A throw becomes the chain's 400. The framework
|
|
12
|
+
* embeds no parser — validation is the consumer's AOT `assert`.
|
|
13
|
+
*/
|
|
14
|
+
export function validationPipe<T>(validator: (raw: unknown) => T): Pipe<unknown, T> {
|
|
15
|
+
return {
|
|
16
|
+
transform(value: unknown): T {
|
|
17
|
+
return validator(value);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A pipe that only converts: the wire→app decode at the boundary (plan D3).
|
|
24
|
+
*
|
|
25
|
+
* Separate from `validationPipe` because the two do different jobs and the order matters.
|
|
26
|
+
* `wireDecoder(Schema)` turns the ISO string JSON carries into the `Date` a `CreateDTO`
|
|
27
|
+
* asks for; the validator then checks the app type. Decoding after validation would mean
|
|
28
|
+
* validating the wrong layer, and doing both in one function is how a validator ends up
|
|
29
|
+
* accepting `Date | string` and checking neither.
|
|
30
|
+
*/
|
|
31
|
+
export function decodePipe<In = unknown, Out = unknown>(decode: (value: In) => Out): Pipe<In, Out> {
|
|
32
|
+
return {
|
|
33
|
+
transform(value: In): Out {
|
|
34
|
+
return decode(value);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse the exact request bytes as multipart at the ordinary pipe boundary.
|
|
41
|
+
*
|
|
42
|
+
* The returned `Multipart` remains available to later validation pipes, so form
|
|
43
|
+
* fields use the same validation path as JSON bodies.
|
|
44
|
+
*/
|
|
45
|
+
export function multipartPipe(limits: Partial<UploadLimits> = {}): Pipe<unknown, Multipart> {
|
|
46
|
+
const configured = { ...limits };
|
|
47
|
+
return {
|
|
48
|
+
transform(value, ctx): Multipart {
|
|
49
|
+
return parseMultipart(value, ctx.headers['content-type'] ?? '', configured);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A serialization interceptor: transforms the handler's result via `serialize`
|
|
56
|
+
* (default: identity — the pipeline JSON-encodes downstream). Pass an entity
|
|
57
|
+
* serializer to shape the response from `Entity<S>`.
|
|
58
|
+
*/
|
|
59
|
+
export function serializationInterceptor(serialize: (result: unknown) => unknown = r => r): Interceptor {
|
|
60
|
+
return {
|
|
61
|
+
async intercept(_ctx, next): Promise<unknown> {
|
|
62
|
+
const result = await next();
|
|
63
|
+
return serialize(result);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Options for `dtoChain`. */
|
|
69
|
+
export interface DtoChainOptions<T> {
|
|
70
|
+
/** The wire→app decode, e.g. `wireDecoder(Schema, 'create')`. Runs before `validate`. */
|
|
71
|
+
readonly decode?: (raw: unknown) => unknown;
|
|
72
|
+
readonly validate: (raw: unknown) => T;
|
|
73
|
+
readonly serialize?: (result: unknown) => unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compose a Chain with the validation pipe (+ optional decode pipe and
|
|
78
|
+
* serialization interceptor), so a route adopts DTO validation/serialization in
|
|
79
|
+
* one call.
|
|
80
|
+
*/
|
|
81
|
+
export function dtoChain<T>(options: DtoChainOptions<T>): Chain {
|
|
82
|
+
return {
|
|
83
|
+
guards: [],
|
|
84
|
+
// Decode first, then validate: the validator checks the app type, which is only what
|
|
85
|
+
// the body holds once the two types JSON cannot carry have been converted.
|
|
86
|
+
pipes:
|
|
87
|
+
options.decode === undefined
|
|
88
|
+
? [validationPipe(options.validate)]
|
|
89
|
+
: [decodePipe(options.decode), validationPipe(options.validate)],
|
|
90
|
+
interceptors: options.serialize === undefined ? [] : [serializationInterceptor(options.serialize)],
|
|
91
|
+
filters: [],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @zmdb/web — WebSocket & SSE gateways (epic #307, spec ./SPEC.md). @Gateway/
|
|
2
|
+
// @Subscribe store handlers in context.metadata; a dispatcher routes events with
|
|
3
|
+
// a typed message ctx; sseStream frames an async iterable as SSE. No ws
|
|
4
|
+
// dependency, no reflection, no `as` on the consumer surface.
|
|
5
|
+
|
|
6
|
+
import '@zmdb/app';
|
|
7
|
+
|
|
8
|
+
/** The context a subscribed handler receives. */
|
|
9
|
+
export interface MessageCtx<T = unknown> {
|
|
10
|
+
readonly event: string;
|
|
11
|
+
readonly data: T;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A recorded event subscription. */
|
|
15
|
+
export interface Subscription {
|
|
16
|
+
readonly event: string;
|
|
17
|
+
readonly handlerName: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NAMESPACE = Symbol('zmdb.web.gateway.namespace');
|
|
21
|
+
const SUBSCRIPTIONS = Symbol('zmdb.web.gateway.subscriptions');
|
|
22
|
+
|
|
23
|
+
interface GatewayMetadata {
|
|
24
|
+
[NAMESPACE]?: string;
|
|
25
|
+
[SUBSCRIPTIONS]?: Subscription[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// boundary: our @Gateway/@Subscribe decorators are the only writers of these
|
|
29
|
+
// slots, so viewing the record through GatewayMetadata is sound (§2.1).
|
|
30
|
+
function gatewayView(metadata: DecoratorMetadata): GatewayMetadata {
|
|
31
|
+
return metadata;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Stage-3 class decorator: mark a gateway with an optional namespace. */
|
|
35
|
+
export function Gateway(namespace = '') {
|
|
36
|
+
return function <T extends abstract new (...args: never[]) => unknown>(
|
|
37
|
+
_target: T,
|
|
38
|
+
context: ClassDecoratorContext<T>,
|
|
39
|
+
): void {
|
|
40
|
+
gatewayView(context.metadata)[NAMESPACE] = namespace;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Stage-3 method decorator: subscribe the method to an event. */
|
|
45
|
+
export function Subscribe(event: string) {
|
|
46
|
+
return function (_target: (...args: never[]) => unknown, context: ClassMethodDecoratorContext): void {
|
|
47
|
+
const handlerName = typeof context.name === 'string' ? context.name : context.name.toString();
|
|
48
|
+
const view = gatewayView(context.metadata);
|
|
49
|
+
const existing = view[SUBSCRIPTIONS];
|
|
50
|
+
if (existing === undefined) {
|
|
51
|
+
view[SUBSCRIPTIONS] = [{ event, handlerName }];
|
|
52
|
+
} else {
|
|
53
|
+
existing.push({ event, handlerName });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read a gateway class's subscriptions (declaration order). No reflection. */
|
|
59
|
+
export function getSubscriptions(gateway: abstract new (...args: never[]) => unknown): readonly Subscription[] {
|
|
60
|
+
const metadata = gateway[Symbol.metadata];
|
|
61
|
+
if (metadata === undefined || metadata === null) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return gatewayView(metadata)[SUBSCRIPTIONS] ?? [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type MessageHandler = (ctx: MessageCtx) => unknown;
|
|
68
|
+
|
|
69
|
+
/** A dispatcher routing an event+message to the gateway's matching handler. */
|
|
70
|
+
export interface GatewayDispatcher {
|
|
71
|
+
dispatch(event: string, data: unknown): Promise<unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build a dispatcher for a gateway instance. */
|
|
75
|
+
export function createGatewayDispatcher(gateway: object): GatewayDispatcher {
|
|
76
|
+
const ctor = gateway.constructor;
|
|
77
|
+
const subs = typeof ctor === 'function' ? getSubscriptions(gatewayClass(ctor)) : [];
|
|
78
|
+
const byEvent = new Map<string, string>();
|
|
79
|
+
for (const sub of subs) {
|
|
80
|
+
byEvent.set(sub.event, sub.handlerName);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
async dispatch(event: string, data: unknown): Promise<unknown> {
|
|
84
|
+
const handlerName = byEvent.get(event);
|
|
85
|
+
if (handlerName === undefined) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const handler = readMessageHandler(gateway, handlerName);
|
|
89
|
+
if (handler === undefined) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return handler({ event, data });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type GatewayClass = abstract new (...args: never[]) => unknown;
|
|
98
|
+
|
|
99
|
+
// boundary: an instance's `.constructor` carries the gateway metadata; narrowing
|
|
100
|
+
// it for getSubscriptions is sound (§2.1).
|
|
101
|
+
function gatewayClass(ctor: Function): GatewayClass {
|
|
102
|
+
return ctor as GatewayClass;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// boundary: @Subscribe only records names of the gateway's own methods; reading
|
|
106
|
+
// one by name and calling it as a MessageHandler is sound (§2.1).
|
|
107
|
+
function readMessageHandler(gateway: object, name: string): MessageHandler | undefined {
|
|
108
|
+
const value = Reflect.get(gateway, name);
|
|
109
|
+
if (typeof value !== 'function') {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const bound = value.bind(gateway);
|
|
113
|
+
return bound as MessageHandler;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A Server-Sent-Events frame. */
|
|
117
|
+
export interface SseFrame<T = unknown> {
|
|
118
|
+
readonly event?: string;
|
|
119
|
+
readonly data: T;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Turn an async iterable of frames into an SSE-framed byte stream. */
|
|
123
|
+
export function sseStream(source: AsyncIterable<SseFrame>): ReadableStream<Uint8Array> {
|
|
124
|
+
const encoder = new TextEncoder();
|
|
125
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
126
|
+
let state: 'open' | 'cancelling' | 'closed' = 'open';
|
|
127
|
+
let cancellation: Promise<void> | undefined;
|
|
128
|
+
|
|
129
|
+
const cancel = (reason: unknown): Promise<void> => {
|
|
130
|
+
if (state === 'closed') {
|
|
131
|
+
return Promise.resolve();
|
|
132
|
+
}
|
|
133
|
+
if (cancellation !== undefined) {
|
|
134
|
+
return cancellation;
|
|
135
|
+
}
|
|
136
|
+
state = 'cancelling';
|
|
137
|
+
cancellation = (async () => {
|
|
138
|
+
try {
|
|
139
|
+
await iterator.return?.(reason);
|
|
140
|
+
} catch {
|
|
141
|
+
// A client disconnect is normal teardown. This helper has no error
|
|
142
|
+
// reporter, so a cleanup rejection must not become a server failure;
|
|
143
|
+
// sources that need reporting own it inside return/finally.
|
|
144
|
+
} finally {
|
|
145
|
+
state = 'closed';
|
|
146
|
+
}
|
|
147
|
+
})();
|
|
148
|
+
return cancellation;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return new ReadableStream<Uint8Array>({
|
|
152
|
+
async pull(controller): Promise<void> {
|
|
153
|
+
if (state !== 'open') {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let next: IteratorResult<SseFrame>;
|
|
157
|
+
try {
|
|
158
|
+
next = await iterator.next();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (state !== 'open') {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
state = 'closed';
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
if (state !== 'open') {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const { value, done } = next;
|
|
170
|
+
if (done) {
|
|
171
|
+
state = 'closed';
|
|
172
|
+
controller.close();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const prefix = value.event === undefined ? '' : `event: ${value.event}\n`;
|
|
176
|
+
controller.enqueue(encoder.encode(`${prefix}data: ${JSON.stringify(value.data)}\n\n`));
|
|
177
|
+
},
|
|
178
|
+
cancel,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// @zmdb/web — HTTP liveness and readiness routes (#581, epic #578).
|
|
2
|
+
// Liveness is synchronous by type. Readiness is concurrent, bounded, success-cached,
|
|
3
|
+
// and coalesced while a probe invocation is still waiting.
|
|
4
|
+
|
|
5
|
+
import type { CheckResult, DetailedCheck, HealthChecks, LivenessCheck, ReadinessCheck } from '@zmdb/app/health';
|
|
6
|
+
|
|
7
|
+
import { json, type WebResponse } from '../pipeline/index.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CACHE_MS = 1000;
|
|
10
|
+
const DEADLINE_GRACE_MS = 50;
|
|
11
|
+
|
|
12
|
+
export interface HealthProbes {
|
|
13
|
+
readonly live: () => WebResponse;
|
|
14
|
+
readonly ready: () => Promise<WebResponse>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DetailedBody {
|
|
18
|
+
readonly status: 'ok' | 'error';
|
|
19
|
+
readonly checks: readonly DetailedCheck[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CachedSuccess {
|
|
23
|
+
readonly expiresAt: number;
|
|
24
|
+
readonly result: DetailedCheck;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CheckState {
|
|
28
|
+
cached?: CachedSuccess;
|
|
29
|
+
inFlight?: Promise<DetailedCheck>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ReadinessRunner {
|
|
33
|
+
run(): Promise<readonly DetailedCheck[]>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createCheckState(): CheckState {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function publicResponse(ok: boolean): WebResponse {
|
|
41
|
+
return json({ status: ok ? 'ok' : 'error' }, ok ? {} : { status: 503 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runLiveness(checks: readonly LivenessCheck[]): boolean {
|
|
45
|
+
for (const check of checks) {
|
|
46
|
+
try {
|
|
47
|
+
if (!check.run()) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createReadinessRunner(checks: readonly ReadinessCheck[]): ReadinessRunner {
|
|
58
|
+
const entries = checks.map(check => ({ check, state: createCheckState() }));
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
run: () => {
|
|
62
|
+
if (entries.length === 0) {
|
|
63
|
+
return Promise.resolve([]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const completed: (DetailedCheck | undefined)[] = entries.map(() => undefined);
|
|
67
|
+
const running = entries.map(({ check, state }, index) =>
|
|
68
|
+
runReadinessCheck(check, state).then(result => {
|
|
69
|
+
completed[index] = result;
|
|
70
|
+
return result;
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
const aggregateDeadline = Math.max(...entries.map(({ check }) => check.timeoutMs)) + DEADLINE_GRACE_MS;
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
|
|
76
|
+
return new Promise(resolve => {
|
|
77
|
+
let settled = false;
|
|
78
|
+
const finish = (results: readonly DetailedCheck[]): void => {
|
|
79
|
+
if (settled) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
settled = true;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
resolve(results);
|
|
85
|
+
};
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
finish(
|
|
88
|
+
entries.map(
|
|
89
|
+
({ check }, index): DetailedCheck =>
|
|
90
|
+
completed[index] ?? {
|
|
91
|
+
name: check.name,
|
|
92
|
+
ok: false,
|
|
93
|
+
detail: 'timeout',
|
|
94
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
95
|
+
},
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
}, aggregateDeadline);
|
|
99
|
+
|
|
100
|
+
void Promise.all(running).then(finish);
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runReadinessCheck(check: ReadinessCheck, state: CheckState): Promise<DetailedCheck> {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
if (state.cached !== undefined) {
|
|
109
|
+
if (now < state.cached.expiresAt) {
|
|
110
|
+
return Promise.resolve(state.cached.result);
|
|
111
|
+
}
|
|
112
|
+
delete state.cached;
|
|
113
|
+
}
|
|
114
|
+
if (state.inFlight !== undefined) {
|
|
115
|
+
return state.inFlight;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const startedAt = now;
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const pending = new Promise<DetailedCheck>(resolve => {
|
|
121
|
+
let settled = false;
|
|
122
|
+
let timedOut = false;
|
|
123
|
+
const finish = (result: CheckResult): void => {
|
|
124
|
+
if (settled) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(abortTimer);
|
|
129
|
+
clearTimeout(deadlineTimer);
|
|
130
|
+
const detailed = {
|
|
131
|
+
name: check.name,
|
|
132
|
+
ok: result.ok,
|
|
133
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
134
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
135
|
+
};
|
|
136
|
+
if (result.ok) {
|
|
137
|
+
state.cached = {
|
|
138
|
+
expiresAt: Date.now() + (check.cacheMs ?? DEFAULT_CACHE_MS),
|
|
139
|
+
result: detailed,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
resolve(detailed);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const abortTimer = setTimeout(() => {
|
|
146
|
+
timedOut = true;
|
|
147
|
+
controller.abort();
|
|
148
|
+
}, check.timeoutMs);
|
|
149
|
+
const deadlineTimer = setTimeout(() => {
|
|
150
|
+
finish({ ok: false, detail: 'timeout' });
|
|
151
|
+
}, check.timeoutMs + DEADLINE_GRACE_MS);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
void check.run(controller.signal).then(
|
|
155
|
+
result => finish(timedOut ? { ok: false, detail: 'timeout' } : result),
|
|
156
|
+
() => finish(timedOut ? { ok: false, detail: 'timeout' } : { ok: false }),
|
|
157
|
+
);
|
|
158
|
+
} catch {
|
|
159
|
+
finish({ ok: false });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
state.inFlight = pending;
|
|
164
|
+
void pending.finally(() => {
|
|
165
|
+
if (state.inFlight === pending) {
|
|
166
|
+
delete state.inFlight;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return pending;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Build the public liveness and readiness handlers from this application's explicit checks. */
|
|
173
|
+
export function healthRoutes(checks: HealthChecks): HealthProbes {
|
|
174
|
+
const liveness = checks.liveness ?? [];
|
|
175
|
+
const readiness = createReadinessRunner(checks.readiness ?? []);
|
|
176
|
+
return {
|
|
177
|
+
live: () => publicResponse(runLiveness(liveness)),
|
|
178
|
+
ready: async () => {
|
|
179
|
+
const results = await readiness.run();
|
|
180
|
+
return publicResponse(results.every(result => result.ok));
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build the opt-in detailed readiness handler.
|
|
187
|
+
*
|
|
188
|
+
* Mount this separate handler through the application's normal RouteOptions.guards composition.
|
|
189
|
+
* The public handlers returned by healthRoutes never expose names, details, or timings.
|
|
190
|
+
*/
|
|
191
|
+
export function detailedReadyRoute(checks: HealthChecks): () => Promise<WebResponse> {
|
|
192
|
+
const readiness = createReadinessRunner(checks.readiness ?? []);
|
|
193
|
+
return async () => {
|
|
194
|
+
const results = await readiness.run();
|
|
195
|
+
const ok = results.every(result => result.ok);
|
|
196
|
+
return json({ status: ok ? 'ok' : 'error', checks: results }, ok ? {} : { status: 503 });
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function isHttpTokenCode(code: number): boolean {
|
|
2
|
+
return (
|
|
3
|
+
(code >= 48 && code <= 57) ||
|
|
4
|
+
(code >= 65 && code <= 90) ||
|
|
5
|
+
(code >= 97 && code <= 122) ||
|
|
6
|
+
code === 33 ||
|
|
7
|
+
code === 35 ||
|
|
8
|
+
code === 36 ||
|
|
9
|
+
code === 37 ||
|
|
10
|
+
code === 38 ||
|
|
11
|
+
code === 39 ||
|
|
12
|
+
code === 42 ||
|
|
13
|
+
code === 43 ||
|
|
14
|
+
code === 45 ||
|
|
15
|
+
code === 46 ||
|
|
16
|
+
code === 94 ||
|
|
17
|
+
code === 95 ||
|
|
18
|
+
code === 96 ||
|
|
19
|
+
code === 124 ||
|
|
20
|
+
code === 126
|
|
21
|
+
);
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @zmdb/web — the HTTP adapter for the zmdb application kernel.
|
|
2
|
+
//
|
|
3
|
+
// Stage-3 metadata, DI, modules and lifecycle are installed and owned by
|
|
4
|
+
// @zmdb/app. Importing the web root activates that one metadata baseline before
|
|
5
|
+
// any HTTP decorator is evaluated.
|
|
6
|
+
import '@zmdb/app';
|
|
7
|
+
|
|
8
|
+
// Controllers & routing (Stage-3 decorators → context.metadata). See ./routing.
|
|
9
|
+
export {
|
|
10
|
+
Controller,
|
|
11
|
+
Get,
|
|
12
|
+
Post,
|
|
13
|
+
Put,
|
|
14
|
+
Patch,
|
|
15
|
+
Delete,
|
|
16
|
+
Public,
|
|
17
|
+
getRoutes,
|
|
18
|
+
isPublic,
|
|
19
|
+
type RouteDefinition,
|
|
20
|
+
type ResolvedRoute,
|
|
21
|
+
} from './routing/index.js';
|
|
22
|
+
|
|
23
|
+
// Inert HTTP declarations and serialisable operation IR. The compiler-backed
|
|
24
|
+
// collector is intentionally available only from `@zmdb/web/contract/compiler`.
|
|
25
|
+
export {
|
|
26
|
+
defineHttpContract,
|
|
27
|
+
httpOperation,
|
|
28
|
+
type AuthorizationCodeFlow,
|
|
29
|
+
type ClientCredentialsFlow,
|
|
30
|
+
type CompiledHttpContract,
|
|
31
|
+
type CompiledHttpOperation,
|
|
32
|
+
type HttpBodyDeclaration,
|
|
33
|
+
type HttpBodyIR,
|
|
34
|
+
type HttpBodyKind,
|
|
35
|
+
type HttpContractDeclaration,
|
|
36
|
+
type HttpContractIR,
|
|
37
|
+
type HttpController,
|
|
38
|
+
type HttpMethod,
|
|
39
|
+
type HttpOperationDeclaration,
|
|
40
|
+
type HttpOperationIR,
|
|
41
|
+
type HttpOperationTypes,
|
|
42
|
+
type HttpParameterDeclaration,
|
|
43
|
+
type HttpParameterIR,
|
|
44
|
+
type HttpRequestBodyDeclaration,
|
|
45
|
+
type HttpRequestBodyIR,
|
|
46
|
+
type HttpResponseDeclaration,
|
|
47
|
+
type HttpResponseHeaderDeclaration,
|
|
48
|
+
type HttpResponseHeaderIR,
|
|
49
|
+
type HttpResponseIR,
|
|
50
|
+
type HttpTypeIR,
|
|
51
|
+
type HttpVersionDeclaration,
|
|
52
|
+
type HttpVersionIR,
|
|
53
|
+
type ImplicitFlow,
|
|
54
|
+
type JsonValue,
|
|
55
|
+
type OAuthFlow,
|
|
56
|
+
type OAuthFlows,
|
|
57
|
+
type PasswordFlow,
|
|
58
|
+
type SecurityRequirement,
|
|
59
|
+
type SecurityScheme,
|
|
60
|
+
} from './contract/index.js';
|
|
61
|
+
|
|
62
|
+
// API version declarations and strategy types. See ./versioning.
|
|
63
|
+
export { Version, VersionNeutral, versionsOf, type VersionStrategy } from './versioning/index.js';
|
|
64
|
+
|
|
65
|
+
// Typed request context + compile-time path-param derivation. See ./context.
|
|
66
|
+
export {
|
|
67
|
+
extractParams,
|
|
68
|
+
compilePattern,
|
|
69
|
+
countSegments,
|
|
70
|
+
matchCompiled,
|
|
71
|
+
type CompiledPattern,
|
|
72
|
+
type PathParams,
|
|
73
|
+
type QueryValues,
|
|
74
|
+
type Ctx,
|
|
75
|
+
type HandlerFor,
|
|
76
|
+
} from './context/index.js';
|
|
77
|
+
|
|
78
|
+
// Request pipeline & runtime adapters. See ./pipeline.
|
|
79
|
+
export {
|
|
80
|
+
createRouter,
|
|
81
|
+
toNodeHandler,
|
|
82
|
+
toFetchHandler,
|
|
83
|
+
json,
|
|
84
|
+
text,
|
|
85
|
+
respond,
|
|
86
|
+
bytes,
|
|
87
|
+
stream,
|
|
88
|
+
file,
|
|
89
|
+
bodyText,
|
|
90
|
+
type Router,
|
|
91
|
+
type WebRequest,
|
|
92
|
+
type WebResponse,
|
|
93
|
+
type ResponseBody,
|
|
94
|
+
type RouteOptions,
|
|
95
|
+
type GuardRegistry,
|
|
96
|
+
type RouterOptions,
|
|
97
|
+
type ResponseOptions,
|
|
98
|
+
type StreamOptions,
|
|
99
|
+
type FileResponseOptions,
|
|
100
|
+
type AdapterOptions,
|
|
101
|
+
} from './pipeline/index.js';
|
|
102
|
+
|
|
103
|
+
// Confined static files with validators and single-range streaming. See ./static.
|
|
104
|
+
export { createStaticHandler, type StaticHandler, type StaticOptions } from './static/index.js';
|
|
105
|
+
|
|
106
|
+
// Incremental, cross-runtime gzip/deflate middleware. See ./compression.
|
|
107
|
+
export { compress, compressionInterceptor, type CompressionOptions, type ContentCoding } from './compression/index.js';
|
|
108
|
+
|
|
109
|
+
// HTTP router construction over app-owned observability ports.
|
|
110
|
+
export { createTracedRouter } from './traced-router.js';
|
|
111
|
+
|
|
112
|
+
// HTTP validation and wire conversion. Repository DI tokens are app-owned.
|
|
113
|
+
export { validateWith } from './data/index.js';
|
|
114
|
+
|
|
115
|
+
// Guards, pipes, interceptors & exception filters. See ./middleware.
|
|
116
|
+
export {
|
|
117
|
+
runChain,
|
|
118
|
+
ChainError,
|
|
119
|
+
type AnyCtx,
|
|
120
|
+
type Guard,
|
|
121
|
+
type SecurityAwareGuard,
|
|
122
|
+
type Pipe,
|
|
123
|
+
type Interceptor,
|
|
124
|
+
type ExceptionFilter,
|
|
125
|
+
type Chain,
|
|
126
|
+
type ChainHandler,
|
|
127
|
+
} from './middleware/index.js';
|
|
128
|
+
|
|
129
|
+
// HTTP application bootstrap over one app-owned graph. See ./app.
|
|
130
|
+
export { createApp, type WebApplication, type WebApplicationOptions } from './app/index.js';
|
|
131
|
+
|
|
132
|
+
// DTO validation & serialization pipes. See ./dto-pipes.
|
|
133
|
+
export {
|
|
134
|
+
validationPipe,
|
|
135
|
+
multipartPipe,
|
|
136
|
+
serializationInterceptor,
|
|
137
|
+
dtoChain,
|
|
138
|
+
type DtoChainOptions,
|
|
139
|
+
} from './dto-pipes/index.js';
|
|
140
|
+
|
|
141
|
+
// Bounded multipart/form-data parsing. See ./upload.
|
|
142
|
+
export { parseMultipart, UPLOAD_DEFAULTS, type Multipart, type UploadLimits, type UploadPart } from './upload/index.js';
|
|
143
|
+
|
|
144
|
+
// Stateless, session-bound CSRF protection. See ./csrf.
|
|
145
|
+
export { createCsrf, type Csrf, type CsrfOptions } from './csrf/index.js';
|
|
146
|
+
|
|
147
|
+
// OpenAPI 3.1 projection from the shared HTTP contract IR. See ./openapi.
|
|
148
|
+
export {
|
|
149
|
+
toOpenApi,
|
|
150
|
+
serveOpenApi,
|
|
151
|
+
type JsonSchema,
|
|
152
|
+
type OpenApiDocument,
|
|
153
|
+
type OpenApiRenderOptions,
|
|
154
|
+
} from './openapi/index.js';
|
|
155
|
+
|
|
156
|
+
// WebSocket & SSE gateways. See ./gateways.
|
|
157
|
+
export {
|
|
158
|
+
Gateway,
|
|
159
|
+
Subscribe,
|
|
160
|
+
getSubscriptions,
|
|
161
|
+
createGatewayDispatcher,
|
|
162
|
+
sseStream,
|
|
163
|
+
type MessageCtx,
|
|
164
|
+
type Subscription,
|
|
165
|
+
type GatewayDispatcher,
|
|
166
|
+
type SseFrame,
|
|
167
|
+
} from './gateways/index.js';
|