@ingenioz-it/caits 0.7.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 +14 -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 +189 -19
- package/src/messaging/ContextEvents.ts +11 -0
- package/src/messaging/Controller.ts +35 -73
- package/src/messaging/EventSubscriber.ts +1 -1
- package/src/messaging/InProcessMessageBus.ts +6 -5
- package/src/messaging/MessageBus.ts +4 -1
- 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/testing/backend/applications.ts +4 -1
- package/src/testing/backend/presentationApplication.ts +23 -3
- package/src/testing/backend/presentationScenario.ts +84 -7
- package/src/testing/shared/FakeMessageBus.ts +39 -5
- package/src/vite.js +2 -0
- package/src/vite.types.d.ts +7 -0
- package/template/caits/architecture.mjs +26 -7
- package/template/caits/imports.mjs +2 -2
- package/template/caits/stryker.mjs +11 -6
- package/template/caits/vite.ts +13 -5
- package/quality/mutation-suite.mjs +0 -82
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export class InvalidInputError extends Error {
|
|
2
|
+
constructor(message: string, readonly details?: unknown) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "InvalidInputError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class MalformedBodyError extends InvalidInputError {}
|
|
9
|
+
|
|
10
|
+
export class UnsupportedMediaTypeError extends InvalidInputError {}
|
|
11
|
+
|
|
12
|
+
export class BodyTooLargeError extends InvalidInputError {}
|
|
13
|
+
|
|
14
|
+
export class CrossSiteRequestError extends InvalidInputError {}
|
|
15
|
+
|
|
16
|
+
/** The largest body that content reads by default, in bytes: 1 MB. */
|
|
17
|
+
export const MAX_BODY_SIZE = 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A JSON media type, in any case: application/json, or a type with the suffix +json (application/merge-patch+json). Parameters can
|
|
21
|
+
* follow, after a ";". A parameter is not the type: a browser sends `text/plain;x+json` from any site, as text/plain.
|
|
22
|
+
*/
|
|
23
|
+
const JSON_MEDIA_TYPE = /^(?:application\/json|[^\s/]+\/[^\s/;]+\+json)\s*(?:;|$)/i;
|
|
24
|
+
|
|
25
|
+
/** The media types of a form, in any case, with parameters after a ";": the boundary of the parts of multipart/form-data. */
|
|
26
|
+
const FORM_MEDIA_TYPE = /^(?:multipart\/form-data|application\/x-www-form-urlencoded)\s*(?:;|$)/i;
|
|
27
|
+
|
|
28
|
+
/** The headers of a request, which a controller reads and does not change. */
|
|
29
|
+
export type RequestHeaders = Pick<Headers, "get" | "has" | "forEach" | "entries" | "keys" | "values" | typeof Symbol.iterator>;
|
|
30
|
+
|
|
31
|
+
/** What content reads: the largest body, in bytes. */
|
|
32
|
+
export type ContentOptions = { readonly maxSize?: number };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The request that a controller reads. It gives the address, the method, the headers and the signal of a client that leaves, as the
|
|
36
|
+
* request of the web gives them, and the content of the body, which it reads once, and gives again at each call: the request of the web
|
|
37
|
+
* reads its body once only. A controller reads this request, never the request of the web (rule controllers-read-the-request-of-caits).
|
|
38
|
+
*/
|
|
39
|
+
export class Request {
|
|
40
|
+
readonly url: string;
|
|
41
|
+
readonly method: string;
|
|
42
|
+
readonly headers: RequestHeaders;
|
|
43
|
+
/** Aborts when the client leaves. */
|
|
44
|
+
readonly signal: AbortSignal;
|
|
45
|
+
readonly #request: globalThis.Request;
|
|
46
|
+
/** The bytes of the body, once read: the first call of content reads them, with its largest size. */
|
|
47
|
+
#body: Promise<Blob> | undefined;
|
|
48
|
+
|
|
49
|
+
constructor(request: globalThis.Request) {
|
|
50
|
+
this.#request = request;
|
|
51
|
+
this.url = request.url;
|
|
52
|
+
this.method = request.method;
|
|
53
|
+
this.headers = request.headers;
|
|
54
|
+
this.signal = request.signal;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The content of the body: the value of its JSON, or the fields of a form as an object, a text or a File for each name, a list for a
|
|
59
|
+
* name that comes more than once. The request must say what its body is: content-type application/json, or a type with the suffix
|
|
60
|
+
* +json, or a form, multipart/form-data or application/x-www-form-urlencoded. A browser sends a body of another type (text/plain) or a
|
|
61
|
+
* form from any site, without asking the server first, and says where the request comes from in its header Sec-Fetch-Site, which a
|
|
62
|
+
* client that is not a browser does not send: a form that a browser sends from another site is refused. The body must not be larger
|
|
63
|
+
* than `maxSize` bytes (MAX_BODY_SIZE, 1 MB, by default): the rest of a larger body is not read. The first call reads the body, with its
|
|
64
|
+
* `maxSize`; the next calls give its content again.
|
|
65
|
+
* @throws {UnsupportedMediaTypeError} when the request does not say that its body is JSON or a form: the Controller answers 415.
|
|
66
|
+
* @throws {CrossSiteRequestError} when a browser sends the form from another site: the Controller answers 403.
|
|
67
|
+
* @throws {BodyTooLargeError} when the body is larger than `maxSize`: the Controller answers 413.
|
|
68
|
+
* @throws {MalformedBodyError} when the body is not JSON, or not the form that it says: the Controller answers 400.
|
|
69
|
+
*/
|
|
70
|
+
async content({ maxSize = MAX_BODY_SIZE }: ContentOptions = {}): Promise<unknown> {
|
|
71
|
+
const contentType = this.headers.get("content-type");
|
|
72
|
+
if (contentType?.match(JSON_MEDIA_TYPE)) {
|
|
73
|
+
const text = await (await this.#bytes(maxSize)).text();
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(text);
|
|
76
|
+
} catch {
|
|
77
|
+
throw new MalformedBodyError("Invalid JSON");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!contentType?.match(FORM_MEDIA_TYPE)) throw new UnsupportedMediaTypeError("The content type must be application/json, multipart/form-data or application/x-www-form-urlencoded");
|
|
81
|
+
const site = this.headers.get("sec-fetch-site");
|
|
82
|
+
if (site === "cross-site" || site === "same-site") throw new CrossSiteRequestError("A browser sent this form from another site");
|
|
83
|
+
const bytes = await this.#bytes(maxSize);
|
|
84
|
+
let form: FormData;
|
|
85
|
+
try {
|
|
86
|
+
form = await new Response(bytes, { headers: { "content-type": contentType } }).formData();
|
|
87
|
+
} catch {
|
|
88
|
+
throw new MalformedBodyError("Invalid form");
|
|
89
|
+
}
|
|
90
|
+
const fields: Record<string, FormDataEntryValue | FormDataEntryValue[]> = {};
|
|
91
|
+
for (const [name, value] of form) {
|
|
92
|
+
// A field of its own, as JSON.parse makes it, also for a name such as __proto__.
|
|
93
|
+
const known = Object.hasOwn(fields, name) ? fields[name] : undefined;
|
|
94
|
+
Object.defineProperty(fields, name, { value: known === undefined ? value : [known, value].flat(), enumerable: true, writable: true, configurable: true });
|
|
95
|
+
}
|
|
96
|
+
return fields;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#bytes(maxSize: number): Promise<Blob> {
|
|
100
|
+
this.#body ??= bytesOf(this.#request, maxSize);
|
|
101
|
+
return this.#body;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The bytes of the body, read chunk by chunk: the reading stops at the first chunk after `maxSize` bytes. */
|
|
106
|
+
async function bytesOf(request: globalThis.Request, maxSize: number): Promise<Blob> {
|
|
107
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
const reader = request.body?.getReader();
|
|
110
|
+
for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
|
|
111
|
+
size += chunk.value.byteLength;
|
|
112
|
+
if (size > maxSize) throw new BodyTooLargeError(`The body is larger than ${maxSize} bytes`);
|
|
113
|
+
chunks.push(chunk.value);
|
|
114
|
+
}
|
|
115
|
+
return new Blob(chunks);
|
|
116
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
|
+
import { Token } from "../Token.js";
|
|
3
|
+
import type { EventSubscriber } from "./EventSubscriber.js";
|
|
4
|
+
|
|
5
|
+
/** The events that the handlers of a request publish, as they publish them: a controller that streams writes them in its response. */
|
|
6
|
+
export type RequestEvents = EventSubscriber<DomainEvent>;
|
|
7
|
+
|
|
8
|
+
export const RequestEvents = new Token<RequestEvents>();
|
|
@@ -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
|
+
}
|
|
@@ -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,
|