@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.
Files changed (38) hide show
  1. package/bin/caits.js +20 -7
  2. package/bin/init.js +16 -1
  3. package/package.json +8 -5
  4. package/quality/architecture/adapters/scss.mjs +2 -2
  5. package/quality/architecture/facts.mjs +14 -6
  6. package/quality/dev.mjs +6 -3
  7. package/quality/mutation-summary.mjs +30 -20
  8. package/quality/mutation.mjs +170 -14
  9. package/src/adapter.js +94 -0
  10. package/src/di/Container.ts +27 -0
  11. package/src/di/RequestContext.ts +8 -2
  12. package/src/di/backend.ts +12 -0
  13. package/src/di/createBackend.ts +189 -19
  14. package/src/messaging/ContextEvents.ts +11 -0
  15. package/src/messaging/Controller.ts +35 -73
  16. package/src/messaging/EventSubscriber.ts +1 -1
  17. package/src/messaging/InProcessMessageBus.ts +6 -5
  18. package/src/messaging/MessageBus.ts +4 -1
  19. package/src/messaging/Request.ts +116 -0
  20. package/src/messaging/RequestEvents.ts +8 -0
  21. package/src/messaging/StreamController.ts +261 -0
  22. package/src/messaging/TimerController.ts +25 -0
  23. package/src/messaging/sse.ts +23 -0
  24. package/src/server/entry.ts +37 -0
  25. package/src/server/kernel.ts +19 -0
  26. package/src/server/packages.d.ts +12 -0
  27. package/src/server/serve.js +121 -0
  28. package/src/testing/backend/applications.ts +4 -1
  29. package/src/testing/backend/presentationApplication.ts +23 -3
  30. package/src/testing/backend/presentationScenario.ts +84 -7
  31. package/src/testing/shared/FakeMessageBus.ts +39 -5
  32. package/src/vite.js +2 -0
  33. package/src/vite.types.d.ts +7 -0
  34. package/template/caits/architecture.mjs +26 -7
  35. package/template/caits/imports.mjs +2 -2
  36. package/template/caits/stryker.mjs +11 -6
  37. package/template/caits/vite.ts +13 -5
  38. package/quality/mutation-suite.mjs +0 -82
@@ -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);
@@ -1,6 +1,12 @@
1
1
  import { Token } from "../Token.js";
2
2
 
3
- /** What the backend knows of the request that a handler or a controller serves. */
4
- export type RequestContext = { readonly correlationId: string };
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();
@@ -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
- /** A context, and the events of shared that an event of its handlers gives. */
21
- type Context = { readonly container: Container; readonly useCases: readonly UseCase[]; readonly shares: (event: DomainEvent) => DomainEvent[] };
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
- const SYSTEM: RequestContext = { correlationId: "system" };
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 its events
31
- * and its message bus: its handlers and its controllers are built there. The backend event bus is the shared bus: the context map of a
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<(event: DomainEvent) => void>();
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
- /** The container of a request, and its message bus: each input DTO gets a new handler. */
51
- const requestOf = ({ container, useCases, shares }: Context, request: RequestContext) => {
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) => { tell(shares, event, request); } }),
56
- ...(container.has(MessageBus) ? [] : [bind(MessageBus).toValue(bus)])
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) bus.subscribe(input as never, received => scope.create(handler as Concrete<{ execute(input: unknown): unknown }>).execute(received) as never);
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
- const send = async (input: object, request: RequestContext = SYSTEM): Promise<unknown> =>
63
- requestOf(ownerOfInput(input), request).bus.send(input);
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["shares"], event: DomainEvent, request: RequestContext) => {
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) => send(input, { correlationId: envelope.correlationId }),
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: RequestContext = SYSTEM): C =>
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: RequestContext = SYSTEM) => { eventBus.publish(event, 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
- export class InvalidInputError extends Error {
5
- constructor(message: string, readonly details?: unknown) {
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
- /** 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. */
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, private reportError: ReportError = reportToConsole) {}
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 does not say it is JSON is a 415, a body too large a 413,
79
- * a body that is not JSON a 400, a body that toDto refuses a 422 with its issues, and any other error is an internal server error. A
80
- * controller answers its own errors first, then calls super.
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
- if (error instanceof UnsupportedMediaTypeError) return problem(415, "unsupported-media-type", "Unsupported media type", error.message);
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
- async handle(request: Request): Promise<Response> {
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.internalServerError(failure);
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 frontend context: the store gets it. It cannot publish. */
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
- subscribe<I extends object>(input: InputClass<I>, handler: (input: I) => unknown): void {
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>();