@ingenioz-it/caits 0.0.0-stage → 0.5.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/LICENSE +5 -0
- package/README.md +36 -2
- package/bin/caits.js +184 -0
- package/bin/init.js +359 -0
- package/package.json +114 -4
- package/quality/architecture/adapters/marko.mjs +23 -0
- package/quality/architecture/adapters/scss.mjs +52 -0
- package/quality/architecture/adapters/typescript.mjs +13 -0
- package/quality/architecture/facts.mjs +159 -0
- package/quality/architecture/project.mjs +148 -0
- package/quality/architecture.mjs +116 -0
- package/quality/check.mjs +104 -0
- package/quality/dev.mjs +110 -0
- package/quality/e2e.mjs +125 -0
- package/quality/mutation-suite.mjs +31 -0
- package/quality/mutation-summary.mjs +41 -0
- package/quality/route-types.mjs +40 -0
- package/quality/test-docs/page.mjs +301 -0
- package/quality/test-docs/playwright-reporter.mjs +55 -0
- package/quality/test-docs/test-docs.mjs +180 -0
- package/quality/test-docs/vitest-reporter.mjs +38 -0
- package/src/DomainEvent.ts +4 -0
- package/src/Result.ts +6 -0
- package/src/Token.ts +8 -0
- package/src/di/Container.ts +123 -0
- package/src/di/DataDirectory.ts +6 -0
- package/src/di/Fetcher.ts +6 -0
- package/src/di/RequestContext.ts +6 -0
- package/src/di/backend.ts +19 -0
- package/src/di/createBackend.ts +106 -0
- package/src/di/createFrontend.ts +53 -0
- package/src/di/declareDependencies.js +93 -0
- package/src/di/discover.ts +39 -0
- package/src/di/discoverFrontends.ts +36 -0
- package/src/di/discovered.ts +6 -0
- package/src/di/discoveredFrontends.ts +6 -0
- package/src/di/frontend.ts +33 -0
- package/src/http/failureOf.ts +10 -0
- package/src/messaging/BackendEventBus.ts +60 -0
- package/src/messaging/ContextMap.ts +22 -0
- package/src/messaging/Controller.ts +109 -0
- package/src/messaging/EventPublisher.ts +12 -0
- package/src/messaging/EventSubscriber.ts +4 -0
- package/src/messaging/FrontendEventBus.ts +38 -0
- package/src/messaging/InProcessMessageBus.ts +35 -0
- package/src/messaging/Input.ts +4 -0
- package/src/messaging/MessageBus.ts +12 -0
- package/src/messaging/ReportError.ts +9 -0
- package/src/state/Loadable.ts +13 -0
- package/src/state/createStore.ts +45 -0
- package/src/state/follow.ts +7 -0
- package/src/state/request.ts +21 -0
- package/src/testing/backend/application.ts +12 -0
- package/src/testing/backend/applicationScenario.ts +60 -0
- package/src/testing/backend/applications.ts +62 -0
- package/src/testing/backend/context-map.ts +12 -0
- package/src/testing/backend/infra.ts +6 -0
- package/src/testing/backend/integration.ts +8 -0
- package/src/testing/backend/presentation.ts +14 -0
- package/src/testing/backend/presentationApplication.ts +25 -0
- package/src/testing/backend/presentationScenario.ts +47 -0
- package/src/testing/frontend/application.ts +12 -0
- package/src/testing/frontend/applicationScenario.ts +36 -0
- package/src/testing/frontend/applications.ts +70 -0
- package/src/testing/frontend/frontends.ts +28 -0
- package/src/testing/frontend/infra.ts +2 -0
- package/src/testing/frontend/marko.ts +12 -0
- package/src/testing/frontend/presentation.ts +7 -0
- package/src/testing/shared/FakeMessageBus.ts +58 -0
- package/src/testing/shared/tempDirectories.ts +15 -0
- package/src/virtual-modules.d.ts +33 -0
- package/src/vite.js +162 -0
- package/src/vite.types.d.ts +14 -0
- package/template/caits/architecture.mjs +467 -0
- package/template/caits/eslint.js +32 -0
- package/template/caits/marko-modules.d.ts +4 -0
- package/template/caits/playwright.ts +35 -0
- package/template/caits/stryker.mjs +20 -0
- package/template/caits/tsconfig.json +21 -0
- package/template/caits/tsconfig.mutation.json +5 -0
- package/template/caits/vite.ts +34 -0
- package/template/empty/src/routes/_frontend/+page.marko +9 -0
- package/template/empty/test/e2e/home.spec.ts +6 -0
- package/template/project/AGENTS +28 -0
- package/template/project/gitignore +16 -0
- package/template/project/nvmrc +1 -0
- package/template/project/quality/architecture.mjs +9 -0
- package/template/project/quality/eslint.config.js +6 -0
- package/template/project/quality/playwright.config.ts +10 -0
- package/template/project/quality/stryker.config.mjs +6 -0
- package/template/project/quality/vite.config.ts +5 -0
- package/template/project/tsconfig.json +4 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Token } from "../Token.js";
|
|
2
|
+
|
|
3
|
+
/** What the build writes next to each class: the parameters of its constructor, in order. */
|
|
4
|
+
export const DEPENDENCIES = Symbol.for("kernel.dependencies");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A parameter of a constructor: its name, and the token of its type when the type has one (a port and its token, or a class). The name
|
|
8
|
+
* of the type goes with its token: if the module of the type declares no token, the error names the type.
|
|
9
|
+
*/
|
|
10
|
+
export type Dependency = { readonly name: string; readonly type?: string; readonly token?: () => unknown; readonly optional?: true };
|
|
11
|
+
|
|
12
|
+
export type Concrete<T = unknown> = new (...args: never[]) => T;
|
|
13
|
+
export type Key<T = unknown> = Token<T> | (abstract new (...args: never[]) => T);
|
|
14
|
+
|
|
15
|
+
/** How the container gets what a key stands for: it builds a class, it gives a value, or it makes the value when something needs it. */
|
|
16
|
+
export type Binding =
|
|
17
|
+
| { readonly key: Key; readonly to: Concrete; readonly values: Readonly<Record<string, unknown>> }
|
|
18
|
+
| { readonly key: Key; readonly value: unknown }
|
|
19
|
+
| { readonly key: Key; readonly make: () => unknown };
|
|
20
|
+
|
|
21
|
+
type ClassBinding<T> = Binding & {
|
|
22
|
+
/** The values of some parameters of the class, by their names. */
|
|
23
|
+
with(values: Readonly<Record<string, unknown>>): ClassBinding<T>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const toClass = <T>(key: Key<T>, to: Concrete, values: Readonly<Record<string, unknown>>): ClassBinding<T> =>
|
|
27
|
+
({ key, to, values, with: more => toClass(key, to, { ...values, ...more }) });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Says what a key stands for: `bind(Port).to(Adapter)`, `bind(Token).toValue(value)`, `bind(Token).toFactory(() => value)`,
|
|
31
|
+
* `bind(SomeClass).with({ parameter: value })`.
|
|
32
|
+
*/
|
|
33
|
+
export function bind<T>(key: Key<T>) {
|
|
34
|
+
return {
|
|
35
|
+
to: <I extends T>(implementation: Concrete<I>): ClassBinding<T> => toClass(key, implementation, {}),
|
|
36
|
+
toValue: (value: T): Binding => ({ key, value }),
|
|
37
|
+
toFactory: (make: () => T): Binding => ({ key, make }),
|
|
38
|
+
with: (values: Readonly<Record<string, unknown>>): ClassBinding<T> => toClass(key, key as Concrete, values)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const describe = (route: readonly Key[]) => route.map(key => key.name).join(" → ");
|
|
43
|
+
const isKey = (value: unknown): value is Key => value instanceof Token || typeof value === "function";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds a class with its dependencies, and the dependencies of its dependencies. A key without a binding is built if it is a class.
|
|
47
|
+
* Each key gives one instance; `create` gives a new instance of a class each time. A child container (for a request) has bindings of
|
|
48
|
+
* its own, and asks its parent for the others: what the parent builds never depends on what only the child binds.
|
|
49
|
+
*/
|
|
50
|
+
export class Container {
|
|
51
|
+
private readonly bindings = new Map<Key, Binding>();
|
|
52
|
+
private readonly built = new Map<Key, unknown>();
|
|
53
|
+
|
|
54
|
+
constructor(bindings: readonly Binding[] = [], private readonly parent?: Container) {
|
|
55
|
+
for (const binding of bindings) this.bindings.set(binding.key, binding);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A container whose bindings come first, and which asks this container for the others. */
|
|
59
|
+
child(bindings: readonly Binding[]): Container {
|
|
60
|
+
return new Container(bindings, this);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Tells if a binding of this container, or of its parents, says what this key stands for. */
|
|
64
|
+
has(key: Key): boolean {
|
|
65
|
+
return this.bindings.has(key) || this.parent?.has(key) === true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The instance of a key: built the first time something needs it, then kept. */
|
|
69
|
+
get<T>(key: Key<T>): T {
|
|
70
|
+
return this.resolve(key, [], true) as T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A new instance of a class, with its dependencies. */
|
|
74
|
+
create<T>(type: Concrete<T>): T {
|
|
75
|
+
return this.build(type, {}, [type], true) as T;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Makes sure that the container can build a class: each dependency has a binding, a value or a default. It builds nothing. */
|
|
79
|
+
check(type: Concrete): void {
|
|
80
|
+
this.build(type, {}, [type], false);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** From now on, this value stands for the key. */
|
|
84
|
+
use<T, Given extends T>(key: Key<T>, value: Given): Given {
|
|
85
|
+
this.bindings.set(key, { key, value });
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private resolve(key: Key, path: readonly Key[], make: boolean): unknown {
|
|
90
|
+
const binding = this.bindings.get(key);
|
|
91
|
+
if (!binding && this.parent?.has(key)) return this.parent.resolve(key, path, make);
|
|
92
|
+
if (binding && "value" in binding) return binding.value;
|
|
93
|
+
if (this.built.has(key)) return this.built.get(key);
|
|
94
|
+
const route = [...path, key];
|
|
95
|
+
if (path.includes(key)) throw new Error(`Circular dependency: ${describe(route)}`);
|
|
96
|
+
if (!binding && key instanceof Token) throw new Error(`Nothing is bound to ${describe(route)}`);
|
|
97
|
+
const instance = !binding ? this.build(key as Concrete, {}, route, make)
|
|
98
|
+
: "make" in binding ? (make ? binding.make() : undefined)
|
|
99
|
+
: this.build(binding.to, binding.values, route, make);
|
|
100
|
+
if (make) this.built.set(key, instance);
|
|
101
|
+
return instance;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private build(type: Concrete, values: Readonly<Record<string, unknown>>, path: readonly Key[], make: boolean): unknown {
|
|
105
|
+
const dependencies = (type as unknown as { [DEPENDENCIES]?: readonly Dependency[] })[DEPENDENCIES];
|
|
106
|
+
if (!dependencies && type.length > 0) throw new Error(`${type.name} declares no dependencies: the build must run the dependencies plugin`);
|
|
107
|
+
for (const name of Object.keys(values)) {
|
|
108
|
+
if (!dependencies?.some(dependency => dependency.name === name)) throw new Error(`${type.name} has no parameter named ${name}`);
|
|
109
|
+
}
|
|
110
|
+
const args = (dependencies ?? []).map(({ name, type: typeName, token, optional }) => {
|
|
111
|
+
if (Object.hasOwn(values, name)) return values[name];
|
|
112
|
+
const key = token?.();
|
|
113
|
+
// An optional parameter keeps its default value when its type has no token, or when nothing is bound to its token.
|
|
114
|
+
if (optional && (!isKey(key) || (key instanceof Token && !this.has(key)))) return undefined;
|
|
115
|
+
if (isKey(key)) return this.resolve(key, path, make);
|
|
116
|
+
const bound = path[path.length - 1];
|
|
117
|
+
const binding = bound === type ? `bind(${type.name})` : `bind(${bound.name}).to(${type.name})`;
|
|
118
|
+
const noToken = typeName ? `its type ${typeName} has no token (export const ${typeName} = new Token<${typeName}>()), or ` : "";
|
|
119
|
+
throw new Error(`${describe(path)} needs a value for its parameter ${name}: ${noToken}give it with ${binding}.with({ ${name}: … })`);
|
|
120
|
+
});
|
|
121
|
+
return make ? new type(...(args as never[])) : undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Token } from "../Token.js";
|
|
2
|
+
|
|
3
|
+
/** How a frontend reaches the API: on the server, the fetch of the request; in the browser, the fetch of the browser. */
|
|
4
|
+
export type Fetcher = (input: string, init?: RequestInit) => Promise<Response>;
|
|
5
|
+
|
|
6
|
+
export const Fetcher = new Token<Fetcher>();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createBackend, type Backend, type BackendOptions } from "./createBackend.js";
|
|
2
|
+
import { discovered } from "./discovered.js";
|
|
3
|
+
|
|
4
|
+
let current: Backend | undefined;
|
|
5
|
+
|
|
6
|
+
/** Starts the backend that serves the requests, with the bindings given replacing those of the contexts. */
|
|
7
|
+
export function startBackend(options: BackendOptions = {}): Backend {
|
|
8
|
+
return current = createBackend(discovered, options);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The backend that serves the requests, started with the production bindings on first use. */
|
|
12
|
+
export function backend(): Backend {
|
|
13
|
+
return current ?? startBackend();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The controller of a context, built for a new request with a correlation id of its own. */
|
|
17
|
+
export function controller<C>(type: abstract new (...args: never[]) => C): C {
|
|
18
|
+
return backend().controller(type, { correlationId: crypto.randomUUID() });
|
|
19
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
|
+
import type { Token } from "../Token.js";
|
|
3
|
+
import { BackendEventBus, type Published } from "../messaging/BackendEventBus.js";
|
|
4
|
+
import { ContextMap } from "../messaging/ContextMap.js";
|
|
5
|
+
import { EventPublisher } from "../messaging/EventPublisher.js";
|
|
6
|
+
import { InProcessMessageBus } from "../messaging/InProcessMessageBus.js";
|
|
7
|
+
import type { Input } from "../messaging/Input.js";
|
|
8
|
+
import { MessageBus } from "../messaging/MessageBus.js";
|
|
9
|
+
import { ReportError, reportToConsole } from "../messaging/ReportError.js";
|
|
10
|
+
import { bind, Container, type Binding, type Concrete } from "./Container.js";
|
|
11
|
+
import type { BackendModules, Class, UseCase } from "./discover.js";
|
|
12
|
+
import { RequestContext } from "./RequestContext.js";
|
|
13
|
+
|
|
14
|
+
export type BackendOptions = {
|
|
15
|
+
/** Bindings that replace those of a context, or, under "shared", those of shared for every context. */
|
|
16
|
+
replace?: Partial<Record<string, readonly Binding[]>>;
|
|
17
|
+
reportError?: ReportError;
|
|
18
|
+
nextEventId?: () => string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type Context = { readonly container: Container; readonly useCases: readonly UseCase[] };
|
|
22
|
+
|
|
23
|
+
const SYSTEM: RequestContext = { correlationId: "system" };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds every context in a container of its own, child of the container of shared: the dependencies of a context are private to it,
|
|
27
|
+
* 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
|
|
28
|
+
* and its message bus: its handlers and its controllers are built there. The backend event bus carries the events between contexts.
|
|
29
|
+
*/
|
|
30
|
+
export function createBackend(modules: BackendModules, options: BackendOptions = {}) {
|
|
31
|
+
const reportError = options.reportError ?? reportToConsole;
|
|
32
|
+
const shared = new Container([bind(ReportError).toValue(reportError), ...modules.shared, ...(options.replace?.shared ?? [])]);
|
|
33
|
+
const contexts = new Map<string, Context>();
|
|
34
|
+
const owners = new Map<unknown, Context>();
|
|
35
|
+
|
|
36
|
+
const ownerOf = (owned: Class): Context => {
|
|
37
|
+
const context = owners.get(owned);
|
|
38
|
+
if (!context) throw new Error(`No context owns ${owned.name}`);
|
|
39
|
+
return context;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The container of a request, and its message bus: each input DTO gets a new handler. */
|
|
43
|
+
const requestOf = ({ container, useCases }: Context, request: RequestContext) => {
|
|
44
|
+
const bus = new InProcessMessageBus();
|
|
45
|
+
const scope = container.child([
|
|
46
|
+
bind(RequestContext).toValue(request),
|
|
47
|
+
bind(EventPublisher).toValue({ publish: (event: DomainEvent) => { eventBus.publish(event, request); } }),
|
|
48
|
+
...(container.has(MessageBus) ? [] : [bind(MessageBus).toValue(bus)])
|
|
49
|
+
]);
|
|
50
|
+
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);
|
|
51
|
+
return { scope, bus };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const send = async <Output>(input: Input<Output>, request: RequestContext = SYSTEM): Promise<Output> =>
|
|
55
|
+
requestOf(ownerOf(input.constructor as Class), request).bus.send(input);
|
|
56
|
+
|
|
57
|
+
const eventBus = new BackendEventBus({
|
|
58
|
+
map: new ContextMap(modules.map),
|
|
59
|
+
send: (input, envelope) => send(input, { correlationId: envelope.correlationId }),
|
|
60
|
+
nextEventId: options.nextEventId ?? (() => crypto.randomUUID()),
|
|
61
|
+
reportError
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
for (const [name, { bindings, useCases, controllers }] of Object.entries(modules.contexts)) {
|
|
65
|
+
const context = { container: shared.child([...bindings, ...(options.replace?.[name] ?? [])]), useCases };
|
|
66
|
+
for (const { input } of useCases) owners.set(input, context);
|
|
67
|
+
for (const controller of controllers) owners.set(controller, context);
|
|
68
|
+
contexts.set(name, context);
|
|
69
|
+
// A dependency that nothing gives is refused now, not when a request arrives.
|
|
70
|
+
const { scope } = requestOf(context, SYSTEM);
|
|
71
|
+
for (const type of [...useCases.map(({ handler }) => handler), ...controllers]) scope.check(type as Concrete);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The contexts that bind a token that shared does not bind. */
|
|
75
|
+
const binding = (token: Token<unknown>): [string, Container][] => {
|
|
76
|
+
const found = [...contexts].filter(([, { container }]) => container.has(token)).map(([name, { container }]): [string, Container] => [name, container]);
|
|
77
|
+
if (found.length === 0) throw new Error(`No context binds ${token.name}`);
|
|
78
|
+
return found;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
/** Sends an input DTO to the context that handles it, in a request of its own. */
|
|
83
|
+
send,
|
|
84
|
+
/** The controller of a context, built for a request. */
|
|
85
|
+
controller: <C>(controller: abstract new (...args: never[]) => C, request: RequestContext = SYSTEM): C =>
|
|
86
|
+
requestOf(ownerOf(controller), request).scope.create(controller as unknown as Concrete<C>),
|
|
87
|
+
publish: (event: DomainEvent, request: RequestContext = SYSTEM) => { eventBus.publish(event, request); },
|
|
88
|
+
redeliver: (published: Published) => { eventBus.redeliver(published.event, published.envelope); },
|
|
89
|
+
subscribe: (listener: (published: Published) => void) => eventBus.subscribe(listener),
|
|
90
|
+
settled: () => eventBus.settled(),
|
|
91
|
+
/** For the tests: the dependency with this token, built now if nothing needed it before. */
|
|
92
|
+
get: <T>(token: Token<T>): T => {
|
|
93
|
+
if (shared.has(token)) return shared.get(token);
|
|
94
|
+
const found = binding(token);
|
|
95
|
+
if (found.length > 1) throw new Error(`The contexts ${found.map(([name]) => name).join(", ")} each bind ${token.name}`);
|
|
96
|
+
return found[0][1].get(token);
|
|
97
|
+
},
|
|
98
|
+
/** For the tests: the dependency that the contexts use from now on, in place of the one their bindings build. */
|
|
99
|
+
use: <T, Given extends T>(token: Token<T>, value: Given): Given => {
|
|
100
|
+
for (const container of shared.has(token) ? [shared] : binding(token).map(([, container]) => container)) container.use(token, value);
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type Backend = ReturnType<typeof createBackend>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { FrontendEventBus } from "../messaging/FrontendEventBus.js";
|
|
2
|
+
import { EventPublisher } from "../messaging/EventPublisher.js";
|
|
3
|
+
import type { EventSubscriber } from "../messaging/EventSubscriber.js";
|
|
4
|
+
import { InProcessMessageBus } from "../messaging/InProcessMessageBus.js";
|
|
5
|
+
import { createStore, type Store } from "../state/createStore.js";
|
|
6
|
+
import type { Token } from "../Token.js";
|
|
7
|
+
import { bind, Container, type Binding } from "./Container.js";
|
|
8
|
+
import type { FrontendModules } from "./discoverFrontends.js";
|
|
9
|
+
import { Fetcher } from "./Fetcher.js";
|
|
10
|
+
|
|
11
|
+
type Handler = new () => { execute(input: unknown): unknown };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds the frontend of a context: its event bus, its message bus with the handlers of its use cases, its store, and a container with
|
|
15
|
+
* the bindings of its di.ts. The container gives each class what its constructor needs: a handler gets the event bus as its
|
|
16
|
+
* EventPublisher. The store gets it as its EventSubscriber.
|
|
17
|
+
*/
|
|
18
|
+
function build(context: FrontendModules, fetch: Fetcher, replace: readonly Binding[]) {
|
|
19
|
+
const events = new FrontendEventBus<unknown>();
|
|
20
|
+
const container = new Container([bind(Fetcher).toValue(fetch), bind(EventPublisher).toValue(events), ...context.bindings, ...replace]);
|
|
21
|
+
const bus = new InProcessMessageBus();
|
|
22
|
+
// A handler is built for each DTO: it always gets the dependencies that the container holds at that time. A dependency that
|
|
23
|
+
// nothing gives is refused now, not when a DTO arrives.
|
|
24
|
+
for (const { input, handler } of context.useCases) {
|
|
25
|
+
container.check(handler as Handler);
|
|
26
|
+
bus.subscribe(input as never, received => container.create(handler as Handler).execute(received) as never);
|
|
27
|
+
}
|
|
28
|
+
return { events, store: createStore(context.store, { events, bus }), bus, container };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The frontend of a context, as the views get it: the publisher of its events, and the store they subscribe to. */
|
|
32
|
+
export function createFrontend(context: FrontendModules, fetch: Fetcher, replace: readonly Binding[] = []): { events: EventPublisher<unknown>; store: Store<unknown> } {
|
|
33
|
+
const { events, store } = build(context, fetch, replace);
|
|
34
|
+
return { events, store };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The same frontend, with its message bus and its dependencies: the views never get them. The tests of the application send their DTOs
|
|
39
|
+
* to the message bus, subscribe to the events to read what the handlers publish, and read or replace a dependency by its token.
|
|
40
|
+
*/
|
|
41
|
+
export function createFrontendWithBus(context: FrontendModules, fetch: Fetcher, replace: readonly Binding[] = []) {
|
|
42
|
+
const { events, store, bus, container } = build(context, fetch, replace);
|
|
43
|
+
const subscriber: EventSubscriber<unknown> = events;
|
|
44
|
+
return {
|
|
45
|
+
events: subscriber,
|
|
46
|
+
store,
|
|
47
|
+
bus,
|
|
48
|
+
/** The dependency with this token, built now if no handler needed it before. */
|
|
49
|
+
get: <T>(token: Token<T>): T => container.get(token),
|
|
50
|
+
/** Gives the dependency that the handlers use from now on, in place of the one the bindings build. */
|
|
51
|
+
use: <T, Given extends T>(token: Token<T>, value: Given): Given => container.use(token, value)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// JavaScript, not TypeScript: the Vite plugin (vite.js) imports this file, and Node loads the plugin itself. Node strips no types in
|
|
2
|
+
// node_modules, where a project installs the kernel. TypeScript checks the types of the comments.
|
|
3
|
+
|
|
4
|
+
/** @import { ESTree } from "vite" */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads a module of TypeScript as a program: the parser of Vite (oxc) in the build, and in the tests.
|
|
8
|
+
* @typedef {(filename: string, code: string) => { program: ESTree.Program }} Parse
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The code that a module needs after its own, for the containers:
|
|
13
|
+
* - after each class that has a constructor, the dependencies of the constructor (DEPENDENCIES in Container.ts): the name of each
|
|
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
|
+
* 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
|
+
* one of the packages given (a port and its token, or a class: the kernel gives EventPublisher, DataDirectory…). The token is read only
|
|
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
|
+
* - the name of each token declared without one: the name of its constant.
|
|
19
|
+
* The names are text: the minifier can rename the code.
|
|
20
|
+
* @param {string} code
|
|
21
|
+
* @param {string} filename
|
|
22
|
+
* @param {Parse} parse
|
|
23
|
+
* @param {readonly string[]} packages
|
|
24
|
+
* @returns {string | undefined}
|
|
25
|
+
*/
|
|
26
|
+
export function declareDependencies(code, filename, parse, packages) {
|
|
27
|
+
const { program } = parse(filename, code);
|
|
28
|
+
/** @type {Map<string, { source: string; name: string }>} */
|
|
29
|
+
const imported = new Map();
|
|
30
|
+
/** @type {Set<string>} */
|
|
31
|
+
const values = new Set();
|
|
32
|
+
/** @type {{ name: string; parameters: readonly ESTree.ParamPattern[] }[]} */
|
|
33
|
+
const classes = [];
|
|
34
|
+
/** @type {string[]} */
|
|
35
|
+
const tokens = [];
|
|
36
|
+
|
|
37
|
+
for (const statement of program.body) {
|
|
38
|
+
if (statement.type === "ImportDeclaration") {
|
|
39
|
+
const source = statement.source.value;
|
|
40
|
+
if (!source.startsWith(".") && !source.startsWith("#") && !packages.some(name => source === name || source.startsWith(`${name}/`))) continue;
|
|
41
|
+
for (const specifier of statement.specifiers) {
|
|
42
|
+
if (specifier.type === "ImportSpecifier") imported.set(specifier.local.name, { source, name: nameOf(specifier.imported) });
|
|
43
|
+
if (specifier.type === "ImportDefaultSpecifier") imported.set(specifier.local.name, { source, name: "default" });
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const declaration = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement;
|
|
48
|
+
if (declaration?.type === "VariableDeclaration") {
|
|
49
|
+
for (const { id, init } of declaration.declarations) {
|
|
50
|
+
if (id.type !== "Identifier") continue;
|
|
51
|
+
values.add(id.name);
|
|
52
|
+
if (init?.type === "NewExpression" && init.callee.type === "Identifier" && init.callee.name === "Token" && init.arguments.length === 0) tokens.push(id.name);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if ((declaration?.type === "FunctionDeclaration" || declaration?.type === "TSEnumDeclaration") && declaration.id) values.add(declaration.id.name);
|
|
56
|
+
if (declaration?.type === "ClassDeclaration" && declaration.id) {
|
|
57
|
+
values.add(declaration.id.name);
|
|
58
|
+
const constructor = declaration.body.body.find(member => member.type === "MethodDefinition" && member.kind === "constructor");
|
|
59
|
+
if (constructor?.type === "MethodDefinition") classes.push({ name: declaration.id.name, parameters: constructor.value.params });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (classes.length === 0 && tokens.length === 0) return undefined;
|
|
63
|
+
|
|
64
|
+
/** @type {Map<string, string>} */
|
|
65
|
+
const namespaces = new Map();
|
|
66
|
+
/** @type {(type: ESTree.TSType | undefined) => { type: string; token: string } | undefined} */
|
|
67
|
+
const tokenOf = type => {
|
|
68
|
+
if (type?.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return undefined;
|
|
69
|
+
const name = type.typeName.name;
|
|
70
|
+
if (values.has(name)) return { type: name, token: name };
|
|
71
|
+
const from = imported.get(name);
|
|
72
|
+
if (!from) return undefined;
|
|
73
|
+
if (!namespaces.has(from.source)) namespaces.set(from.source, `__dependencies_${namespaces.size}`);
|
|
74
|
+
return { type: name, token: `${namespaces.get(from.source)}[${JSON.stringify(from.name)}]` };
|
|
75
|
+
};
|
|
76
|
+
/** @type {(parameter: ESTree.ParamPattern, index: number) => string} */
|
|
77
|
+
const dependencyOf = (parameter, index) => {
|
|
78
|
+
const inner = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter;
|
|
79
|
+
const pattern = inner.type === "AssignmentPattern" ? inner.left : inner;
|
|
80
|
+
const name = pattern.type === "Identifier" ? pattern.name : `#${index}`;
|
|
81
|
+
const typed = tokenOf(pattern.typeAnnotation?.typeAnnotation);
|
|
82
|
+
const optional = inner.type === "AssignmentPattern" || pattern.optional === true;
|
|
83
|
+
return `{ name: ${JSON.stringify(name)}${typed ? `, type: ${JSON.stringify(typed.type)}, token: () => ${typed.token}` : ""}${optional ? ", optional: true" : ""} }`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const declarations = classes.map(({ name, parameters }) => `${name}[Symbol.for("kernel.dependencies")] = [${parameters.map(dependencyOf).join(", ")}];`);
|
|
87
|
+
const imports = [...namespaces].map(([source, alias]) => `import * as ${alias} from ${JSON.stringify(source)};`);
|
|
88
|
+
const names = tokens.map(name => `${name}.name = ${JSON.stringify(name)};`);
|
|
89
|
+
return [...imports, ...declarations, ...names].join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @type {(name: ESTree.ModuleExportName) => string} */
|
|
93
|
+
const nameOf = name => name.type === "Literal" ? name.value : name.name;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Translation } from "../messaging/ContextMap.js";
|
|
2
|
+
import { Input } from "../messaging/Input.js";
|
|
3
|
+
import type { Binding } from "./Container.js";
|
|
4
|
+
|
|
5
|
+
export type Class = abstract new (...args: never[]) => unknown;
|
|
6
|
+
export type UseCase = { readonly input: Class; readonly handler: Class };
|
|
7
|
+
export type ContextModules = { bindings: readonly Binding[]; useCases: UseCase[]; controllers: Class[] };
|
|
8
|
+
export type BackendModules = { shared: readonly Binding[]; contexts: Record<string, ContextModules>; map: Translation[] };
|
|
9
|
+
|
|
10
|
+
type Modules = Record<string, Record<string, unknown>>;
|
|
11
|
+
|
|
12
|
+
const contextOf = (path: string) => /backend\/([^/]+)\//.exec(path)![1];
|
|
13
|
+
export const classesOf = (module: Record<string, unknown>) => Object.values(module).filter((value): value is Class => typeof value === "function");
|
|
14
|
+
|
|
15
|
+
export function single<T>(found: T[], what: string): T {
|
|
16
|
+
if (found.length !== 1) throw new Error(`${what} (found ${found.length})`);
|
|
17
|
+
return found[0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Turns the modules found in the backend folder (src/backend by default) into the contexts of the backend: a handler serves the input DTO of its use-case folder. */
|
|
21
|
+
export function discover(found: { bindings: Record<string, readonly Binding[]>; dtos: Modules; handlers: Modules; controllers: Modules; map: Record<string, Translation[]> }): BackendModules {
|
|
22
|
+
const contexts: Record<string, ContextModules> = {};
|
|
23
|
+
const context = (path: string) => contexts[contextOf(path)] ??= { bindings: [], useCases: [], controllers: [] };
|
|
24
|
+
let shared: readonly Binding[] = [];
|
|
25
|
+
|
|
26
|
+
for (const [path, bindings] of Object.entries(found.bindings)) {
|
|
27
|
+
if (contextOf(path) === "shared") shared = bindings;
|
|
28
|
+
else context(path).bindings = bindings;
|
|
29
|
+
}
|
|
30
|
+
for (const [path, module] of Object.entries(found.handlers)) {
|
|
31
|
+
const dto = path.replace(".handler.ts", ".dto.ts");
|
|
32
|
+
const input = single(classesOf(found.dtos[dto] ?? {}).filter(type => type.prototype instanceof Input), `${dto} declares one input DTO`);
|
|
33
|
+
const handler = single(classesOf(module), `${path} declares one handler`);
|
|
34
|
+
context(path).useCases.push({ input, handler });
|
|
35
|
+
}
|
|
36
|
+
for (const [path, module] of Object.entries(found.controllers)) context(path).controllers.push(...classesOf(module));
|
|
37
|
+
|
|
38
|
+
return { shared, contexts, map: Object.values(found.map)[0] ?? [] };
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Input } from "../messaging/Input.js";
|
|
2
|
+
import type { StoreDefinition } from "../state/createStore.js";
|
|
3
|
+
import type { Binding } from "./Container.js";
|
|
4
|
+
import { classesOf, single, type UseCase } from "./discover.js";
|
|
5
|
+
|
|
6
|
+
export type FrontendModules = { bindings: readonly Binding[]; useCases: UseCase[]; store: StoreDefinition<unknown, unknown> };
|
|
7
|
+
|
|
8
|
+
type Modules = Record<string, Record<string, unknown>>;
|
|
9
|
+
|
|
10
|
+
const contextOf = (path: string) => /frontend\/([^/]+)\//.exec(path)![1];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Turns the modules found in the frontend folder (src/frontend by default) into the contexts of the frontend: a context has a di.ts and a store,
|
|
14
|
+
* and a handler serves the input DTO of its use-case folder.
|
|
15
|
+
*/
|
|
16
|
+
export function discoverFrontends(found: {
|
|
17
|
+
bindings: Record<string, readonly Binding[]>;
|
|
18
|
+
dtos: Modules;
|
|
19
|
+
handlers: Modules;
|
|
20
|
+
stores: Record<string, StoreDefinition<unknown, unknown>>;
|
|
21
|
+
}): Record<string, FrontendModules> {
|
|
22
|
+
const contexts: Record<string, FrontendModules> = {};
|
|
23
|
+
for (const [path, bindings] of Object.entries(found.bindings)) {
|
|
24
|
+
const name = contextOf(path);
|
|
25
|
+
const store = single(Object.entries(found.stores).filter(([storePath]) => contextOf(storePath) === name).map(([, definition]) => definition), `the frontend ${name} declares one store in presentation/store/`);
|
|
26
|
+
contexts[name] = { bindings, useCases: [], store };
|
|
27
|
+
}
|
|
28
|
+
for (const [path, module] of Object.entries(found.handlers)) {
|
|
29
|
+
const context = contexts[contextOf(path)];
|
|
30
|
+
if (!context) throw new Error(`${path} belongs to a frontend without a di.ts`);
|
|
31
|
+
const dto = path.replace(".handler.ts", ".dto.ts");
|
|
32
|
+
const input = single(classesOf(found.dtos[dto] ?? {}).filter(type => type.prototype instanceof Input), `${dto} declares one input DTO`);
|
|
33
|
+
context.useCases.push({ input, handler: single(classesOf(module), `${path} declares one handler`) });
|
|
34
|
+
}
|
|
35
|
+
return contexts;
|
|
36
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/// <reference path="../virtual-modules.d.ts" />
|
|
2
|
+
import { bindings, dtos, handlers, controllers, map } from "virtual:kernel/backend";
|
|
3
|
+
import { discover } from "./discover.js";
|
|
4
|
+
|
|
5
|
+
/** Every context of the backend: its di.ts, its use cases and its controllers, and the context map. The plugin of the kernel finds them. */
|
|
6
|
+
export const discovered = discover({ bindings, dtos, handlers, controllers, map });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/// <reference path="../virtual-modules.d.ts" />
|
|
2
|
+
import { bindings, dtos, handlers, stores } from "virtual:kernel/frontend";
|
|
3
|
+
import { discoverFrontends } from "./discoverFrontends.js";
|
|
4
|
+
|
|
5
|
+
/** Every context of the frontend: its di.ts, its use cases and its store. The plugin of the kernel finds them. */
|
|
6
|
+
export const discoveredFrontends = discoverFrontends({ bindings, dtos, handlers, stores });
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createFrontend } from "./createFrontend.js";
|
|
2
|
+
import { discoveredFrontends } from "./discoveredFrontends.js";
|
|
3
|
+
import type { Fetcher } from "./Fetcher.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The frontends of the contexts, by name. Each context adds its own, next to its type:
|
|
7
|
+
* `declare module "@ingenioz-it/caits/di/frontend" { interface Frontends { counter: CounterFrontend } }`.
|
|
8
|
+
*/
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the contexts fill it
|
|
10
|
+
export interface Frontends {}
|
|
11
|
+
|
|
12
|
+
const kept = Symbol("frontends");
|
|
13
|
+
type Global = { fetch?: Fetcher; [kept]?: Map<string, unknown> };
|
|
14
|
+
|
|
15
|
+
/** The frontends kept on a $global, by name. A test can put its own there before a view asks. */
|
|
16
|
+
export function frontendsOn($global: object): Map<string, unknown> {
|
|
17
|
+
return ($global as Global)[kept] ??= new Map<string, unknown>();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The frontend of a context, for the views of one $global: the server has one $global per request, the browser one per page.
|
|
22
|
+
* It is built the first time a view asks. On the server, it reaches the API with the fetch of the request (Marko Run, in the
|
|
23
|
+
* same process); in the browser, with the fetch of the browser.
|
|
24
|
+
*/
|
|
25
|
+
export function frontendOf<Name extends keyof Frontends>($global: object, name: Name): Frontends[Name] {
|
|
26
|
+
const frontends = frontendsOn($global);
|
|
27
|
+
if (!frontends.has(name)) {
|
|
28
|
+
const context = discoveredFrontends[name];
|
|
29
|
+
if (!context) throw new Error(`No frontend context is named ${name}`);
|
|
30
|
+
frontends.set(name, createFrontend(context, ($global as Global).fetch ?? ((input, init) => fetch(input, init))));
|
|
31
|
+
}
|
|
32
|
+
return frontends.get(name) as Frontends[Name];
|
|
33
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The error of a refused HTTP request: the `detail` of its problem when it is text, otherwise its status. */
|
|
2
|
+
export async function failureOf(response: Response): Promise<Error> {
|
|
3
|
+
const fallback = `HTTP ${response.status}`;
|
|
4
|
+
try {
|
|
5
|
+
const body = (await response.json()) as { detail?: unknown } | null;
|
|
6
|
+
return new Error(typeof body?.detail === "string" ? body.detail : fallback);
|
|
7
|
+
} catch {
|
|
8
|
+
return new Error(fallback);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
|
+
import type { ContextMap, Envelope } from "./ContextMap.js";
|
|
3
|
+
import type { Input } from "./Input.js";
|
|
4
|
+
import type { ReportError } from "./ReportError.js";
|
|
5
|
+
|
|
6
|
+
export type Published = { readonly event: DomainEvent; readonly envelope: Envelope };
|
|
7
|
+
|
|
8
|
+
const nextTurn = () => new Promise(resolve => { setTimeout(resolve, 0); });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Carries the events of the contexts. The context map turns each event into input DTOs, and each input goes to the context that
|
|
12
|
+
* handles it. The publisher never waits for the reactions; every failure is reported.
|
|
13
|
+
*/
|
|
14
|
+
export class BackendEventBus {
|
|
15
|
+
private inFlight = new Set<Promise<void>>();
|
|
16
|
+
private listeners = new Set<(published: Published) => void>();
|
|
17
|
+
|
|
18
|
+
constructor(private readonly options: {
|
|
19
|
+
map: ContextMap;
|
|
20
|
+
send: (input: Input<unknown>, envelope: Envelope) => Promise<unknown>;
|
|
21
|
+
nextEventId: () => string;
|
|
22
|
+
reportError: ReportError;
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
25
|
+
publish(event: DomainEvent, request: { readonly correlationId: string }): void {
|
|
26
|
+
this.redeliver(event, { eventId: this.options.nextEventId(), correlationId: request.correlationId });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Carries an event again, in the envelope it was first published in: what a broker does when it delivers twice. */
|
|
30
|
+
redeliver(event: DomainEvent, envelope: Envelope): void {
|
|
31
|
+
for (const listener of [...this.listeners]) listener({ event, envelope });
|
|
32
|
+
const delivery = this.deliver(event, envelope);
|
|
33
|
+
this.inFlight.add(delivery);
|
|
34
|
+
void delivery.then(() => { this.inFlight.delete(delivery); });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Watches every event the contexts publish. */
|
|
38
|
+
subscribe(listener: (published: Published) => void): () => void {
|
|
39
|
+
this.listeners.add(listener);
|
|
40
|
+
return () => { this.listeners.delete(listener); };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Waits until every event published so far, and every event they led to, has been carried. */
|
|
44
|
+
async settled(): Promise<void> {
|
|
45
|
+
while (this.inFlight.size > 0) await Promise.all([...this.inFlight]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async deliver(event: DomainEvent, envelope: Envelope): Promise<void> {
|
|
49
|
+
await nextTurn();
|
|
50
|
+
let inputs: Input<unknown>[];
|
|
51
|
+
try {
|
|
52
|
+
inputs = this.options.map.inputsFor(event, envelope);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
this.options.reportError(error);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const results = await Promise.allSettled(inputs.map(input => this.options.send(input, envelope)));
|
|
58
|
+
for (const result of results) if (result.status === "rejected") this.options.reportError(result.reason);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
|
+
import type { Input } from "./Input.js";
|
|
3
|
+
|
|
4
|
+
/** What the shared bus adds to an event it carries: an id of its own, and the correlation id of the request that published it. */
|
|
5
|
+
export type Envelope = { readonly eventId: string; readonly correlationId: string };
|
|
6
|
+
|
|
7
|
+
type EventClass<E> = abstract new (...args: never[]) => E;
|
|
8
|
+
|
|
9
|
+
export type Translation = { readonly event: EventClass<DomainEvent>; readonly inputs: (event: never, envelope: Envelope) => Input<unknown>[] };
|
|
10
|
+
|
|
11
|
+
/** One line of the context map: an event of a context becomes input DTOs, for whichever contexts handle them. */
|
|
12
|
+
export function when<E extends DomainEvent>(event: EventClass<E>, inputs: (event: E, envelope: Envelope) => Input<unknown>[]): Translation {
|
|
13
|
+
return { event, inputs };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ContextMap {
|
|
17
|
+
constructor(private readonly translations: Translation[]) {}
|
|
18
|
+
|
|
19
|
+
inputsFor(event: DomainEvent, envelope: Envelope): Input<unknown>[] {
|
|
20
|
+
return this.translations.filter(translation => event instanceof translation.event).flatMap(translation => translation.inputs(event as never, envelope));
|
|
21
|
+
}
|
|
22
|
+
}
|