@ingenioz-it/caits 0.6.0 → 0.8.0
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/bin/caits.js +20 -7
- package/bin/init.js +16 -1
- package/package.json +8 -5
- package/quality/architecture/adapters/scss.mjs +2 -2
- package/quality/architecture/facts.mjs +39 -11
- package/quality/architecture/project.mjs +8 -6
- package/quality/dev.mjs +6 -3
- package/quality/mutation-summary.mjs +30 -20
- package/quality/mutation.mjs +170 -14
- package/src/adapter.js +94 -0
- package/src/di/Container.ts +27 -0
- package/src/di/RequestContext.ts +8 -2
- package/src/di/backend.ts +12 -0
- package/src/di/createBackend.ts +197 -25
- package/src/di/declareDependencies.js +25 -5
- package/src/di/discover.ts +19 -9
- package/src/di/discoverFrontends.ts +4 -8
- package/src/di/discovered.ts +2 -2
- package/src/di/discoveredFrontends.ts +2 -2
- package/src/messaging/BackendEventBus.ts +2 -3
- package/src/messaging/ContextEvents.ts +11 -0
- package/src/messaging/ContextMap.ts +3 -4
- package/src/messaging/Controller.ts +36 -75
- package/src/messaging/EventSubscriber.ts +1 -1
- package/src/messaging/InProcessMessageBus.ts +8 -9
- package/src/messaging/MessageBus.ts +4 -2
- package/src/messaging/Request.ts +116 -0
- package/src/messaging/RequestEvents.ts +8 -0
- package/src/messaging/StreamController.ts +261 -0
- package/src/messaging/TimerController.ts +25 -0
- package/src/messaging/sse.ts +23 -0
- package/src/server/entry.ts +37 -0
- package/src/server/kernel.ts +19 -0
- package/src/server/packages.d.ts +12 -0
- package/src/server/serve.js +121 -0
- package/src/state/createStore.ts +1 -2
- package/src/testing/backend/application.ts +1 -1
- package/src/testing/backend/applicationScenario.ts +6 -7
- package/src/testing/backend/applications.ts +4 -1
- package/src/testing/backend/context-map.ts +1 -1
- package/src/testing/backend/integration.ts +1 -1
- package/src/testing/backend/presentationApplication.ts +23 -3
- package/src/testing/backend/presentationScenario.ts +84 -7
- package/src/testing/frontend/applicationScenario.ts +2 -3
- package/src/testing/frontend/applications.ts +3 -3
- package/src/testing/frontend/frontends.ts +3 -4
- package/src/testing/shared/FakeMessageBus.ts +39 -5
- package/src/virtual-modules.d.ts +0 -2
- package/src/vite.js +2 -2
- package/src/vite.types.d.ts +7 -0
- package/template/caits/architecture.mjs +162 -113
- package/template/caits/imports.mjs +2 -2
- package/template/caits/stryker.mjs +11 -6
- package/template/caits/vite.ts +15 -5
- package/template/project/AGENTS +1 -1
- package/quality/mutation-suite.mjs +0 -82
- package/src/messaging/Input.ts +0 -4
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
|
+
import type { ContextEvents } from "./ContextEvents.js";
|
|
3
|
+
import { errorResponseOf } from "./Controller.js";
|
|
4
|
+
import type { MessageBus } from "./MessageBus.js";
|
|
5
|
+
import { reportToConsole, type ReportError } from "./ReportError.js";
|
|
6
|
+
import { Request } from "./Request.js";
|
|
7
|
+
import type { RequestEvents } from "./RequestEvents.js";
|
|
8
|
+
import { SSE_HEADERS, sseComment, sseEvent } from "./sse.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What comes to the stream: an event that a handler published, the output of the handler, or its error; or the text of an event of a
|
|
12
|
+
* request, which came before the stream started.
|
|
13
|
+
*/
|
|
14
|
+
type Item = { readonly event: DomainEvent } | { readonly output: unknown } | { readonly error: unknown } | { readonly text: string };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Turns a request into an input DTO, sends it to the message bus, and streams the answer as server-sent events: what the handler
|
|
18
|
+
* publishes, as it publishes it, then its output. It answers before the handler ends.
|
|
19
|
+
* - `follows` says which events it writes: those that the handlers of its request publish ("request"), until the handler answers; or
|
|
20
|
+
* those of every request of its context ("context"), after the output of its handler, the state at the start, until the client leaves.
|
|
21
|
+
* An event of the context may come while the handler reads the state, and be in it already: `stateHolds` says so, and the feed does
|
|
22
|
+
* not write it again. A feed ends when the events of its context end, as the backend stops: its client comes back to the next one. A
|
|
23
|
+
* stream of a request goes on, as any request that the server lets end.
|
|
24
|
+
* - An EventSource, in a browser, connects again to a stream that ends, with the same GET: a feed gives its state again. It would run the
|
|
25
|
+
* use case of a stream of a request again and again: its client closes it at the last event, or the API takes POST.
|
|
26
|
+
* - Until the first event that it writes, or the output, an error is a response, as a Controller gives it (`toErrorResponse`): an event
|
|
27
|
+
* that it does not write, as one for another context, decides nothing, since the client reads nothing of it. An event that it cannot
|
|
28
|
+
* write is then an error too. Once the stream started, an error goes in the stream (`toErrorChunk`), which ends: an error of the
|
|
29
|
+
* handler, or of the controller as it writes the stream (`toChunk`, `toOutputChunk`, `stateHolds`, `toKeepAlive`). An error that
|
|
30
|
+
* `toErrorChunk` cannot write is reported, with the error of `toErrorChunk`.
|
|
31
|
+
* - When the stream says nothing for `keepAliveMs`, a comment keeps the connection alive.
|
|
32
|
+
* - It gives the bus the signal of the request with the DTO: the signal aborts when the client leaves, or stops reading the stream, or
|
|
33
|
+
* when the controller cannot write the stream, and the handler reads it in its RequestContext.
|
|
34
|
+
* - A request that asks for no stream (`streams`) gets the response to the output (`toResponse`), once the handler answers, as from a
|
|
35
|
+
* Controller: an API that streams or not at the same address, as the request says.
|
|
36
|
+
* - It reads the request through the Request of CAITS, which gives the content of the body at each call.
|
|
37
|
+
*/
|
|
38
|
+
export abstract class StreamController<Dto extends object, Output> {
|
|
39
|
+
/** The events that it writes: those of its request, or those of its context. */
|
|
40
|
+
protected readonly follows: "request" | "context" = "request";
|
|
41
|
+
/** How long the stream may stay silent before a comment keeps the connection alive, in milliseconds. */
|
|
42
|
+
protected readonly keepAliveMs: number = 15_000;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private bus: MessageBus,
|
|
46
|
+
private requestEvents: RequestEvents,
|
|
47
|
+
private contextEvents: ContextEvents,
|
|
48
|
+
protected readonly reportError: ReportError = reportToConsole
|
|
49
|
+
) {}
|
|
50
|
+
|
|
51
|
+
protected abstract toDto(request: Request): Dto | Promise<Dto>;
|
|
52
|
+
|
|
53
|
+
/** The text of an event in the stream: nothing writes nothing. */
|
|
54
|
+
protected abstract toChunk(event: DomainEvent): string | undefined;
|
|
55
|
+
|
|
56
|
+
/** The text of the output in the stream: the end of the answer of a request, or the state at the start of a feed. */
|
|
57
|
+
protected abstract toOutputChunk(output: Output): string | undefined;
|
|
58
|
+
|
|
59
|
+
/** The response to an error before the stream starts: as a Controller gives it. A controller answers its own errors first. */
|
|
60
|
+
protected toErrorResponse(error: unknown, _request: Request): Response | Promise<Response> {
|
|
61
|
+
return errorResponseOf(error, this.reportError);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The text of an error once the stream started: the error is reported, and the event tells nothing of it. The event is a "problem",
|
|
66
|
+
* as CAITS names its errors: an EventSource, in a browser, gives the name "error" to a connection that fails or ends.
|
|
67
|
+
*/
|
|
68
|
+
protected toErrorChunk(error: unknown): string {
|
|
69
|
+
this.reportError(error);
|
|
70
|
+
return sseEvent(JSON.stringify({ title: "Internal server error" }), { event: "problem" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether the state at the start of a feed holds an event that came before it already: the feed writes it only when the state does
|
|
75
|
+
* not hold it. It writes each event that comes after the state. A state holds no event by default.
|
|
76
|
+
*/
|
|
77
|
+
protected stateHolds(_state: Output, _event: DomainEvent): boolean {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether the request asks for a stream: each one does by default. */
|
|
82
|
+
protected streams(_dto: Dto): boolean {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The response to the output, for a request that asks for no stream: the output in JSON by default. */
|
|
87
|
+
protected toResponse(output: Output, _request: Request): Response | Promise<Response> {
|
|
88
|
+
return Response.json(output);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected headers(): HeadersInit {
|
|
92
|
+
return SSE_HEADERS;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
protected toKeepAlive(): string {
|
|
96
|
+
return sseComment("keep-alive");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Answers a request of the web: the methods of the controller read it through the Request of CAITS. */
|
|
100
|
+
async handle(received: globalThis.Request): Promise<Response> {
|
|
101
|
+
try {
|
|
102
|
+
return await this.respond(new Request(received));
|
|
103
|
+
} catch (failure) {
|
|
104
|
+
// A response to an error that fails too: the answer of a Controller.
|
|
105
|
+
return errorResponseOf(failure, this.reportError);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async respond(request: Request): Promise<Response> {
|
|
110
|
+
let dto: Dto;
|
|
111
|
+
try {
|
|
112
|
+
dto = await this.toDto(request);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return this.toErrorResponse(error, request);
|
|
115
|
+
}
|
|
116
|
+
if (!this.streams(dto)) return this.answer(dto, request);
|
|
117
|
+
const queue: Item[] = [];
|
|
118
|
+
let wake: () => void = () => {};
|
|
119
|
+
// The client leaves when its request aborts, or when it stops reading the stream.
|
|
120
|
+
const cancelled = new AbortController();
|
|
121
|
+
// Before the stream starts, a stream of a request writes each event as it comes: the first text that it writes starts the stream. A
|
|
122
|
+
// feed keeps its events, which its state may hold.
|
|
123
|
+
let take: (item: Item) => void = item => {
|
|
124
|
+
let kept = item;
|
|
125
|
+
if (this.follows === "request" && "event" in item) {
|
|
126
|
+
try {
|
|
127
|
+
const text = this.toChunk(item.event);
|
|
128
|
+
if (text === undefined) return;
|
|
129
|
+
kept = { text };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// No one reads what the handler does next: it stops.
|
|
132
|
+
cancelled.abort();
|
|
133
|
+
kept = { error };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
queue.push(kept);
|
|
137
|
+
wake();
|
|
138
|
+
};
|
|
139
|
+
const stopListening = (this.follows === "request" ? this.requestEvents : this.contextEvents).subscribe(event => { take({ event }); });
|
|
140
|
+
const answering = this.bus.send(dto, { signal: AbortSignal.any([request.signal, cancelled.signal]) }).then(output => ({ output }), (error: unknown) => ({ error }));
|
|
141
|
+
void answering.then(item => {
|
|
142
|
+
// The output of a feed is its start: it goes first, below.
|
|
143
|
+
if (this.follows === "request") take(item);
|
|
144
|
+
});
|
|
145
|
+
// A request waits for what comes first; a feed, for its start; both, until the client leaves.
|
|
146
|
+
const { promise: left, resolve: leftNow } = Promise.withResolvers<undefined>();
|
|
147
|
+
const leaveEarly = () => { leftNow(undefined); };
|
|
148
|
+
request.signal.addEventListener("abort", leaveEarly);
|
|
149
|
+
if (request.signal.aborted) leaveEarly();
|
|
150
|
+
const first = await Promise.race([
|
|
151
|
+
this.follows === "request" ? new Promise<Item>(resolve => { wake = () => { resolve(queue[0]); }; if (queue.length > 0) wake(); }) : answering,
|
|
152
|
+
left
|
|
153
|
+
]);
|
|
154
|
+
if (first === undefined) {
|
|
155
|
+
stopListening();
|
|
156
|
+
// No one reads it: 499, as servers write in their logs a client that left.
|
|
157
|
+
return new Response(null, { status: 499 });
|
|
158
|
+
}
|
|
159
|
+
if ("error" in first) {
|
|
160
|
+
stopListening();
|
|
161
|
+
return this.toErrorResponse(first.error, request);
|
|
162
|
+
}
|
|
163
|
+
// A feed starts with its state, the output of its handler. The events that came before it may be in it already. A stream of a
|
|
164
|
+
// request keeps no event before it starts: it wrote each one as it came.
|
|
165
|
+
const early = new Set(queue);
|
|
166
|
+
if (this.follows === "context") queue.unshift(first);
|
|
167
|
+
|
|
168
|
+
const encoder = new TextEncoder();
|
|
169
|
+
let open = true;
|
|
170
|
+
let silence: ReturnType<typeof setTimeout> | undefined;
|
|
171
|
+
const ended = new AbortController();
|
|
172
|
+
const end = () => {
|
|
173
|
+
open = false;
|
|
174
|
+
clearTimeout(silence);
|
|
175
|
+
stopListening();
|
|
176
|
+
ended.abort();
|
|
177
|
+
};
|
|
178
|
+
const leave = () => {
|
|
179
|
+
end();
|
|
180
|
+
cancelled.abort();
|
|
181
|
+
};
|
|
182
|
+
request.signal.addEventListener("abort", leave);
|
|
183
|
+
const body = new ReadableStream<Uint8Array>({
|
|
184
|
+
start: stream => {
|
|
185
|
+
const close = () => {
|
|
186
|
+
end();
|
|
187
|
+
stream.close();
|
|
188
|
+
};
|
|
189
|
+
// The silence counts from the last text written: an event that writes nothing breaks no silence.
|
|
190
|
+
const quiet = () => {
|
|
191
|
+
clearTimeout(silence);
|
|
192
|
+
silence = setTimeout(() => {
|
|
193
|
+
try {
|
|
194
|
+
write(this.toKeepAlive());
|
|
195
|
+
} catch (error) {
|
|
196
|
+
failToWrite(error);
|
|
197
|
+
}
|
|
198
|
+
}, this.keepAliveMs);
|
|
199
|
+
};
|
|
200
|
+
const write = (text: string | undefined) => {
|
|
201
|
+
if (text === undefined) return;
|
|
202
|
+
stream.enqueue(encoder.encode(text));
|
|
203
|
+
quiet();
|
|
204
|
+
};
|
|
205
|
+
// An error ends the stream with its event; when the controller cannot write the event, it reports both errors.
|
|
206
|
+
const fail = (error: unknown) => {
|
|
207
|
+
try {
|
|
208
|
+
write(this.toErrorChunk(error));
|
|
209
|
+
} catch (failure) {
|
|
210
|
+
this.reportError(error);
|
|
211
|
+
this.reportError(failure);
|
|
212
|
+
}
|
|
213
|
+
close();
|
|
214
|
+
};
|
|
215
|
+
// No one reads what the handler does next: it stops, as when the client leaves.
|
|
216
|
+
const failToWrite = (error: unknown) => {
|
|
217
|
+
fail(error);
|
|
218
|
+
cancelled.abort();
|
|
219
|
+
};
|
|
220
|
+
take = item => {
|
|
221
|
+
if (!open) return;
|
|
222
|
+
try {
|
|
223
|
+
if ("text" in item) {
|
|
224
|
+
write(item.text);
|
|
225
|
+
} else if ("event" in item) {
|
|
226
|
+
if (!(early.has(item) && this.stateHolds((first as { readonly output: Output }).output, item.event))) write(this.toChunk(item.event));
|
|
227
|
+
} else if ("error" in item) {
|
|
228
|
+
fail(item.error);
|
|
229
|
+
} else {
|
|
230
|
+
write(this.toOutputChunk(item.output as Output));
|
|
231
|
+
if (this.follows === "request") close();
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
failToWrite(error);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
quiet();
|
|
238
|
+
for (const item of queue.splice(0)) take(item);
|
|
239
|
+
// A feed ends with the events of its context, as the backend stops.
|
|
240
|
+
if (open && this.follows === "context") {
|
|
241
|
+
const { signal } = this.contextEvents;
|
|
242
|
+
signal.addEventListener("abort", close, { signal: ended.signal });
|
|
243
|
+
if (signal.aborted) close();
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
cancel: leave
|
|
247
|
+
});
|
|
248
|
+
return new Response(body, { status: 200, headers: this.headers() });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Sends the DTO with the signal of the request, and answers as a Controller does once the handler answers. */
|
|
252
|
+
private async answer(dto: Dto, request: Request): Promise<Response> {
|
|
253
|
+
let output: Output;
|
|
254
|
+
try {
|
|
255
|
+
output = await this.bus.send(dto, { signal: request.signal }) as Output;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return await this.toErrorResponse(error, request);
|
|
258
|
+
}
|
|
259
|
+
return await this.toResponse(output, request);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MessageBus } from "./MessageBus.js";
|
|
2
|
+
import { reportToConsole, type ReportError } from "./ReportError.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sends an input DTO at each turn of a timer: what an HTTP controller does for a request, with time for the trigger. The kernel runs it
|
|
6
|
+
* every `every` milliseconds while the server runs, in a request of its own at each turn, and skips a turn while the last one runs. An
|
|
7
|
+
* error is reported, never thrown: the next turn comes anyway.
|
|
8
|
+
*/
|
|
9
|
+
export abstract class TimerController<Dto extends object> {
|
|
10
|
+
constructor(private bus: MessageBus, private reportError: ReportError = reportToConsole) {}
|
|
11
|
+
|
|
12
|
+
/** How often the kernel runs it, in milliseconds. */
|
|
13
|
+
abstract readonly every: number;
|
|
14
|
+
|
|
15
|
+
protected abstract toDto(): Dto | Promise<Dto>;
|
|
16
|
+
|
|
17
|
+
/** Sends the DTO of this turn, and waits for its handler. */
|
|
18
|
+
async tick(): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
await this.bus.send(await this.toDto());
|
|
21
|
+
} catch (error) {
|
|
22
|
+
this.reportError(error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The headers of a stream of server-sent events: no cache, and no compression that would hold its chunks back. */
|
|
2
|
+
export const SSE_HEADERS: Readonly<Record<string, string>> = { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform" };
|
|
3
|
+
|
|
4
|
+
/** Where a client of server-sent events ends a line. */
|
|
5
|
+
const LINE_BREAK = /\r\n|\r|\n/;
|
|
6
|
+
|
|
7
|
+
/** The value of a field that has one line: a line break would end the field, and start another one. */
|
|
8
|
+
function oneLine(field: "event" | "id", value: string): string {
|
|
9
|
+
if (/[\r\n]/.test(value)) throw new Error(`The ${field} of a server-sent event holds no line break: ${JSON.stringify(value)}`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A server-sent event: its name and its id when it has them, then its data, line by line. */
|
|
14
|
+
export function sseEvent(data: string, { event, id }: { readonly event?: string; readonly id?: string } = {}): string {
|
|
15
|
+
const name = event === undefined ? "" : `event: ${oneLine("event", event)}\n`;
|
|
16
|
+
const identity = id === undefined ? "" : `id: ${oneLine("id", id)}\n`;
|
|
17
|
+
return `${name}${identity}${data.split(LINE_BREAK).map(line => `data: ${line}`).join("\n")}\n\n`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A comment, which a client of server-sent events ignores: it keeps the connection alive, and can tell a person what happens. */
|
|
21
|
+
export function sseComment(text: string): string {
|
|
22
|
+
return `${text.split(LINE_BREAK).map(line => `: ${line}`).join("\n")}\n\n`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What `npm start` runs: `npm run build` builds the server of the app from this file, which the adapter of CAITS (adapter.js) gives to
|
|
2
|
+
// @marko/run. It serves the files, the pages and the handlers of the app as the entry of @marko/run does, boots the backend first, and
|
|
3
|
+
// stops it on SIGTERM or SIGINT.
|
|
4
|
+
import { createMiddleware } from "@marko/run/adapter/middleware";
|
|
5
|
+
import { fetch } from "@marko/run/router";
|
|
6
|
+
import compression from "compression";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import zlib from "node:zlib";
|
|
10
|
+
import createStaticServe from "serve-static";
|
|
11
|
+
import { boot } from "./kernel.js";
|
|
12
|
+
import { serve, stopOnSignals } from "./serve.js";
|
|
13
|
+
|
|
14
|
+
const folder = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const middleware = createMiddleware(fetch);
|
|
16
|
+
const compress = compression({ flush: zlib.constants.Z_PARTIAL_FLUSH, threshold: 500 });
|
|
17
|
+
const servePublic = createStaticServe(`${folder}/public`, { index: false, redirect: false, maxAge: "10 minutes" });
|
|
18
|
+
const serveAssets = createStaticServe(`${folder}/public/assets`, { index: false, redirect: false, immutable: true, fallthrough: false, maxAge: "365 days" });
|
|
19
|
+
|
|
20
|
+
const served = await serve({
|
|
21
|
+
port: Number(process.env.PORT ?? 3000),
|
|
22
|
+
boot,
|
|
23
|
+
handle: (request, response) => {
|
|
24
|
+
compress(request, response, () => {
|
|
25
|
+
if (request.url?.startsWith("/assets/")) {
|
|
26
|
+
request.url = request.url.slice("/assets".length);
|
|
27
|
+
serveAssets(request, response, () => {
|
|
28
|
+
response.statusCode = 404;
|
|
29
|
+
response.end();
|
|
30
|
+
});
|
|
31
|
+
} else {
|
|
32
|
+
servePublic(request, response, () => { middleware(request, response); });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
stopOnSignals(served.close);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { startBackend, stopBackend } from "../di/backend.js";
|
|
2
|
+
import type { BackendOptions } from "../di/createBackend.js";
|
|
3
|
+
import type { Stop } from "./serve.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Boots the backend of the server, with the bindings of the di.ts of the contexts, and starts its timers. Gives what stops it: the server
|
|
7
|
+
* of `npm start` and that of `caits dev` call it when they stop, and `caits dev` before it boots the backend again. A backend whose timers
|
|
8
|
+
* cannot start stops at once, and releases what it built: the boot fails.
|
|
9
|
+
*/
|
|
10
|
+
export async function boot(options: BackendOptions = {}): Promise<Stop> {
|
|
11
|
+
const backend = startBackend(options);
|
|
12
|
+
try {
|
|
13
|
+
backend.start();
|
|
14
|
+
} catch (error) {
|
|
15
|
+
await stopBackend();
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
return (requestsDone, graceOver) => stopBackend(requestsDone, graceOver);
|
|
19
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The packages that the entry of the server (entry.ts) uses as the entry of @marko/run does. CAITS depends on them, as @marko/run does: they have no types. */
|
|
2
|
+
declare module "compression" {
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
type Options = { readonly flush?: number; readonly threshold?: number };
|
|
5
|
+
export default function compression(options?: Options): (request: IncomingMessage, response: ServerResponse, next: () => void) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
declare module "serve-static" {
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
type Options = { readonly index?: false; readonly redirect?: boolean; readonly maxAge?: string; readonly immutable?: boolean; readonly fallthrough?: boolean };
|
|
11
|
+
export default function serveStatic(root: string, options?: Options): (request: IncomingMessage, response: ServerResponse, next: () => void) => void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The HTTP server of an app, for `npm start` (entry.ts) and for `caits dev` (adapter.js). JavaScript, not TypeScript: `caits dev` loads
|
|
2
|
+
// it with Node, which strips no types in node_modules, where a project installs the kernel.
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
|
|
5
|
+
/** @import { IncomingMessage, ServerResponse } from "node:http" */
|
|
6
|
+
/** @import { AddressInfo } from "node:net" */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What stops the backend: its feeds and its timers, then, once the requests that the server serves are done (the promise), what its
|
|
10
|
+
* containers built. The signal aborts when the grace ends: the backend waits for its turns and its reactions until then at most.
|
|
11
|
+
* @typedef {(requestsDone: Promise<unknown>, graceOver: AbortSignal) => Promise<void>} Stop
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} ServeOptions
|
|
16
|
+
* @property {(request: IncomingMessage, response: ServerResponse) => void} handle The requests: the pages, the handlers, the files.
|
|
17
|
+
* @property {number} port The port, 0 for any free port.
|
|
18
|
+
* @property {() => Stop | Promise<Stop>} boot Boots the backend, and gives what stops it.
|
|
19
|
+
* @property {number} [grace] How long the requests, the turns of the timers and the reactions that run may still run once the server
|
|
20
|
+
* stops, in milliseconds: 10 s by default.
|
|
21
|
+
* @property {(error: unknown) => void} [reportError] Reports a boot that fails at a reboot: the console by default.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What stops no backend: that of a reboot whose boot failed. */
|
|
25
|
+
const NO_STOP = async () => {};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Boots the backend, then serves the requests on the port.
|
|
29
|
+
* - `reboot` stops the backend, then boots it again (`caits dev` does it when its code changes): never two backends at once. A request
|
|
30
|
+
* that comes meanwhile waits for the new backend. A boot that fails is reported: the server serves on, and the next reboot boots again.
|
|
31
|
+
* - `close` serves no new request, stops the backend (which ends its feeds, then its timers), lets the requests that run end (after the
|
|
32
|
+
* grace, it closes their connections), then releases what the backend built. The connection of a request that ends closes. A request
|
|
33
|
+
* that comes meanwhile on a connection that still serves another gets a 503, which closes the connection. A reboot after it boots
|
|
34
|
+
* nothing.
|
|
35
|
+
* @param {ServeOptions} options
|
|
36
|
+
*/
|
|
37
|
+
export async function serve({ handle, port, boot, grace = 10_000, reportError = error => { console.error(error); } }) {
|
|
38
|
+
let stop = await boot();
|
|
39
|
+
/** @type {Promise<void>} the reboots, one after the other */
|
|
40
|
+
let reboots = Promise.resolve();
|
|
41
|
+
/** @type {Promise<void> | undefined} */
|
|
42
|
+
let closing;
|
|
43
|
+
const server = createServer((request, response) => {
|
|
44
|
+
if (closing) {
|
|
45
|
+
response.writeHead(503, { connection: "close" }).end();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// A response that ends while the server closes frees its connection: the server closes it. A stream that started before said that
|
|
49
|
+
// the connection stays open, and the client would keep it until its own delay, a few seconds, which the close would wait for.
|
|
50
|
+
response.once("finish", () => { if (closing) server.closeIdleConnections(); });
|
|
51
|
+
void reboots.then(() => { handle(request, response); });
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
await new Promise((resolve, reject) => { server.once("error", reject).listen(port, () => { resolve(undefined); }); });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await stop(Promise.resolve(), AbortSignal.abort());
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
port: /** @type {AddressInfo} */ (server.address()).port,
|
|
61
|
+
/** @returns {Promise<void>} */
|
|
62
|
+
reboot: () => (reboots = reboots.then(async () => {
|
|
63
|
+
if (closing) return;
|
|
64
|
+
try {
|
|
65
|
+
// A reboot lets no request end: no grace.
|
|
66
|
+
await stop(Promise.resolve(), AbortSignal.abort());
|
|
67
|
+
stop = NO_STOP;
|
|
68
|
+
stop = await boot();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
reportError(error);
|
|
71
|
+
}
|
|
72
|
+
})),
|
|
73
|
+
/** @returns {Promise<void>} */
|
|
74
|
+
close: () => (closing ??= (async () => {
|
|
75
|
+
await reboots;
|
|
76
|
+
const requestsDone = new Promise(resolve => { server.close(() => { resolve(undefined); }); });
|
|
77
|
+
const graceOver = new AbortController();
|
|
78
|
+
const cut = setTimeout(() => {
|
|
79
|
+
server.closeAllConnections();
|
|
80
|
+
graceOver.abort();
|
|
81
|
+
}, grace);
|
|
82
|
+
await stop(requestsDone, graceOver.signal);
|
|
83
|
+
clearTimeout(cut);
|
|
84
|
+
})())
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {object} SignalOptions
|
|
90
|
+
* @property {{ on(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown }} [signals] Where the signals come: the process by default.
|
|
91
|
+
* @property {(code: number) => void} [exit] Ends the process: process.exit by default.
|
|
92
|
+
* @property {(error: unknown) => void} [reportError] Reports a close that fails: the console by default.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/** How long after the first signal a second one ends the process at once, in milliseconds. */
|
|
96
|
+
const SECOND_SIGNAL_MS = 1000;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Closes the server on SIGTERM (`docker stop`) or SIGINT (Ctrl+C), then ends the process: 0 when the close succeeds, 1 when it fails. A
|
|
100
|
+
* second signal, a second or more after the first, ends it at once, with 1. A signal often comes twice at once: Ctrl+C reaches each
|
|
101
|
+
* process of the group of the terminal, and npm, in `npm start`, passes it on to the server. It counts once.
|
|
102
|
+
* @param {() => Promise<void>} close
|
|
103
|
+
* @param {SignalOptions} [options]
|
|
104
|
+
*/
|
|
105
|
+
export function stopOnSignals(close, { signals = process, exit = code => process.exit(code), reportError = error => { console.error(error); } } = {}) {
|
|
106
|
+
/** @type {number | undefined} */
|
|
107
|
+
let stoppedAt;
|
|
108
|
+
const stopNow = () => {
|
|
109
|
+
if (stoppedAt !== undefined) {
|
|
110
|
+
if (Date.now() - stoppedAt >= SECOND_SIGNAL_MS) exit(1);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
stoppedAt = Date.now();
|
|
114
|
+
close().then(() => { exit(0); }, error => {
|
|
115
|
+
reportError(error);
|
|
116
|
+
exit(1);
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
signals.on("SIGTERM", stopNow);
|
|
120
|
+
signals.on("SIGINT", stopNow);
|
|
121
|
+
}
|
package/src/state/createStore.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { EventSubscriber } from "../messaging/EventSubscriber.js";
|
|
2
|
-
import type { Input } from "../messaging/Input.js";
|
|
3
2
|
import type { MessageBus } from "../messaging/MessageBus.js";
|
|
4
3
|
import { reportToConsole, type ReportError } from "../messaging/ReportError.js";
|
|
5
4
|
|
|
@@ -9,7 +8,7 @@ export interface StoreDefinition<S, E> {
|
|
|
9
8
|
/** The state after the event. */
|
|
10
9
|
reduce(state: S, event: E): S;
|
|
11
10
|
/** The input DTO that the event asks the store to send to the message bus, or undefined. It reads the state before the event. */
|
|
12
|
-
send(state: S, event: E):
|
|
11
|
+
send(state: S, event: E): object | undefined;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
14
|
export interface Store<S> {
|
|
@@ -2,7 +2,7 @@ import { test as base } from "vitest";
|
|
|
2
2
|
import { createTestApplication, type TestApplication } from "./applications.js";
|
|
3
3
|
|
|
4
4
|
/** Everything an application test of the backend imports: the test of a use case, written as a scenario. */
|
|
5
|
-
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, vi } from "vitest";
|
|
5
|
+
export { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, vi } from "vitest";
|
|
6
6
|
export { applicationScenario } from "./applicationScenario.js";
|
|
7
7
|
export type { TestApplication } from "./applications.js";
|
|
8
8
|
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { expect } from "vitest";
|
|
2
2
|
import type { DomainEvent } from "../../DomainEvent.js";
|
|
3
|
-
import type { Input } from "../../messaging/Input.js";
|
|
4
3
|
import type { TestApplication } from "./applications.js";
|
|
5
4
|
|
|
6
|
-
type Result
|
|
5
|
+
type Result = { output: unknown } | { error: unknown };
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* An application test, written as a presentation test: the input DTO, the events the handler publishes, then the output DTO or the error
|
|
10
9
|
* that the message bus gives. The events are checked only when the test says which ones it expects.
|
|
11
10
|
*/
|
|
12
|
-
export class ApplicationScenario
|
|
11
|
+
export class ApplicationScenario {
|
|
13
12
|
private readonly expected: DomainEvent[] = [];
|
|
14
13
|
private checksEvents = false;
|
|
15
14
|
|
|
16
|
-
constructor(private readonly app: TestApplication, private readonly dto:
|
|
15
|
+
constructor(private readonly app: TestApplication, private readonly dto: object) {}
|
|
17
16
|
|
|
18
17
|
expectItToPublish(event: DomainEvent): this {
|
|
19
18
|
this.expected.push(event);
|
|
@@ -26,7 +25,7 @@ export class ApplicationScenario<Output> {
|
|
|
26
25
|
return this;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
async expectTheOutputToBe(output:
|
|
28
|
+
async expectTheOutputToBe(output: object): Promise<void> {
|
|
30
29
|
const from = this.app.published.length;
|
|
31
30
|
const result = await this.run();
|
|
32
31
|
if ("error" in result) throw result.error;
|
|
@@ -42,7 +41,7 @@ export class ApplicationScenario<Output> {
|
|
|
42
41
|
expect(result.error).toStrictEqual(error);
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
private async run(): Promise<Result
|
|
44
|
+
private async run(): Promise<Result> {
|
|
46
45
|
try {
|
|
47
46
|
return { output: await this.app.send(this.dto) };
|
|
48
47
|
} catch (error) {
|
|
@@ -56,5 +55,5 @@ export class ApplicationScenario<Output> {
|
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
export const applicationScenario = (app: TestApplication) => ({
|
|
59
|
-
givenTheDto:
|
|
58
|
+
givenTheDto: (dto: object) => new ApplicationScenario(app, dto)
|
|
60
59
|
});
|
|
@@ -47,7 +47,10 @@ export function createTestApplication() {
|
|
|
47
47
|
backend.subscribeToContexts(event => { published.push(event); });
|
|
48
48
|
|
|
49
49
|
return {
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Sends an input DTO to the message bus of the context that handles it: with the signal of a client, `send(dto, { signal })`, its
|
|
52
|
+
* handler sees the client leave when the test aborts it.
|
|
53
|
+
*/
|
|
51
54
|
send: backend.send,
|
|
52
55
|
/** The dependency with this token, built now if no handler needed it before. */
|
|
53
56
|
get: backend.get,
|
|
@@ -5,7 +5,7 @@ import { createTestBackendApplication, type TestBackendApplication } from "./app
|
|
|
5
5
|
* Everything a test of the context map imports. `test` checks what the `ContextMap` gives: the input DTOs for an event of shared
|
|
6
6
|
* (`inputsFor`), the events of shared for an event of the context (`eventsFor`).
|
|
7
7
|
*/
|
|
8
|
-
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
8
|
+
export { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
9
9
|
export { ContextMap } from "../../messaging/ContextMap.js";
|
|
10
10
|
export type { TestBackendApplication } from "./applications.js";
|
|
11
11
|
|
|
@@ -2,7 +2,7 @@ import { createBackend } from "../../di/createBackend.js";
|
|
|
2
2
|
import { discovered } from "../../di/discovered.js";
|
|
3
3
|
|
|
4
4
|
/** Everything an integration test imports: flows of the context map again, with the real infra implementations. */
|
|
5
|
-
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
5
|
+
export { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
6
|
|
|
7
7
|
/** The whole backend, built from the di.ts of the contexts only: the real infra implementations, as in production. */
|
|
8
8
|
export const productionBackend = () => createBackend(discovered);
|
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
/// <reference path="../../virtual-modules.d.ts" />
|
|
2
2
|
import "virtual:marko-run/__marko-run__router.js";
|
|
3
3
|
import { fetch } from "@marko/run/router";
|
|
4
|
-
import { startBackend } from "../../di/backend.js";
|
|
4
|
+
import { controller, startBackend } from "../../di/backend.js";
|
|
5
5
|
import { bind } from "../../di/Container.js";
|
|
6
|
+
import type { DomainEvent } from "../../DomainEvent.js";
|
|
7
|
+
import { ContextEvents } from "../../messaging/ContextEvents.js";
|
|
6
8
|
import { MessageBus } from "../../messaging/MessageBus.js";
|
|
9
|
+
import { RequestEvents } from "../../messaging/RequestEvents.js";
|
|
10
|
+
import type { TimerController } from "../../messaging/TimerController.js";
|
|
7
11
|
import { FakeMessageBus } from "../shared/FakeMessageBus.js";
|
|
8
12
|
|
|
9
13
|
/** The real routes and controllers, over a bus that only records: each test says what the application answers. */
|
|
10
14
|
export function startPresentationApplication() {
|
|
11
|
-
|
|
15
|
+
// The events that the application publishes, as the test says: the controllers that stream hear them, of their request and of their context.
|
|
16
|
+
const listeners = new Set<(event: DomainEvent) => void>();
|
|
17
|
+
// They never end: a feed ends when the client of the test leaves.
|
|
18
|
+
const events: ContextEvents = {
|
|
19
|
+
subscribe: listener => {
|
|
20
|
+
listeners.add(listener);
|
|
21
|
+
return () => { listeners.delete(listener); };
|
|
22
|
+
},
|
|
23
|
+
signal: new AbortController().signal
|
|
24
|
+
};
|
|
25
|
+
const messageBus = new FakeMessageBus(event => { for (const listener of [...listeners]) listener(event); });
|
|
12
26
|
const reportedErrors: unknown[] = [];
|
|
13
27
|
const reportError = (error: unknown) => { reportedErrors.push(error); };
|
|
14
|
-
startBackend({ reportError, replace: { shared: [bind(MessageBus).toValue(messageBus)] } });
|
|
28
|
+
startBackend({ reportError, replace: { shared: [bind(MessageBus).toValue(messageBus), bind(RequestEvents).toValue(events), bind(ContextEvents).toValue(events)] } });
|
|
15
29
|
|
|
16
30
|
return {
|
|
17
31
|
messageBus,
|
|
18
32
|
reportedErrors,
|
|
33
|
+
/** Runs one turn of a timer controller, as the kernel does while the server runs, in a request of its own. Gives its rhythm. */
|
|
34
|
+
async tick(timer: abstract new (...args: never[]) => TimerController<object>): Promise<number> {
|
|
35
|
+
const turn = controller(timer);
|
|
36
|
+
await turn.tick();
|
|
37
|
+
return turn.every;
|
|
38
|
+
},
|
|
19
39
|
async fetch(request: Request): Promise<Response> {
|
|
20
40
|
const response = await fetch(request, {} as never);
|
|
21
41
|
if (!response) throw new Error(`No route answers ${request.method} ${new URL(request.url).pathname}`);
|