@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
package/src/di/Container.ts
CHANGED
|
@@ -40,6 +40,13 @@ export function bind<T>(key: Key<T>) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const describe = (route: readonly Key[]) => route.map(key => key.name).join(" → ");
|
|
43
|
+
|
|
44
|
+
/** Releases an instance that holds a resource: with Symbol.asyncDispose, else Symbol.dispose. Another instance holds none. */
|
|
45
|
+
async function release(instance: unknown): Promise<void> {
|
|
46
|
+
const { [Symbol.asyncDispose]: asyncDispose, [Symbol.dispose]: dispose } = Object(instance) as Partial<AsyncDisposable & Disposable>;
|
|
47
|
+
if (typeof asyncDispose === "function") await asyncDispose.call(instance);
|
|
48
|
+
else if (typeof dispose === "function") dispose.call(instance);
|
|
49
|
+
}
|
|
43
50
|
const isKey = (value: unknown): value is Key => value instanceof Token || typeof value === "function";
|
|
44
51
|
|
|
45
52
|
/**
|
|
@@ -90,6 +97,26 @@ export class Container {
|
|
|
90
97
|
return value;
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Releases what this container built, the last built first, so a dependency outlives what needs it: each instance with
|
|
102
|
+
* Symbol.asyncDispose or Symbol.dispose. A value that a binding gives belongs to whoever made it, and what the parent built to the
|
|
103
|
+
* parent. Every instance is released, even when another fails: the errors come together, after. What it released, it builds again if
|
|
104
|
+
* something needs it.
|
|
105
|
+
*/
|
|
106
|
+
async dispose(): Promise<void> {
|
|
107
|
+
const instances = [...this.built.values()].reverse();
|
|
108
|
+
this.built.clear();
|
|
109
|
+
const errors: unknown[] = [];
|
|
110
|
+
for (const instance of instances) {
|
|
111
|
+
try {
|
|
112
|
+
await release(instance);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
errors.push(error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (errors.length > 0) throw new AggregateError(errors, `${errors.length} instance${errors.length === 1 ? "" : "s"} failed to release`);
|
|
118
|
+
}
|
|
119
|
+
|
|
93
120
|
private resolve(key: Key, path: readonly Key[], make: boolean): unknown {
|
|
94
121
|
const binding = this.bindings.get(key);
|
|
95
122
|
if (!binding && this.parent?.has(key)) return this.parent.resolve(key, path, make);
|
package/src/di/RequestContext.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Token } from "../Token.js";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* What the backend knows of the request that a handler or a controller serves: its correlation id, and its signal, which aborts when its
|
|
5
|
+
* client leaves. A handler gives the signal to the ports whose work the client waits for: a call to a model, to a service, to a database.
|
|
6
|
+
* A request without a client sees the backend stop: the signal of a turn of a timer aborts as soon as the server stops, that of a reaction
|
|
7
|
+
* to an event once the grace of the server ends. A DTO that the backend sends without a signal, as a test does, gets one that does not
|
|
8
|
+
* abort.
|
|
9
|
+
*/
|
|
10
|
+
export type RequestContext = { readonly correlationId: string; readonly signal: AbortSignal };
|
|
5
11
|
|
|
6
12
|
export const RequestContext = new Token<RequestContext>();
|
package/src/di/backend.ts
CHANGED
|
@@ -8,6 +8,18 @@ export function startBackend(options: BackendOptions = {}): Backend {
|
|
|
8
8
|
return current = createBackend(discovered, options);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Stops the backend that serves the requests, if one started, then forgets it: its feeds, its timers, the turns that run, the requests
|
|
13
|
+
* that the server still serves (the promise that it gives), the reactions, until the grace ends (the signal), then what its containers
|
|
14
|
+
* built. The next use starts a new one. A request that comes
|
|
15
|
+
* while it stops gets it: a new backend would outlive the server, since nothing would stop it.
|
|
16
|
+
*/
|
|
17
|
+
export async function stopBackend(requestsDone?: Promise<unknown>, graceOver?: AbortSignal): Promise<void> {
|
|
18
|
+
const stopping = current;
|
|
19
|
+
await stopping?.stop(requestsDone, graceOver);
|
|
20
|
+
if (current === stopping) current = undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
/** The backend that serves the requests, started with the production bindings on first use. */
|
|
12
24
|
export function backend(): Backend {
|
|
13
25
|
return current ?? startBackend();
|
package/src/di/createBackend.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
2
|
import type { Token } from "../Token.js";
|
|
3
3
|
import { BackendEventBus, type Published } from "../messaging/BackendEventBus.js";
|
|
4
|
+
import { ContextEvents } from "../messaging/ContextEvents.js";
|
|
4
5
|
import { ContextMap, type Envelope, type Translation } from "../messaging/ContextMap.js";
|
|
5
6
|
import { EventPublisher } from "../messaging/EventPublisher.js";
|
|
6
7
|
import { InProcessMessageBus, UnknownInputError } from "../messaging/InProcessMessageBus.js";
|
|
8
|
+
import type { EventSubscriber } from "../messaging/EventSubscriber.js";
|
|
7
9
|
import { MessageBus } from "../messaging/MessageBus.js";
|
|
8
10
|
import { ReportError, reportToConsole } from "../messaging/ReportError.js";
|
|
11
|
+
import { RequestEvents } from "../messaging/RequestEvents.js";
|
|
12
|
+
import { TimerController } from "../messaging/TimerController.js";
|
|
9
13
|
import { bind, Container, type Binding, type Concrete } from "./Container.js";
|
|
10
14
|
import type { BackendModules, Class, UseCase } from "./discover.js";
|
|
11
15
|
import { RequestContext } from "./RequestContext.js";
|
|
@@ -15,20 +19,69 @@ export type BackendOptions = {
|
|
|
15
19
|
replace?: Partial<Record<string, readonly Binding[]>>;
|
|
16
20
|
reportError?: ReportError;
|
|
17
21
|
nextEventId?: () => string;
|
|
22
|
+
/** The clock of the timer controllers: setInterval when not given. */
|
|
23
|
+
schedule?: Schedule;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
|
-
/**
|
|
21
|
-
type
|
|
26
|
+
/** Runs a turn every `every` milliseconds, until the function that it gives is called. */
|
|
27
|
+
export type Schedule = (every: number, turn: () => void) => () => void;
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
/** The longest delay of setInterval, about 24 days: Node runs a timer with a longer one every millisecond. */
|
|
30
|
+
const MAX_EVERY = 2 ** 31 - 1;
|
|
31
|
+
|
|
32
|
+
/** How long the turns and the reactions that still run get, once the grace ends and their signal aborted, in milliseconds. */
|
|
33
|
+
const LAST_WAIT_MS = 1000;
|
|
34
|
+
|
|
35
|
+
/** Waits until the work ends, or the signal aborts. */
|
|
36
|
+
const untilEndOrAbort = (work: Promise<unknown>, signal: AbortSignal) => new Promise<void>(resolve => {
|
|
37
|
+
signal.addEventListener("abort", () => { resolve(); });
|
|
38
|
+
if (signal.aborted) resolve();
|
|
39
|
+
void work.then(() => { resolve(); });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
const everyInterval: Schedule = (every, turn) => {
|
|
44
|
+
const timer = setInterval(turn, every);
|
|
45
|
+
return () => { clearInterval(timer); };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isTimer = (type: Class): type is Concrete<TimerController<object>> => type.prototype instanceof TimerController;
|
|
49
|
+
|
|
50
|
+
type Listener = (event: DomainEvent) => void;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A context, its timer controllers, the events of shared that an event of its handlers gives, and who listens to the events of its
|
|
54
|
+
* handlers.
|
|
55
|
+
*/
|
|
56
|
+
type Context = {
|
|
57
|
+
readonly container: Container;
|
|
58
|
+
readonly useCases: readonly UseCase[];
|
|
59
|
+
readonly timers: readonly Concrete<TimerController<object>>[];
|
|
60
|
+
readonly shares: (event: DomainEvent) => DomainEvent[];
|
|
61
|
+
readonly listeners: Set<Listener>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** What subscribes to the events that come to these listeners. */
|
|
65
|
+
const subscriberOf = (listeners: Set<Listener>): EventSubscriber<DomainEvent> => ({
|
|
66
|
+
subscribe: listener => {
|
|
67
|
+
listeners.add(listener);
|
|
68
|
+
return () => { listeners.delete(listener); };
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Where a request of the backend comes from: its correlation id. */
|
|
73
|
+
type Correlation = Pick<RequestContext, "correlationId">;
|
|
74
|
+
|
|
75
|
+
const SYSTEM: Correlation = { correlationId: "system" };
|
|
24
76
|
|
|
25
77
|
/** The context whose events are those of shared: its handlers publish them on the shared bus. */
|
|
26
78
|
const SHARED = "shared";
|
|
27
79
|
|
|
28
80
|
/**
|
|
29
81
|
* Builds every context in a container of its own, child of the container of shared: the dependencies of a context are private to it,
|
|
30
|
-
* those of shared are built once for all. Each request gets a child of its context, with its correlation id, the publisher of
|
|
31
|
-
* and its message bus: its handlers and its controllers are built there.
|
|
82
|
+
* those of shared are built once for all. Each request gets a child of its context, with its correlation id, its signal, the publisher of
|
|
83
|
+
* its events and its message bus: its handlers and its controllers are built there. Its signal aborts when the signal that goes with its
|
|
84
|
+
* DTO aborts: a controller gives the signal of its request, so its handler sees its client leave. The backend event bus is the shared bus: the context map of a
|
|
32
85
|
* context shares there the events of shared that the events of its handlers give, and the context map of each context turns the events
|
|
33
86
|
* of shared that it hears into inputs of the context. The handlers of the context shared publish its events there.
|
|
34
87
|
*/
|
|
@@ -37,7 +90,12 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
37
90
|
const shared = new Container([bind(ReportError).toValue(reportError), ...modules.shared, ...(options.replace?.shared ?? [])]);
|
|
38
91
|
const contexts = new Map<string, Context>();
|
|
39
92
|
const owners = new Map<unknown, Context>();
|
|
40
|
-
const listeners = new Set<
|
|
93
|
+
const listeners = new Set<Listener>();
|
|
94
|
+
/** Aborts when the backend stops: the events of the contexts end. */
|
|
95
|
+
const stopping = new AbortController();
|
|
96
|
+
/** The requests without a client see the backend stop: the turns of the timers at once, the reactions to the events once the grace ends. */
|
|
97
|
+
const turnsEnd = new AbortController();
|
|
98
|
+
const reactionsEnd = new AbortController();
|
|
41
99
|
|
|
42
100
|
/** The context of an input DTO, or of a controller: the error of an input is that of the message bus, which names the fix. */
|
|
43
101
|
const ownerOf = (owned: Class, refusal: () => Error): Context => {
|
|
@@ -47,27 +105,61 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
47
105
|
};
|
|
48
106
|
const ownerOfInput = (input: object): Context => ownerOf(input.constructor as Class, () => new UnknownInputError(input.constructor.name));
|
|
49
107
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The container of a request, and its message bus: each input DTO gets a new handler. A request without a client follows `ends`: its
|
|
110
|
+
* signal aborts when the backend stops it.
|
|
111
|
+
*/
|
|
112
|
+
const requestOf = (context: Context, { correlationId }: Correlation, ends?: AbortSignal) => {
|
|
113
|
+
const { container, useCases } = context;
|
|
52
114
|
const bus = new InProcessMessageBus();
|
|
115
|
+
const left = new AbortController();
|
|
116
|
+
const request: RequestContext = { correlationId, signal: ends === undefined ? left.signal : AbortSignal.any([left.signal, ends]) };
|
|
117
|
+
/** The signal of the request follows the one that goes with a DTO: it aborts with it, or at once if it came aborted. */
|
|
118
|
+
const follow = (signal: AbortSignal | undefined) => {
|
|
119
|
+
if (signal?.aborted) left.abort(signal.reason);
|
|
120
|
+
else signal?.addEventListener("abort", () => { left.abort(signal.reason); });
|
|
121
|
+
};
|
|
122
|
+
// Who listens to the events that the handlers of this request publish: a controller that streams them.
|
|
123
|
+
const heard = new Set<Listener>();
|
|
53
124
|
const scope = container.child([
|
|
54
125
|
bind(RequestContext).toValue(request),
|
|
55
|
-
bind(EventPublisher).toValue({ publish: (event: DomainEvent) => {
|
|
56
|
-
|
|
126
|
+
bind(EventPublisher).toValue({ publish: (event: DomainEvent) => {
|
|
127
|
+
for (const listener of [...heard]) listener(event);
|
|
128
|
+
tell(context, event, request);
|
|
129
|
+
} }),
|
|
130
|
+
...(container.has(MessageBus) ? [] : [bind(MessageBus).toValue(bus)]),
|
|
131
|
+
...(container.has(RequestEvents) ? [] : [bind(RequestEvents).toValue(subscriberOf(heard))])
|
|
57
132
|
]);
|
|
58
|
-
for (const { input, handler } of useCases)
|
|
133
|
+
for (const { input, handler } of useCases) {
|
|
134
|
+
bus.subscribe(input as never, (received, { signal }) => {
|
|
135
|
+
follow(signal);
|
|
136
|
+
return scope.create(handler as Concrete<{ execute(input: unknown): unknown }>).execute(received);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
59
139
|
return { scope, bus };
|
|
60
140
|
};
|
|
61
141
|
|
|
62
|
-
|
|
63
|
-
|
|
142
|
+
/** Sends an input DTO in a request of its own: with a correlation id, "system" by default, and the signal of its client, if it has one. */
|
|
143
|
+
const send = async (input: object, { correlationId = SYSTEM.correlationId, signal }: Partial<Correlation> & { readonly signal?: AbortSignal } = {}): Promise<unknown> =>
|
|
144
|
+
requestOf(ownerOfInput(input), { correlationId }).bus.send(input, { signal });
|
|
145
|
+
|
|
146
|
+
/** The reactions to the events that run, and the name of their input: the stop reports those that outlive it. */
|
|
147
|
+
const reactions = new Map<Promise<unknown>, string>();
|
|
148
|
+
/** Sends the input that an event gives, in a request of its own, which ends when the backend stops the reactions. */
|
|
149
|
+
const react = (input: object, correlationId: string): Promise<unknown> => {
|
|
150
|
+
const reaction = (async () => requestOf(ownerOfInput(input), { correlationId }, reactionsEnd.signal).bus.send(input))();
|
|
151
|
+
reactions.set(reaction, input.constructor.name);
|
|
152
|
+
const forget = () => { reactions.delete(reaction); };
|
|
153
|
+
void reaction.then(forget, forget);
|
|
154
|
+
return reaction;
|
|
155
|
+
};
|
|
64
156
|
|
|
65
157
|
/**
|
|
66
158
|
* An event that a handler publishes, in its context: the events of shared that the lines of its map that share give go to the shared
|
|
67
159
|
* bus, in the request of the handler. A line that fails is reported: the handler never waits for the other contexts, nor fails for them.
|
|
68
160
|
*/
|
|
69
|
-
const tell = (shares: Context
|
|
70
|
-
for (const listener of [...listeners]) listener(event);
|
|
161
|
+
const tell = ({ shares, listeners: contextListeners }: Context, event: DomainEvent, request: Correlation) => {
|
|
162
|
+
for (const listener of [...listeners, ...contextListeners]) listener(event);
|
|
71
163
|
let told: DomainEvent[];
|
|
72
164
|
try {
|
|
73
165
|
told = shares(event);
|
|
@@ -91,7 +183,7 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
91
183
|
}]));
|
|
92
184
|
const eventBus = new BackendEventBus({
|
|
93
185
|
map: new ContextMap(lines),
|
|
94
|
-
send: (input, envelope) =>
|
|
186
|
+
send: (input, envelope) => react(input, envelope.correlationId),
|
|
95
187
|
nextEventId: options.nextEventId ?? (() => crypto.randomUUID()),
|
|
96
188
|
reportError
|
|
97
189
|
});
|
|
@@ -99,7 +191,8 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
99
191
|
for (const [name, { bindings, useCases, controllers, map }] of Object.entries(modules.contexts)) {
|
|
100
192
|
const contextMap = new ContextMap(map);
|
|
101
193
|
const shares = name === SHARED ? (event: DomainEvent) => [event] : (event: DomainEvent) => contextMap.eventsFor(event);
|
|
102
|
-
const context = { container: shared.child([...bindings, ...(options.replace?.[name] ?? [])]), useCases, shares };
|
|
194
|
+
const context = { container: shared.child([...bindings, ...(options.replace?.[name] ?? [])]), useCases, timers: controllers.filter(isTimer), shares, listeners: new Set<Listener>() };
|
|
195
|
+
if (!context.container.has(ContextEvents)) context.container.use(ContextEvents, { ...subscriberOf(context.listeners), signal: stopping.signal });
|
|
103
196
|
for (const { input } of useCases) owners.set(input, context);
|
|
104
197
|
for (const controller of controllers) owners.set(controller, context);
|
|
105
198
|
contexts.set(name, context);
|
|
@@ -115,15 +208,92 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
115
208
|
return found;
|
|
116
209
|
};
|
|
117
210
|
|
|
211
|
+
const schedule = options.schedule ?? everyInterval;
|
|
212
|
+
const cancels: (() => void)[] = [];
|
|
213
|
+
/** The turns that run, and the name of their timer controller. */
|
|
214
|
+
const turns = new Map<Promise<void>, string>();
|
|
215
|
+
|
|
216
|
+
/** The rhythm of a timer controller of a context, in milliseconds: one that Node cannot keep is refused. */
|
|
217
|
+
const rhythmOf = (context: Context, timer: Concrete<TimerController<object>>) => {
|
|
218
|
+
const { every } = requestOf(context, SYSTEM).scope.create(timer);
|
|
219
|
+
if (!(every > 0 && every <= MAX_EVERY)) throw new Error(`${timer.name} runs every ${every} ms, but its every is a number of milliseconds above 0, up to ${MAX_EVERY} (24 days): Node runs a longer one every millisecond`);
|
|
220
|
+
return every;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Runs a timer controller of a context every `every` milliseconds, each turn in a request of its own, and never two turns at once. */
|
|
224
|
+
const run = (context: Context, timer: Concrete<TimerController<object>>, every: number) => {
|
|
225
|
+
let running = false;
|
|
226
|
+
cancels.push(schedule(every, () => {
|
|
227
|
+
if (running) return;
|
|
228
|
+
running = true;
|
|
229
|
+
const turn = (async () => {
|
|
230
|
+
try {
|
|
231
|
+
await requestOf(context, { correlationId: crypto.randomUUID() }, turnsEnd.signal).scope.create(timer).tick();
|
|
232
|
+
} catch (error) {
|
|
233
|
+
reportError(error);
|
|
234
|
+
}
|
|
235
|
+
})().finally(() => {
|
|
236
|
+
running = false;
|
|
237
|
+
turns.delete(turn);
|
|
238
|
+
});
|
|
239
|
+
turns.set(turn, timer.name);
|
|
240
|
+
}));
|
|
241
|
+
};
|
|
242
|
+
|
|
118
243
|
return {
|
|
244
|
+
/**
|
|
245
|
+
* Starts the timer controllers of the contexts: the server calls it once it serves. A second call starts nothing more. Each rhythm is
|
|
246
|
+
* checked before any timer runs: a timer that is refused leaves none running.
|
|
247
|
+
*/
|
|
248
|
+
start: () => {
|
|
249
|
+
if (cancels.length > 0) return;
|
|
250
|
+
const timers = [...contexts.values()].flatMap(context => context.timers.map(timer => ({ context, timer, every: rhythmOf(context, timer) })));
|
|
251
|
+
for (const { context, timer, every } of timers) run(context, timer, every);
|
|
252
|
+
},
|
|
253
|
+
/**
|
|
254
|
+
* Stops what start started, and releases what the containers built: the server calls it when it stops. It ends the events of the
|
|
255
|
+
* contexts first: a feed has no end, and would hold the requests that the server waits for. Then it cancels the timers, and aborts
|
|
256
|
+
* the signal of the turns that run: a work in the background stops with the server. It waits for the turns, for the requests that the
|
|
257
|
+
* server still serves (`requestsDone`), and for the reactions to the events, until the grace ends (`graceOver`, no limit without it).
|
|
258
|
+
* Then the signal of the reactions aborts, and the turns and the reactions that still run get one more second, after which the backend
|
|
259
|
+
* reports them. Last, it releases what each context built, then what shared built. A failure to release is reported.
|
|
260
|
+
*/
|
|
261
|
+
stop: async (requestsDone?: Promise<unknown>, graceOver?: AbortSignal) => {
|
|
262
|
+
stopping.abort();
|
|
263
|
+
for (const cancel of cancels.splice(0)) cancel();
|
|
264
|
+
turnsEnd.abort();
|
|
265
|
+
const done = (async () => {
|
|
266
|
+
while (turns.size > 0) await Promise.all([...turns.keys()]);
|
|
267
|
+
await requestsDone;
|
|
268
|
+
await eventBus.settled();
|
|
269
|
+
})();
|
|
270
|
+
if (graceOver === undefined) await done;
|
|
271
|
+
else {
|
|
272
|
+
await untilEndOrAbort(done, graceOver);
|
|
273
|
+
reactionsEnd.abort();
|
|
274
|
+
const late = new AbortController();
|
|
275
|
+
const timer = setTimeout(() => { late.abort(); }, LAST_WAIT_MS);
|
|
276
|
+
await untilEndOrAbort(done, late.signal);
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
const running = [...[...turns.values()].map(name => `a turn of ${name}`), ...[...reactions.values()].map(name => `a reaction with ${name}`)];
|
|
279
|
+
if (running.length > 0) reportError(new Error(`The backend stopped before these ended: ${running.join(", ")}`));
|
|
280
|
+
}
|
|
281
|
+
for (const container of [...[...contexts.values()].map(({ container }) => container), shared]) {
|
|
282
|
+
try {
|
|
283
|
+
await container.dispose();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
reportError(error);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
},
|
|
119
289
|
/** Sends an input DTO to the context that handles it, in a request of its own. */
|
|
120
290
|
send,
|
|
121
291
|
/** The controller of a context, built for a request. */
|
|
122
|
-
controller: <C>(controller: abstract new (...args: never[]) => C, request:
|
|
292
|
+
controller: <C>(controller: abstract new (...args: never[]) => C, request: Correlation = SYSTEM): C =>
|
|
123
293
|
requestOf(ownerOf(controller, () => new Error(`No context has the controller ${controller.name}: write it in the folder of its use case, as <use-case>.<name>.controller.ts`)), request)
|
|
124
294
|
.scope.create(controller as unknown as Concrete<C>),
|
|
125
295
|
/** Publishes an event of shared on the shared bus. */
|
|
126
|
-
publish: (event: DomainEvent, request:
|
|
296
|
+
publish: (event: DomainEvent, request: Correlation = SYSTEM) => { eventBus.publish(event, request); },
|
|
127
297
|
redeliver: (published: Published) => { eventBus.redeliver(published.event, published.envelope); },
|
|
128
298
|
/** Watches every event on the shared bus, in its envelope. */
|
|
129
299
|
subscribe: (listener: (published: Published) => void) => eventBus.subscribe(listener),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
|
+
import { Token } from "../Token.js";
|
|
3
|
+
import type { EventSubscriber } from "./EventSubscriber.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The events that the handlers of a context publish, in every request: a controller that streams a feed writes them. They end when the
|
|
7
|
+
* backend stops: then `signal` aborts, and a feed ends with them.
|
|
8
|
+
*/
|
|
9
|
+
export type ContextEvents = EventSubscriber<DomainEvent> & { readonly signal: AbortSignal };
|
|
10
|
+
|
|
11
|
+
export const ContextEvents = new Token<ContextEvents>();
|
|
@@ -1,61 +1,9 @@
|
|
|
1
1
|
import type { MessageBus } from "./MessageBus.js";
|
|
2
2
|
import { reportToConsole, type ReportError } from "./ReportError.js";
|
|
3
|
+
import { BodyTooLargeError, CrossSiteRequestError, InvalidInputError, MalformedBodyError, Request, UnsupportedMediaTypeError } from "./Request.js";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
super(message);
|
|
7
|
-
this.name = "InvalidInputError";
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export class MalformedBodyError extends InvalidInputError {}
|
|
12
|
-
|
|
13
|
-
export class UnsupportedMediaTypeError extends InvalidInputError {}
|
|
14
|
-
|
|
15
|
-
export class BodyTooLargeError extends InvalidInputError {}
|
|
16
|
-
|
|
17
|
-
/** The largest body that readBody reads by default, in bytes: 1 MB. */
|
|
18
|
-
export const MAX_BODY_SIZE = 1024 * 1024;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* A JSON media type, in any case: application/json, or a type with the suffix +json (application/merge-patch+json). Parameters can
|
|
22
|
-
* follow, after a ";". A parameter is not the type: a browser sends `text/plain;x+json` from any site, as text/plain.
|
|
23
|
-
*/
|
|
24
|
-
const JSON_MEDIA_TYPE = /^(?:application\/json|[^\s/]+\/[^\s/;]+\+json)\s*(?:;|$)/i;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The JSON of the body. The request must say that its body is JSON: content-type application/json, or a type with the suffix +json.
|
|
28
|
-
* A browser sends a body of another type (text/plain, a form) from any site, without asking the server first: such a request cannot
|
|
29
|
-
* change the data of the app. The body must not be larger than `maxSize` bytes (MAX_BODY_SIZE, 1 MB, by default): the rest of a
|
|
30
|
-
* larger body is not read. The request stays readable.
|
|
31
|
-
* @throws {UnsupportedMediaTypeError} when the request does not say that its body is JSON: the Controller answers 415.
|
|
32
|
-
* @throws {BodyTooLargeError} when the body is larger than `maxSize`: the Controller answers 413.
|
|
33
|
-
* @throws {MalformedBodyError} when the body is not JSON: the Controller answers 400.
|
|
34
|
-
*/
|
|
35
|
-
export async function readBody(request: Request, maxSize = MAX_BODY_SIZE): Promise<unknown> {
|
|
36
|
-
const contentType = request.headers.get("content-type");
|
|
37
|
-
if (!contentType?.match(JSON_MEDIA_TYPE)) throw new UnsupportedMediaTypeError("The content type must be application/json");
|
|
38
|
-
const text = await textOf(request.clone(), maxSize);
|
|
39
|
-
try {
|
|
40
|
-
return JSON.parse(text);
|
|
41
|
-
} catch {
|
|
42
|
-
throw new MalformedBodyError("Invalid JSON");
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The text of the body, read chunk by chunk: the reading stops at the first chunk after `maxSize` bytes. */
|
|
47
|
-
async function textOf(request: Request, maxSize: number): Promise<string> {
|
|
48
|
-
const decoder = new TextDecoder();
|
|
49
|
-
let text = "";
|
|
50
|
-
let size = 0;
|
|
51
|
-
const reader = request.body?.getReader();
|
|
52
|
-
for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
|
|
53
|
-
size += chunk.value.byteLength;
|
|
54
|
-
if (size > maxSize) throw new BodyTooLargeError(`The body is larger than ${maxSize} bytes`);
|
|
55
|
-
text += decoder.decode(chunk.value, { stream: true });
|
|
56
|
-
}
|
|
57
|
-
return text + decoder.decode();
|
|
58
|
-
}
|
|
5
|
+
// The errors of a request that a controller reads, which its toErrorResponse answers.
|
|
6
|
+
export { BodyTooLargeError, CrossSiteRequestError, InvalidInputError, MalformedBodyError, MAX_BODY_SIZE, UnsupportedMediaTypeError } from "./Request.js";
|
|
59
7
|
|
|
60
8
|
const problemType = (slug: string) => `urn:caits:problem:${slug}`;
|
|
61
9
|
|
|
@@ -66,43 +14,57 @@ export function problem(status: number, slug: string, title: string, detail?: st
|
|
|
66
14
|
});
|
|
67
15
|
}
|
|
68
16
|
|
|
69
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The response to an error of the request or of the application. A body that is neither JSON nor a form is a 415, a
|
|
19
|
+
* body too large a 413, a form that a browser sends from another site a 403, a body that is not JSON, or not the form that it says, a
|
|
20
|
+
* 400, a body that toDto refuses a 422 with its issues, and any other error is an internal server error, which is reported and tells
|
|
21
|
+
* nothing.
|
|
22
|
+
*/
|
|
23
|
+
export function errorResponseOf(error: unknown, reportError: ReportError): Response {
|
|
24
|
+
if (error instanceof UnsupportedMediaTypeError) return problem(415, "unsupported-media-type", "Unsupported media type", error.message);
|
|
25
|
+
if (error instanceof BodyTooLargeError) return problem(413, "request-body-too-large", "Request body too large", error.message);
|
|
26
|
+
if (error instanceof CrossSiteRequestError) return problem(403, "cross-site-request", "Cross-site request", error.message);
|
|
27
|
+
if (error instanceof MalformedBodyError) return problem(400, "invalid-request-body", "Invalid request body", error.message);
|
|
28
|
+
if (error instanceof InvalidInputError) return problem(422, "invalid-request-body", "Invalid request body", error.message, { issues: error.details });
|
|
29
|
+
reportError(error);
|
|
30
|
+
return problem(500, "internal-server-error", "Internal server error");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Turns a request into an input DTO, sends it to the message bus of the request, and turns the output or the error into a response. It
|
|
35
|
+
* reads the request through the Request of CAITS, which gives the content of the body at each call, and gives the bus the signal of the
|
|
36
|
+
* request with the DTO: its handler sees its client leave.
|
|
37
|
+
*/
|
|
70
38
|
export abstract class Controller<Dto extends object, Output> {
|
|
71
|
-
constructor(private bus: MessageBus,
|
|
39
|
+
constructor(private bus: MessageBus, protected readonly reportError: ReportError = reportToConsole) {}
|
|
72
40
|
|
|
73
41
|
protected abstract toDto(request: Request): Dto | Promise<Dto>;
|
|
74
42
|
|
|
75
43
|
protected abstract toResponse(output: Output, request: Request): Response | Promise<Response>;
|
|
76
44
|
|
|
77
45
|
/**
|
|
78
|
-
* The response to an error of the request or of the application. A body that
|
|
79
|
-
*
|
|
80
|
-
*
|
|
46
|
+
* The response to an error of the request or of the application. A body that is neither JSON nor a form is a 415, a
|
|
47
|
+
* body too large a 413, a form that a browser sends from another site a 403, a body that is not JSON, or not the form that it says, a
|
|
48
|
+
* 400, a body that toDto refuses a 422 with its issues, and any other error is an internal server error. A controller answers its own
|
|
49
|
+
* errors first, then calls super.
|
|
81
50
|
*/
|
|
82
51
|
protected toErrorResponse(error: unknown, _request: Request): Response | Promise<Response> {
|
|
83
|
-
|
|
84
|
-
if (error instanceof BodyTooLargeError) return problem(413, "request-body-too-large", "Request body too large", error.message);
|
|
85
|
-
if (error instanceof MalformedBodyError) return problem(400, "invalid-request-body", "Invalid request body", error.message);
|
|
86
|
-
if (error instanceof InvalidInputError) return problem(422, "invalid-request-body", "Invalid request body", error.message, { issues: error.details });
|
|
87
|
-
return this.internalServerError(error);
|
|
52
|
+
return errorResponseOf(error, this.reportError);
|
|
88
53
|
}
|
|
89
54
|
|
|
90
|
-
|
|
55
|
+
/** Answers a request of the web: toDto, toResponse and toErrorResponse read it through the Request of CAITS. */
|
|
56
|
+
async handle(received: globalThis.Request): Promise<Response> {
|
|
57
|
+
const request = new Request(received);
|
|
91
58
|
try {
|
|
92
59
|
let output: Output;
|
|
93
60
|
try {
|
|
94
|
-
output = await this.bus.send(await this.toDto(request)) as Output;
|
|
61
|
+
output = await this.bus.send(await this.toDto(request), { signal: request.signal }) as Output;
|
|
95
62
|
} catch (error) {
|
|
96
63
|
return await this.toErrorResponse(error, request);
|
|
97
64
|
}
|
|
98
65
|
return await this.toResponse(output, request);
|
|
99
66
|
} catch (failure) {
|
|
100
|
-
return this.
|
|
67
|
+
return errorResponseOf(failure, this.reportError);
|
|
101
68
|
}
|
|
102
69
|
}
|
|
103
|
-
|
|
104
|
-
private internalServerError(error: unknown): Response {
|
|
105
|
-
this.reportError(error);
|
|
106
|
-
return problem(500, "internal-server-error", "Internal server error");
|
|
107
|
-
}
|
|
108
70
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Subscribes to the events of a
|
|
1
|
+
/** Subscribes to the events of a context: in the frontend, the store gets it; in the backend, the controllers that stream. It cannot publish. */
|
|
2
2
|
export interface EventSubscriber<E> {
|
|
3
3
|
subscribe(listener: (event: E) => void): () => void;
|
|
4
4
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MessageBus } from "./MessageBus.js";
|
|
1
|
+
import type { MessageBus, Sending } from "./MessageBus.js";
|
|
2
2
|
|
|
3
3
|
type InputClass<I> = abstract new (...args: never[]) => I;
|
|
4
4
|
|
|
@@ -18,16 +18,17 @@ export class DuplicateHandlerError extends Error {
|
|
|
18
18
|
|
|
19
19
|
/** The message bus of one context, in the process: each handler subscribes to the class of the input DTO it handles. */
|
|
20
20
|
export class InProcessMessageBus implements MessageBus {
|
|
21
|
-
private handlers = new Map<unknown, (input: never) => unknown>();
|
|
21
|
+
private handlers = new Map<unknown, (input: never, sending: Sending) => unknown>();
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/** The handler gets the input, and what goes with it: the signal of its request. */
|
|
24
|
+
subscribe<I extends object>(input: InputClass<I>, handler: (input: I, sending: Sending) => unknown): void {
|
|
24
25
|
if (this.handlers.has(input)) throw new DuplicateHandlerError(input.name);
|
|
25
26
|
this.handlers.set(input, handler);
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
async send(input: object): Promise<unknown> {
|
|
29
|
+
async send(input: object, sending: Sending = {}): Promise<unknown> {
|
|
29
30
|
const handler = this.handlers.get(input.constructor);
|
|
30
31
|
if (!handler) throw new UnknownInputError(input.constructor.name);
|
|
31
|
-
return await handler(input as never);
|
|
32
|
+
return await handler(input as never, sending);
|
|
32
33
|
}
|
|
33
34
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Token } from "../Token.js";
|
|
2
2
|
|
|
3
|
+
/** What goes with an input DTO: the signal of its request, which aborts when its client leaves. Its handler reads it in its RequestContext. */
|
|
4
|
+
export type Sending = { readonly signal?: AbortSignal };
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Where a controller or a store sends an input DTO: the message bus gives it to the handler of its class, and gives back the output of
|
|
5
8
|
* the handler.
|
|
6
9
|
*/
|
|
7
10
|
export interface MessageBus {
|
|
8
|
-
send(input: object): Promise<unknown>;
|
|
11
|
+
send(input: object, sending?: Sending): Promise<unknown>;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
export const MessageBus = new Token<MessageBus>();
|