@ingenioz-it/caits 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/caits.js +20 -7
- package/bin/init.js +16 -1
- package/package.json +8 -5
- package/quality/architecture/adapters/scss.mjs +2 -2
- package/quality/architecture/facts.mjs +39 -11
- package/quality/architecture/project.mjs +8 -6
- package/quality/dev.mjs +6 -3
- package/quality/mutation-summary.mjs +30 -20
- package/quality/mutation.mjs +170 -14
- package/src/adapter.js +94 -0
- package/src/di/Container.ts +27 -0
- package/src/di/RequestContext.ts +8 -2
- package/src/di/backend.ts +12 -0
- package/src/di/createBackend.ts +197 -25
- package/src/di/declareDependencies.js +25 -5
- package/src/di/discover.ts +19 -9
- package/src/di/discoverFrontends.ts +4 -8
- package/src/di/discovered.ts +2 -2
- package/src/di/discoveredFrontends.ts +2 -2
- package/src/messaging/BackendEventBus.ts +2 -3
- package/src/messaging/ContextEvents.ts +11 -0
- package/src/messaging/ContextMap.ts +3 -4
- package/src/messaging/Controller.ts +36 -75
- package/src/messaging/EventSubscriber.ts +1 -1
- package/src/messaging/InProcessMessageBus.ts +8 -9
- package/src/messaging/MessageBus.ts +4 -2
- package/src/messaging/Request.ts +116 -0
- package/src/messaging/RequestEvents.ts +8 -0
- package/src/messaging/StreamController.ts +261 -0
- package/src/messaging/TimerController.ts +25 -0
- package/src/messaging/sse.ts +23 -0
- package/src/server/entry.ts +37 -0
- package/src/server/kernel.ts +19 -0
- package/src/server/packages.d.ts +12 -0
- package/src/server/serve.js +121 -0
- package/src/state/createStore.ts +1 -2
- package/src/testing/backend/application.ts +1 -1
- package/src/testing/backend/applicationScenario.ts +6 -7
- package/src/testing/backend/applications.ts +4 -1
- package/src/testing/backend/context-map.ts +1 -1
- package/src/testing/backend/integration.ts +1 -1
- package/src/testing/backend/presentationApplication.ts +23 -3
- package/src/testing/backend/presentationScenario.ts +84 -7
- package/src/testing/frontend/applicationScenario.ts +2 -3
- package/src/testing/frontend/applications.ts +3 -3
- package/src/testing/frontend/frontends.ts +3 -4
- package/src/testing/shared/FakeMessageBus.ts +39 -5
- package/src/virtual-modules.d.ts +0 -2
- package/src/vite.js +2 -2
- package/src/vite.types.d.ts +7 -0
- package/template/caits/architecture.mjs +162 -113
- package/template/caits/imports.mjs +2 -2
- package/template/caits/stryker.mjs +11 -6
- package/template/caits/vite.ts +15 -5
- package/template/project/AGENTS +1 -1
- package/quality/mutation-suite.mjs +0 -82
- package/src/messaging/Input.ts +0 -4
package/src/adapter.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The adapter of @marko/run for a CAITS app: quality/caits/vite.ts gives it to the plugin of @marko/run. JavaScript, not TypeScript: Node
|
|
2
|
+
// loads it with the configuration of Vite, and strips no types in node_modules, where a project installs the kernel.
|
|
3
|
+
// - `npm run build` builds the server of `npm start` from server/entry.ts: it boots the backend, and stops it on SIGTERM or SIGINT.
|
|
4
|
+
// - `caits dev` serves the app with the dev server of @marko/run, in a server of its own (server/serve.js): it boots the backend through
|
|
5
|
+
// Vite, boots it again when a module that the backend imports changes, and stops it when it closes.
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import baseAdapter, { createDevServer } from "@marko/run/adapter";
|
|
8
|
+
import { serve } from "./server/serve.js";
|
|
9
|
+
|
|
10
|
+
/** @import { Adapter } from "@marko/run/vite" */
|
|
11
|
+
/** @import { ModuleNode, ViteDevServer } from "vite" */
|
|
12
|
+
|
|
13
|
+
const ENTRY = fileURLToPath(new URL("./server/entry.ts", import.meta.url));
|
|
14
|
+
const KERNEL = fileURLToPath(new URL("./server/kernel.ts", import.meta.url));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The adapter of @marko/run that serves a CAITS app: that of Node, whose server boots the backend and stops it.
|
|
18
|
+
* @returns {Adapter}
|
|
19
|
+
*/
|
|
20
|
+
export function adapter() {
|
|
21
|
+
return {
|
|
22
|
+
...baseAdapter(),
|
|
23
|
+
name: "caits",
|
|
24
|
+
getEntryFile: () => ENTRY,
|
|
25
|
+
startDev: async ({ config, options }) => {
|
|
26
|
+
const vite = await createDevServer(config);
|
|
27
|
+
/** @type {Awaited<ReturnType<typeof serve>>} */
|
|
28
|
+
let served;
|
|
29
|
+
try {
|
|
30
|
+
served = await serve({
|
|
31
|
+
handle: vite.middlewares,
|
|
32
|
+
port: options.port ?? 3000,
|
|
33
|
+
boot: async () => /** @type {typeof import("./server/kernel.js")} */ (await vite.ssrLoadModule(KERNEL)).boot()
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// A first boot that fails, or a port in use: the server of Vite and its watcher would keep the command alive, and nothing would
|
|
37
|
+
// answer. The command ends, with the error.
|
|
38
|
+
await vite.close();
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
vite.watcher.on("change", file => {
|
|
42
|
+
if (!imports(vite, KERNEL, file)) return;
|
|
43
|
+
forget(vite, file);
|
|
44
|
+
void served.reboot();
|
|
45
|
+
});
|
|
46
|
+
// The words of the server of @marko/run: who waits for the server reads them.
|
|
47
|
+
console.log(`\n Server listening at\n http://localhost:${served.port}\n`);
|
|
48
|
+
return {
|
|
49
|
+
port: served.port,
|
|
50
|
+
close: async () => {
|
|
51
|
+
await served.close();
|
|
52
|
+
await vite.close();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a module that Vite loaded imports a file, itself or through the modules that it imports.
|
|
61
|
+
* @param {ViteDevServer} vite
|
|
62
|
+
* @param {string} id
|
|
63
|
+
* @param {string} file
|
|
64
|
+
*/
|
|
65
|
+
function imports(vite, id, file) {
|
|
66
|
+
/** @type {Set<ModuleNode>} */
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
/** @type {(module: ModuleNode | undefined) => boolean} */
|
|
69
|
+
const visit = module => {
|
|
70
|
+
if (!module || seen.has(module)) return false;
|
|
71
|
+
seen.add(module);
|
|
72
|
+
return module.file === file || [...module.importedModules].some(visit);
|
|
73
|
+
};
|
|
74
|
+
return visit(vite.moduleGraph.getModuleById(id));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Makes Vite forget a file that changed, and the modules that import it: the backend that boots again loads their new code. Vite would
|
|
79
|
+
* forget them itself, but after the watchers of the project see the change.
|
|
80
|
+
* @param {ViteDevServer} vite
|
|
81
|
+
* @param {string} file
|
|
82
|
+
*/
|
|
83
|
+
function forget(vite, file) {
|
|
84
|
+
/** @type {Set<ModuleNode>} */
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
/** @type {(module: ModuleNode) => void} */
|
|
87
|
+
const visit = module => {
|
|
88
|
+
if (seen.has(module)) return;
|
|
89
|
+
seen.add(module);
|
|
90
|
+
vite.moduleGraph.invalidateModule(module);
|
|
91
|
+
for (const importer of module.importers) visit(importer);
|
|
92
|
+
};
|
|
93
|
+
for (const module of vite.moduleGraph.getModulesByFile(file) ?? []) visit(module);
|
|
94
|
+
}
|
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,12 +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
|
-
import { InProcessMessageBus } from "../messaging/InProcessMessageBus.js";
|
|
7
|
-
import type {
|
|
7
|
+
import { InProcessMessageBus, UnknownInputError } from "../messaging/InProcessMessageBus.js";
|
|
8
|
+
import type { EventSubscriber } from "../messaging/EventSubscriber.js";
|
|
8
9
|
import { MessageBus } from "../messaging/MessageBus.js";
|
|
9
10
|
import { ReportError, reportToConsole } from "../messaging/ReportError.js";
|
|
11
|
+
import { RequestEvents } from "../messaging/RequestEvents.js";
|
|
12
|
+
import { TimerController } from "../messaging/TimerController.js";
|
|
10
13
|
import { bind, Container, type Binding, type Concrete } from "./Container.js";
|
|
11
14
|
import type { BackendModules, Class, UseCase } from "./discover.js";
|
|
12
15
|
import { RequestContext } from "./RequestContext.js";
|
|
@@ -16,20 +19,69 @@ export type BackendOptions = {
|
|
|
16
19
|
replace?: Partial<Record<string, readonly Binding[]>>;
|
|
17
20
|
reportError?: ReportError;
|
|
18
21
|
nextEventId?: () => string;
|
|
22
|
+
/** The clock of the timer controllers: setInterval when not given. */
|
|
23
|
+
schedule?: Schedule;
|
|
19
24
|
};
|
|
20
25
|
|
|
21
|
-
/**
|
|
22
|
-
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;
|
|
23
28
|
|
|
24
|
-
|
|
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" };
|
|
25
76
|
|
|
26
77
|
/** The context whose events are those of shared: its handlers publish them on the shared bus. */
|
|
27
78
|
const SHARED = "shared";
|
|
28
79
|
|
|
29
80
|
/**
|
|
30
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,
|
|
31
|
-
* those of shared are built once for all. Each request gets a child of its context, with its correlation id, the publisher of
|
|
32
|
-
* 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
|
|
33
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
|
|
34
86
|
* of shared that it hears into inputs of the context. The handlers of the context shared publish its events there.
|
|
35
87
|
*/
|
|
@@ -38,35 +90,76 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
38
90
|
const shared = new Container([bind(ReportError).toValue(reportError), ...modules.shared, ...(options.replace?.shared ?? [])]);
|
|
39
91
|
const contexts = new Map<string, Context>();
|
|
40
92
|
const owners = new Map<unknown, Context>();
|
|
41
|
-
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();
|
|
42
99
|
|
|
43
|
-
|
|
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. */
|
|
101
|
+
const ownerOf = (owned: Class, refusal: () => Error): Context => {
|
|
44
102
|
const context = owners.get(owned);
|
|
45
|
-
if (!context) throw
|
|
103
|
+
if (!context) throw refusal();
|
|
46
104
|
return context;
|
|
47
105
|
};
|
|
106
|
+
const ownerOfInput = (input: object): Context => ownerOf(input.constructor as Class, () => new UnknownInputError(input.constructor.name));
|
|
48
107
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
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;
|
|
51
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>();
|
|
52
124
|
const scope = container.child([
|
|
53
125
|
bind(RequestContext).toValue(request),
|
|
54
|
-
bind(EventPublisher).toValue({ publish: (event: DomainEvent) => {
|
|
55
|
-
|
|
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))])
|
|
56
132
|
]);
|
|
57
|
-
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
|
+
}
|
|
58
139
|
return { scope, bus };
|
|
59
140
|
};
|
|
60
141
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
};
|
|
63
156
|
|
|
64
157
|
/**
|
|
65
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
|
|
66
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.
|
|
67
160
|
*/
|
|
68
|
-
const tell = (shares: Context
|
|
69
|
-
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);
|
|
70
163
|
let told: DomainEvent[];
|
|
71
164
|
try {
|
|
72
165
|
told = shares(event);
|
|
@@ -83,14 +176,14 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
83
176
|
event: line.event,
|
|
84
177
|
inputs: (event: never, envelope: Envelope) => {
|
|
85
178
|
const inputs = line.inputs(event, envelope);
|
|
86
|
-
const foreign = inputs.find(input =>
|
|
179
|
+
const foreign = inputs.find(input => ownerOfInput(input) !== contexts.get(name));
|
|
87
180
|
if (foreign) throw new Error(`the context map of ${name} gives ${foreign.constructor.name}, an input of another context: a context map gives the inputs of its own context`);
|
|
88
181
|
return inputs;
|
|
89
182
|
}
|
|
90
183
|
}]));
|
|
91
184
|
const eventBus = new BackendEventBus({
|
|
92
185
|
map: new ContextMap(lines),
|
|
93
|
-
send: (input, envelope) =>
|
|
186
|
+
send: (input, envelope) => react(input, envelope.correlationId),
|
|
94
187
|
nextEventId: options.nextEventId ?? (() => crypto.randomUUID()),
|
|
95
188
|
reportError
|
|
96
189
|
});
|
|
@@ -98,7 +191,8 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
98
191
|
for (const [name, { bindings, useCases, controllers, map }] of Object.entries(modules.contexts)) {
|
|
99
192
|
const contextMap = new ContextMap(map);
|
|
100
193
|
const shares = name === SHARED ? (event: DomainEvent) => [event] : (event: DomainEvent) => contextMap.eventsFor(event);
|
|
101
|
-
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 });
|
|
102
196
|
for (const { input } of useCases) owners.set(input, context);
|
|
103
197
|
for (const controller of controllers) owners.set(controller, context);
|
|
104
198
|
contexts.set(name, context);
|
|
@@ -114,14 +208,92 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
|
|
|
114
208
|
return found;
|
|
115
209
|
};
|
|
116
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
|
+
|
|
117
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
|
+
},
|
|
118
289
|
/** Sends an input DTO to the context that handles it, in a request of its own. */
|
|
119
290
|
send,
|
|
120
291
|
/** The controller of a context, built for a request. */
|
|
121
|
-
controller: <C>(controller: abstract new (...args: never[]) => C, request:
|
|
122
|
-
requestOf(ownerOf(controller
|
|
292
|
+
controller: <C>(controller: abstract new (...args: never[]) => C, request: Correlation = SYSTEM): C =>
|
|
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)
|
|
294
|
+
.scope.create(controller as unknown as Concrete<C>),
|
|
123
295
|
/** Publishes an event of shared on the shared bus. */
|
|
124
|
-
publish: (event: DomainEvent, request:
|
|
296
|
+
publish: (event: DomainEvent, request: Correlation = SYSTEM) => { eventBus.publish(event, request); },
|
|
125
297
|
redeliver: (published: Published) => { eventBus.redeliver(published.event, published.envelope); },
|
|
126
298
|
/** Watches every event on the shared bus, in its envelope. */
|
|
127
299
|
subscribe: (listener: (published: Published) => void) => eventBus.subscribe(listener),
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* The code that a module needs after its own, for the containers:
|
|
12
|
+
* The code that a module needs after its own, for the containers and the message buses:
|
|
13
13
|
* - after each class that has a constructor, the dependencies of the constructor (DEPENDENCIES in Container.ts): the name of each
|
|
14
14
|
* parameter, if it is optional, and the token of its type when the type has a value of the same name, declared in the module or
|
|
15
15
|
* imported from a relative path, from a shortcut of the project (#…, which a package.json or a tsconfig.json maps to a folder) or from
|
|
16
16
|
* one of the packages given (a port and its token, or a class: the kernel gives EventPublisher, DataDirectory…). The token is read only
|
|
17
17
|
* when the container needs it, thus a cycle between modules does no harm. The name of the type goes with its token, for the errors;
|
|
18
|
+
* - after each class that has an execute method, the value of the type of its first parameter, found the same way (INPUT in
|
|
19
|
+
* discover.ts): the input DTO of a handler, which the message bus gives it;
|
|
18
20
|
* - the name of each token declared without one: the name of its constant.
|
|
19
21
|
* The names are text: the minifier can rename the code.
|
|
20
22
|
* @param {string} code
|
|
@@ -31,6 +33,8 @@ export function declareDependencies(code, filename, parse, packages) {
|
|
|
31
33
|
const values = new Set();
|
|
32
34
|
/** @type {{ name: string; parameters: readonly ESTree.ParamPattern[] }[]} */
|
|
33
35
|
const classes = [];
|
|
36
|
+
/** @type {{ name: string; input: ESTree.ParamPattern }[]} */
|
|
37
|
+
const executes = [];
|
|
34
38
|
/** @type {string[]} */
|
|
35
39
|
const tokens = [];
|
|
36
40
|
|
|
@@ -57,9 +61,12 @@ export function declareDependencies(code, filename, parse, packages) {
|
|
|
57
61
|
values.add(declaration.id.name);
|
|
58
62
|
const constructor = declaration.body.body.find(member => member.type === "MethodDefinition" && member.kind === "constructor");
|
|
59
63
|
if (constructor?.type === "MethodDefinition") classes.push({ name: declaration.id.name, parameters: constructor.value.params });
|
|
64
|
+
const execute = declaration.body.body.find(member => member.type === "MethodDefinition" && member.kind === "method" && !member.static && !member.computed
|
|
65
|
+
&& member.key.type === "Identifier" && member.key.name === "execute");
|
|
66
|
+
const input = execute?.type === "MethodDefinition" ? execute.value.params[0] : undefined;
|
|
67
|
+
if (input) executes.push({ name: declaration.id.name, input });
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
|
-
if (classes.length === 0 && tokens.length === 0) return undefined;
|
|
63
70
|
|
|
64
71
|
/** @type {Map<string, string>} */
|
|
65
72
|
const namespaces = new Map();
|
|
@@ -75,8 +82,7 @@ export function declareDependencies(code, filename, parse, packages) {
|
|
|
75
82
|
};
|
|
76
83
|
/** @type {(parameter: ESTree.ParamPattern, index: number) => string} */
|
|
77
84
|
const dependencyOf = (parameter, index) => {
|
|
78
|
-
const inner
|
|
79
|
-
const pattern = inner.type === "AssignmentPattern" ? inner.left : inner;
|
|
85
|
+
const { inner, pattern } = partsOf(parameter);
|
|
80
86
|
const name = pattern.type === "Identifier" ? pattern.name : `#${index}`;
|
|
81
87
|
const typed = tokenOf(pattern.typeAnnotation?.typeAnnotation);
|
|
82
88
|
const optional = inner.type === "AssignmentPattern" || pattern.optional === true;
|
|
@@ -84,9 +90,23 @@ export function declareDependencies(code, filename, parse, packages) {
|
|
|
84
90
|
};
|
|
85
91
|
|
|
86
92
|
const declarations = classes.map(({ name, parameters }) => `${name}[Symbol.for("kernel.dependencies")] = [${parameters.map(dependencyOf).join(", ")}];`);
|
|
93
|
+
const inputs = executes.flatMap(({ name, input }) => {
|
|
94
|
+
const typed = tokenOf(partsOf(input).pattern.typeAnnotation?.typeAnnotation);
|
|
95
|
+
return typed ? [`${name}[Symbol.for("kernel.input")] = () => ${typed.token};`] : [];
|
|
96
|
+
});
|
|
87
97
|
const imports = [...namespaces].map(([source, alias]) => `import * as ${alias} from ${JSON.stringify(source)};`);
|
|
88
98
|
const names = tokens.map(name => `${name}.name = ${JSON.stringify(name)};`);
|
|
89
|
-
|
|
99
|
+
const lines = [...imports, ...declarations, ...inputs, ...names];
|
|
100
|
+
return lines.length > 0 ? lines.join("\n") : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A parameter, without what TypeScript adds around it (`private readonly`), and its pattern, without its default value.
|
|
105
|
+
* @param {ESTree.ParamPattern} parameter
|
|
106
|
+
*/
|
|
107
|
+
function partsOf(parameter) {
|
|
108
|
+
const inner = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter;
|
|
109
|
+
return { inner, pattern: inner.type === "AssignmentPattern" ? inner.left : inner };
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
/** @type {(name: ESTree.ModuleExportName) => string} */
|
package/src/di/discover.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Translation } from "../messaging/ContextMap.js";
|
|
2
|
-
import { Input } from "../messaging/Input.js";
|
|
3
2
|
import type { Binding } from "./Container.js";
|
|
4
3
|
|
|
5
4
|
export type Class = abstract new (...args: never[]) => unknown;
|
|
@@ -21,6 +20,22 @@ export function single<T>(found: T[], what: string): T {
|
|
|
21
20
|
return found[0];
|
|
22
21
|
}
|
|
23
22
|
|
|
23
|
+
/** Where the plugin of the kernel writes the input DTO of a handler (see declareDependencies.js). */
|
|
24
|
+
export const INPUT = Symbol.for("kernel.input");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The use case of a handler: the handler, and the input DTO that its execute takes. The plugin of the kernel reads the class from the
|
|
28
|
+
* type of the parameter, as it reads the ports from the types of the constructor.
|
|
29
|
+
*/
|
|
30
|
+
export function useCaseOf(path: string, module: Record<string, unknown>): UseCase {
|
|
31
|
+
const handler = single(classesOf(module), `${path} declares one handler`);
|
|
32
|
+
const input = (handler as { [INPUT]?: () => unknown })[INPUT]?.();
|
|
33
|
+
if (typeof input !== "function") {
|
|
34
|
+
throw new Error(`${path}: ${handler.name}.execute names no input DTO. Give its parameter the type of the input DTO, the class that the use case receives.`);
|
|
35
|
+
}
|
|
36
|
+
return { input: input as Class, handler };
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
/**
|
|
25
40
|
* The lines of a context map, as its file gives them: the kernel refuses, when it starts, a map that is not a list of lines, rather than
|
|
26
41
|
* lose what it holds without a word. `isLine` tells a line of the side.
|
|
@@ -40,10 +55,10 @@ const isTranslation = ({ shares, event, inputs, events }: Partial<Record<string,
|
|
|
40
55
|
|
|
41
56
|
/**
|
|
42
57
|
* Turns the modules found in the backend folder (src/backend by default) into the contexts of the backend: a handler serves the input
|
|
43
|
-
* DTO
|
|
58
|
+
* DTO that its execute takes, and the context map of a context gives the events of shared that it hears and those of its own that it
|
|
44
59
|
* shares.
|
|
45
60
|
*/
|
|
46
|
-
export function discover(found: { bindings: Record<string, readonly Binding[]>;
|
|
61
|
+
export function discover(found: { bindings: Record<string, readonly Binding[]>; handlers: Modules; controllers: Modules; maps: Record<string, Translation[]> }): BackendModules {
|
|
47
62
|
const contexts: Record<string, ContextModules> = {};
|
|
48
63
|
const context = (path: string) => contexts[contextOf(path)] ??= { bindings: [], useCases: [], controllers: [], map: [] };
|
|
49
64
|
let shared: readonly Binding[] = [];
|
|
@@ -52,12 +67,7 @@ export function discover(found: { bindings: Record<string, readonly Binding[]>;
|
|
|
52
67
|
if (contextOf(path) === "shared") shared = bindings;
|
|
53
68
|
else context(path).bindings = bindings;
|
|
54
69
|
}
|
|
55
|
-
for (const [path, module] of Object.entries(found.handlers))
|
|
56
|
-
const dto = path.replace(".handler.ts", ".dto.ts");
|
|
57
|
-
const input = single(classesOf(found.dtos[dto] ?? {}).filter(type => type.prototype instanceof Input), `${dto} declares one input DTO`);
|
|
58
|
-
const handler = single(classesOf(module), `${path} declares one handler`);
|
|
59
|
-
context(path).useCases.push({ input, handler });
|
|
60
|
-
}
|
|
70
|
+
for (const [path, module] of Object.entries(found.handlers)) context(path).useCases.push(useCaseOf(path, module));
|
|
61
71
|
for (const [path, module] of Object.entries(found.controllers)) context(path).controllers.push(...classesOf(module));
|
|
62
72
|
for (const [path, map] of Object.entries(found.maps)) {
|
|
63
73
|
if (contextOf(path) === "shared") throw new Error(`the backend context shared hears no other context: it has no context map (${path})`);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { Input } from "../messaging/Input.js";
|
|
2
1
|
import type { Line } from "../messaging/FrontendContextMap.js";
|
|
3
2
|
import type { StoreDefinition } from "../state/createStore.js";
|
|
4
3
|
import type { Binding } from "./Container.js";
|
|
5
|
-
import {
|
|
4
|
+
import { linesIn, useCaseOf, type UseCase } from "./discover.js";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* A context of the frontend: its bindings, its use cases, its store (none for a context without a use case: its views and its
|
|
@@ -20,12 +19,11 @@ const isLine = ({ shares, event, events }: Partial<Record<string, unknown>>) =>
|
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* Turns the modules found in the frontend folder (src/frontend by default) into the contexts of the frontend: a context has a di.ts,
|
|
23
|
-
* a store at most, and its context map if it hears the context shared or shares events with it; a handler serves the input DTO
|
|
24
|
-
*
|
|
22
|
+
* a store at most, and its context map if it hears the context shared or shares events with it; a handler serves the input DTO that
|
|
23
|
+
* its execute takes.
|
|
25
24
|
*/
|
|
26
25
|
export function discoverFrontends(found: {
|
|
27
26
|
bindings: Record<string, readonly Binding[]>;
|
|
28
|
-
dtos: Modules;
|
|
29
27
|
handlers: Modules;
|
|
30
28
|
stores: Record<string, StoreDefinition<unknown, unknown>>;
|
|
31
29
|
maps: Record<string, readonly Line[]>;
|
|
@@ -46,9 +44,7 @@ export function discoverFrontends(found: {
|
|
|
46
44
|
for (const [path, module] of Object.entries(found.handlers)) {
|
|
47
45
|
const context = contexts[contextOf(path)];
|
|
48
46
|
if (!context) throw new Error(`${path} belongs to a frontend without a di.ts`);
|
|
49
|
-
|
|
50
|
-
const input = single(classesOf(found.dtos[dto] ?? {}).filter(type => type.prototype instanceof Input), `${dto} declares one input DTO`);
|
|
51
|
-
context.useCases.push({ input, handler: single(classesOf(module), `${path} declares one handler`) });
|
|
47
|
+
context.useCases.push(useCaseOf(path, module));
|
|
52
48
|
}
|
|
53
49
|
for (const [name, context] of Object.entries(contexts)) {
|
|
54
50
|
if (context.useCases.length > 0 && !context.store) {
|