@fougere/core 0.8.3-alpha.0 → 0.8.4-alpha.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/dist/boot/Emissions.d.ts.map +1 -1
- package/dist/boot/Emissions.js +3 -3
- package/dist/boot/Emissions.js.map +1 -1
- package/dist/boot/auth.d.ts +1 -1
- package/dist/boot/auth.d.ts.map +1 -1
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +339 -595
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/card.js +1 -1
- package/dist/boot/card.js.map +1 -1
- package/dist/boot/hosted.d.ts +1 -1
- package/dist/boot/hosted.d.ts.map +1 -1
- package/dist/boot/install.d.ts +40 -0
- package/dist/boot/install.d.ts.map +1 -0
- package/dist/boot/install.js +298 -0
- package/dist/boot/install.js.map +1 -0
- package/dist/boot/ownership.d.ts +1 -1
- package/dist/boot/ownership.d.ts.map +1 -1
- package/dist/boot/ownership.js +14 -1
- package/dist/boot/ownership.js.map +1 -1
- package/dist/boot/seed.d.ts +7 -1
- package/dist/boot/seed.d.ts.map +1 -1
- package/dist/boot/seed.js +7 -2
- package/dist/boot/seed.js.map +1 -1
- package/dist/boot/statement-drift.d.ts +1 -1
- package/dist/boot/statement-drift.d.ts.map +1 -1
- package/dist/boot/together.d.ts +1 -1
- package/dist/boot/together.d.ts.map +1 -1
- package/dist/boot/together.js +1 -1
- package/dist/boot/together.js.map +1 -1
- package/dist/boot/types.d.ts +2 -2
- package/dist/boot/types.d.ts.map +1 -1
- package/dist/config-loader.d.ts +1 -1
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/contract.d.ts +2 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/conventions.d.ts.map +1 -0
- package/dist/conventions.js.map +1 -0
- package/dist/crypto/encoding.d.ts +0 -1
- package/dist/crypto/encoding.d.ts.map +1 -1
- package/dist/crypto/encoding.js +0 -7
- package/dist/crypto/encoding.js.map +1 -1
- package/dist/declare.js +1 -1
- package/dist/declare.js.map +1 -1
- package/dist/descriptor/index.d.ts +15 -0
- package/dist/descriptor/index.d.ts.map +1 -0
- package/dist/descriptor/index.js +14 -0
- package/dist/descriptor/index.js.map +1 -0
- package/dist/descriptor/surface.d.ts +8 -0
- package/dist/descriptor/surface.d.ts.map +1 -0
- package/dist/descriptor/surface.js +16 -0
- package/dist/descriptor/surface.js.map +1 -0
- package/dist/dispatch/HandlerFacade.d.ts +2 -0
- package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.js +7 -2
- package/dist/dispatch/HandlerFacade.js.map +1 -1
- package/dist/dispatch/StorageGuard.d.ts.map +1 -1
- package/dist/dispatch/StorageGuard.js +9 -23
- package/dist/dispatch/StorageGuard.js.map +1 -1
- package/dist/dispatch/presenterArguments.d.ts +7 -1
- package/dist/dispatch/presenterArguments.d.ts.map +1 -1
- package/dist/dispatch/presenterArguments.js +15 -4
- package/dist/dispatch/presenterArguments.js.map +1 -1
- package/dist/dispatch/validateInput.d.ts.map +1 -1
- package/dist/dispatch/validateInput.js +2 -2
- package/dist/dispatch/validateInput.js.map +1 -1
- package/dist/effective-operation.d.ts +1 -1
- package/dist/effective-operation.d.ts.map +1 -1
- package/dist/effective-operation.js +2 -12
- package/dist/effective-operation.js.map +1 -1
- package/dist/index.d.ts +7 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +2 -12
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +2 -12
- package/dist/node.js.map +1 -1
- package/dist/prefab/crud.d.ts +1 -1
- package/dist/prefab/crud.d.ts.map +1 -1
- package/dist/prefab/mirror.d.ts +10 -11
- package/dist/prefab/mirror.d.ts.map +1 -1
- package/dist/prefab/mirror.js +1 -27
- package/dist/prefab/mirror.js.map +1 -1
- package/dist/prefab/repository.d.ts +1 -1
- package/dist/prefab/repository.d.ts.map +1 -1
- package/dist/{scan/result.d.ts → scan.d.ts} +2 -2
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +2 -0
- package/dist/scan.js.map +1 -0
- package/dist/source.d.ts +1 -1
- package/dist/source.d.ts.map +1 -1
- package/dist/storage/criterion.d.ts.map +1 -0
- package/dist/{criterion.js → storage/criterion.js} +2 -2
- package/dist/storage/criterion.js.map +1 -0
- package/dist/{storage.d.ts → storage/port.d.ts} +6 -3
- package/dist/storage/port.d.ts.map +1 -0
- package/dist/{storage.js → storage/port.js} +13 -6
- package/dist/storage/port.js.map +1 -0
- package/dist/{store.d.ts → storage/store.d.ts} +1 -1
- package/dist/storage/store.d.ts.map +1 -0
- package/dist/{store.js → storage/store.js} +33 -17
- package/dist/storage/store.js.map +1 -0
- package/dist/verify.js +1 -1
- package/dist/verify.js.map +1 -1
- package/dist/wire/emit.d.ts.map +1 -0
- package/dist/wire/emit.js.map +1 -0
- package/dist/wire/errors.js +2 -2
- package/dist/wire/errors.js.map +1 -1
- package/package.json +10 -4
- package/src/boot/Emissions.ts +3 -3
- package/src/boot/auth.ts +1 -1
- package/src/boot/bootstrap.ts +386 -674
- package/src/boot/card.ts +1 -1
- package/src/boot/hosted.ts +1 -1
- package/src/boot/install.ts +390 -0
- package/src/boot/ownership.ts +17 -2
- package/src/boot/seed.ts +18 -3
- package/src/boot/statement-drift.ts +1 -1
- package/src/boot/together.ts +1 -1
- package/src/boot/types.ts +2 -2
- package/src/config-loader.ts +1 -1
- package/src/contract.ts +2 -2
- package/src/crypto/encoding.ts +0 -7
- package/src/declare.ts +1 -1
- package/src/descriptor/index.ts +15 -0
- package/src/descriptor/surface.ts +23 -0
- package/src/dispatch/HandlerFacade.ts +9 -2
- package/src/dispatch/StorageGuard.ts +8 -21
- package/src/dispatch/presenterArguments.ts +21 -9
- package/src/dispatch/validateInput.ts +2 -2
- package/src/effective-operation.ts +3 -14
- package/src/index.ts +10 -9
- package/src/node.ts +2 -16
- package/src/prefab/crud.ts +1 -1
- package/src/prefab/mirror.ts +12 -38
- package/src/prefab/repository.ts +1 -1
- package/src/{scan/result.ts → scan.ts} +1 -1
- package/src/source.ts +1 -1
- package/src/{criterion.ts → storage/criterion.ts} +2 -2
- package/src/{storage.ts → storage/port.ts} +20 -5
- package/src/{store.ts → storage/store.ts} +35 -16
- package/src/verify.ts +1 -1
- package/src/wire/call.ts +1 -1
- package/src/wire/errors.ts +2 -2
- package/dist/boot/boot.d.ts +0 -45
- package/dist/boot/boot.d.ts.map +0 -1
- package/dist/boot/boot.js +0 -63
- package/dist/boot/boot.js.map +0 -1
- package/dist/criterion.d.ts.map +0 -1
- package/dist/criterion.js.map +0 -1
- package/dist/declarations.d.ts +0 -34
- package/dist/declarations.d.ts.map +0 -1
- package/dist/declarations.js +0 -75
- package/dist/declarations.js.map +0 -1
- package/dist/emit.d.ts.map +0 -1
- package/dist/emit.js.map +0 -1
- package/dist/imports.d.ts +0 -16
- package/dist/imports.d.ts.map +0 -1
- package/dist/imports.js +0 -78
- package/dist/imports.js.map +0 -1
- package/dist/placement.d.ts +0 -25
- package/dist/placement.d.ts.map +0 -1
- package/dist/placement.js +0 -69
- package/dist/placement.js.map +0 -1
- package/dist/scan/adapters.d.ts +0 -3
- package/dist/scan/adapters.d.ts.map +0 -1
- package/dist/scan/adapters.js +0 -25
- package/dist/scan/adapters.js.map +0 -1
- package/dist/scan/bundling.d.ts +0 -3
- package/dist/scan/bundling.d.ts.map +0 -1
- package/dist/scan/bundling.js +0 -15
- package/dist/scan/bundling.js.map +0 -1
- package/dist/scan/contract.d.ts +0 -27
- package/dist/scan/contract.d.ts.map +0 -1
- package/dist/scan/contract.js +0 -46
- package/dist/scan/contract.js.map +0 -1
- package/dist/scan/conventions.d.ts.map +0 -1
- package/dist/scan/conventions.js.map +0 -1
- package/dist/scan/emit.d.ts +0 -10
- package/dist/scan/emit.d.ts.map +0 -1
- package/dist/scan/emit.js +0 -144
- package/dist/scan/emit.js.map +0 -1
- package/dist/scan/handler-parser.d.ts +0 -26
- package/dist/scan/handler-parser.d.ts.map +0 -1
- package/dist/scan/handler-parser.js +0 -607
- package/dist/scan/handler-parser.js.map +0 -1
- package/dist/scan/result.d.ts.map +0 -1
- package/dist/scan/result.js +0 -2
- package/dist/scan/result.js.map +0 -1
- package/dist/scan/scanner.d.ts +0 -12
- package/dist/scan/scanner.d.ts.map +0 -1
- package/dist/scan/scanner.js +0 -573
- package/dist/scan/scanner.js.map +0 -1
- package/dist/scan/statement.d.ts +0 -4
- package/dist/scan/statement.d.ts.map +0 -1
- package/dist/scan/statement.js +0 -135
- package/dist/scan/statement.js.map +0 -1
- package/dist/storage.d.ts.map +0 -1
- package/dist/storage.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/src/boot/boot.ts +0 -110
- package/src/declarations.ts +0 -110
- package/src/imports.ts +0 -94
- package/src/placement.ts +0 -86
- package/src/scan/adapters.ts +0 -32
- package/src/scan/bundling.ts +0 -14
- package/src/scan/contract.ts +0 -80
- package/src/scan/emit.ts +0 -160
- package/src/scan/handler-parser.ts +0 -695
- package/src/scan/scanner.ts +0 -698
- package/src/scan/statement.ts +0 -159
- /package/dist/{scan/conventions.d.ts → conventions.d.ts} +0 -0
- /package/dist/{scan/conventions.js → conventions.js} +0 -0
- /package/dist/{criterion.d.ts → storage/criterion.d.ts} +0 -0
- /package/dist/{emit.d.ts → wire/emit.d.ts} +0 -0
- /package/dist/{emit.js → wire/emit.js} +0 -0
- /package/src/{scan/conventions.ts → conventions.ts} +0 -0
- /package/src/{emit.ts → wire/emit.ts} +0 -0
package/src/boot/bootstrap.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { lowerFirst, type Fields, type SchemaView } from '@fougere/schema';
|
|
|
3
3
|
import { nameOf } from '../descriptor/frond.js';
|
|
4
4
|
import type { EntityEntry, HandlerEntry, PresenterEntry } from '../descriptor/frond.js';
|
|
5
5
|
import { hostedBy } from './hosted.js';
|
|
6
|
+
import { installFrond, type Assembly } from './install.js';
|
|
6
7
|
import type { AuthRuntime } from './auth.js';
|
|
7
8
|
import type { CreateAppOptions, App } from './types.js';
|
|
8
9
|
import type { AppMiddleware } from '../wire/middleware.js';
|
|
@@ -28,7 +29,7 @@ import { identityCardOf } from './card.js';
|
|
|
28
29
|
import { AppLifecycle } from './AppLifecycle.js';
|
|
29
30
|
import { inheritsCrud, subjectOf } from '../prefab/crud.js';
|
|
30
31
|
import { repositoryKeyOf } from '../prefab/repository.js';
|
|
31
|
-
import { storageKeyOf } from '../storage.js';
|
|
32
|
+
import { storageKeyOf } from '../storage/port.js';
|
|
32
33
|
import { declares } from '../source.js';
|
|
33
34
|
import { presenterKeyOf } from '../prefab/presenter.js';
|
|
34
35
|
import { collectorKeyOf } from '../prefab/collector.js';
|
|
@@ -36,6 +37,7 @@ import { RouteAddress } from '../wire/RouteAddress.js';
|
|
|
36
37
|
import { DispatchLifecycle } from '../dispatch/DispatchLifecycle.js';
|
|
37
38
|
import { Dispatcher } from '../dispatch/Dispatcher.js';
|
|
38
39
|
import { LocalRoutePolicy } from '../dispatch/LocalRoutePolicy.js';
|
|
40
|
+
import { servedSurfaces } from '../descriptor/surface.js';
|
|
39
41
|
import { OperationRoute } from '../dispatch/OperationRoute.js';
|
|
40
42
|
import { remoteRoutes } from '../dispatch/remoteRoutes.js';
|
|
41
43
|
import { RouteRegistry } from '../dispatch/RouteRegistry.js';
|
|
@@ -78,506 +80,12 @@ function assertOneOwnerPerKey(
|
|
|
78
80
|
/** Bootstrap a fougere application. */
|
|
79
81
|
export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
80
82
|
const container = options.createContainer();
|
|
81
|
-
// Boot chatter is debug by default; a host (e.g. the CLI) can quiet it.
|
|
82
|
-
const log = new Logger('boot:app');
|
|
83
|
-
|
|
84
|
-
// Builtins — registered under class name (PascalCase) for type-based DI.
|
|
85
|
-
// No level here and none anywhere: a logger consults `setLogLevel`'s value at each
|
|
86
|
-
// emission, so this instance survives a level change and so does every handler that
|
|
87
|
-
// was handed it. A frond declaring `class X extends Logger` takes this key over,
|
|
88
|
-
// like any other port.
|
|
89
|
-
container.registerValue('Logger', new Logger('app'));
|
|
90
|
-
container.register('Config', Config, { lifetime: 'singleton' });
|
|
91
|
-
log.debug('builtins registered (Logger, Config)');
|
|
92
|
-
|
|
93
|
-
// What this app hosts is HANDED IN — stated, scanned, or both (`hostedBy`). Producing
|
|
94
|
-
// it may read a disk; consuming it never does, which is the whole reason this file names
|
|
95
|
-
// no builtin and a Worker can run what it builds.
|
|
96
|
-
const scanStart = performance.now();
|
|
97
|
-
const { fronds, diagnostics } = await hostedBy(options);
|
|
98
|
-
// An app that states nothing AND scans nothing is a mistake — unless something else it
|
|
99
|
-
// declares brings its own entities, which an auth provider does. Refused here and not in
|
|
100
|
-
// `hostedBy`, which is handed the frond sources and cannot see the rest of the app. The
|
|
101
|
-
// condition is the KEYS, not the count: a scan that found nothing is an ordinary answer.
|
|
102
|
-
if (!options.fronds && !options.scan && !options.auth) {
|
|
103
|
-
throw new Error(
|
|
104
|
-
'createApp needs `fronds:` (what this app states) or `scan:` (what a scanner found). '
|
|
105
|
-
+ 'Neither was given, and nothing else declares entities of its own.',
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
const operationModel = resolveEffectiveOperations(fronds, {
|
|
109
|
-
diagnostics,
|
|
110
|
-
remotes: options.remotes,
|
|
111
|
-
adapters: options.adapters,
|
|
112
|
-
});
|
|
113
|
-
const scanMs = (performance.now() - scanStart).toFixed(0);
|
|
114
|
-
const blocking = diagnostics.filter((d) => d.severity === 'blocking');
|
|
115
|
-
log.info(`read ${fronds.length} frond(s) in ${scanMs}ms`
|
|
116
|
-
+ (diagnostics.length ? ` — ${diagnostics.length} thing(s) the scan could not do` : ''));
|
|
117
|
-
|
|
118
|
-
/** Say what could not be read, at the one line everyone already watches. */
|
|
119
|
-
for (const d of blocking) log.error(`[${d.code}] ${d.message}`, d.cause);
|
|
120
|
-
for (const d of diagnostics) if (d.severity === 'warning') log.warn(`[${d.code}] ${d.message}`);
|
|
121
|
-
|
|
122
|
-
/** An ambiguous convention is not a partial scan. */
|
|
123
|
-
const invalidOperations = operationModel.resolutionDiagnostics
|
|
124
|
-
.filter((diagnostic) => diagnostic.severity === 'blocking');
|
|
125
|
-
if (invalidOperations.length > 0) {
|
|
126
|
-
const details = invalidOperations.map((d) =>
|
|
127
|
-
` [${d.code}]${d.subject ? ` ${d.subject}` : ''}\n ${d.message}\n ${d.filePath}`,
|
|
128
|
-
);
|
|
129
|
-
throw new Error(
|
|
130
|
-
`Fougere boot refused: ${invalidOperations.length} unresolved operation contract(s):\n`
|
|
131
|
-
+ details.join('\n'),
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
83
|
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// storageFactory so all auth writes flow through Storage.
|
|
138
|
-
let authRuntime: AuthRuntime | undefined;
|
|
139
|
-
if (options.auth) {
|
|
140
|
-
if (!options.storageFactory) {
|
|
141
|
-
throw new Error('createApp: `auth` is set but `storageFactory` is missing — auth providers need it to back their adapter.');
|
|
142
|
-
}
|
|
143
|
-
if (options.db === undefined) {
|
|
144
|
-
throw new Error('createApp: `auth` is set but `db` is missing — pass the storage handle through CreateAppOptions.db.');
|
|
145
|
-
}
|
|
146
|
-
log.info('initializing auth runtime');
|
|
147
|
-
authRuntime = await options.auth.create({
|
|
148
|
-
db: options.db,
|
|
149
|
-
storageFactory: options.storageFactory,
|
|
150
|
-
});
|
|
151
|
-
log.info(`auth ready — mounted at ${authRuntime.basePath}`);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Remote routing — validated at boot: declaring remotes without a transport is a config error.
|
|
155
|
-
// A remote declaration wins over local presence: `remotes: { blog: url }` IS
|
|
156
|
-
// the topology statement — the frond's code may sit in fronds/**, it runs elsewhere.
|
|
157
|
-
const declaredRemotes = Object.entries(options.remotes ?? {});
|
|
158
|
-
if (declaredRemotes.length > 0 && !options.remoteTransport) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
'createApp: `remotes` is declared but `remoteTransport` is missing — pass one (e.g. from @fougere/transport-http).',
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
const remoteRouter = declaredRemotes.length > 0 && options.remoteTransport
|
|
164
|
-
? createRemoteRouter(Object.fromEntries(declaredRemotes), options.remoteTransport)
|
|
165
|
-
: undefined;
|
|
166
|
-
|
|
167
|
-
// What is running on this app — counted at the one door every caller goes through,
|
|
168
|
-
// so releasing it can wait for the work instead of pulling the floor out.
|
|
169
|
-
const inflight = new InFlight();
|
|
170
|
-
|
|
171
|
-
// Middleware storage — read at call time, not at boot time
|
|
172
|
-
const globalMiddlewares: AppMiddleware[] = [];
|
|
173
|
-
const scopedMiddlewares = new Map<string, AppMiddleware[]>();
|
|
174
|
-
/** What this app took on beyond its fronds. Its `up` is the last thing the boot does. */
|
|
84
|
+
// Held out here, and not where the ascent reads it, because releasing needs it and
|
|
85
|
+
// releasing has to work from the first line the boot takes something.
|
|
175
86
|
const appLifecycle = new AppLifecycle().add(...(options.extensions ?? []));
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
options.dispatchObservers,
|
|
179
|
-
(error, event) => log.error(
|
|
180
|
-
`[dispatch-observer] ${event.stage} ${event.call.address.toString()}`,
|
|
181
|
-
error,
|
|
182
|
-
),
|
|
183
|
-
);
|
|
184
|
-
const dispatcher = new Dispatcher(routeRegistry, inflight, dispatchLifecycle);
|
|
185
|
-
const localDispatcher = new Dispatcher(
|
|
186
|
-
routeRegistry,
|
|
187
|
-
inflight,
|
|
188
|
-
dispatchLifecycle,
|
|
189
|
-
new LocalRoutePolicy((surface) => fronds.servedNames(surface)),
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
function getMiddlewares(entity: string): AppMiddleware[] {
|
|
193
|
-
const scoped = scopedMiddlewares.get(entity) ?? [];
|
|
194
|
-
return [...globalMiddlewares, ...scoped];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
assertOneOwnerPerKey(fronds, options.remotes);
|
|
198
|
-
|
|
199
|
-
// Every entity of every frond, by name — so a fact can be validated where it LANDS, and
|
|
200
|
-
// so a `reads:` clause can name a neighbour's.
|
|
201
|
-
const entityByName = fronds.schemas();
|
|
202
|
-
// Which frond holds an entity — what turns "a member is remote" into a refusal that
|
|
203
|
-
// names the frond rather than the entity, since `remotes:` is declared per frond.
|
|
204
|
-
const frondOf = new Map(fronds.flatMap((f) => f.entities.map((e) => [e.name, f.name] as const)));
|
|
205
|
-
const emissions = new Emissions(fronds, entityByName, container, log, options.onEmit);
|
|
206
|
-
/** Canonical operation tables, indexed by the same audience key as their facades. */
|
|
207
|
-
const effectiveByKey = new Map<string, EffectiveOperationsMap>();
|
|
208
|
-
|
|
209
|
-
const contractsOf = (operations: EffectiveOperationsMap): OperationsMap => new Map(
|
|
210
|
-
[...operations].map(([name, operation]) => [name, operation as OperationContract] as const),
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
// Every port an implementation was bound to, so a `ports:` entry that named none
|
|
214
|
-
// can say so rather than look obeyed.
|
|
215
|
-
const boundPorts = new Set<string>();
|
|
216
|
-
|
|
217
|
-
// Register frond scopes
|
|
218
|
-
for (const frond of fronds) {
|
|
219
|
-
// Declared remote: keep the scanned metadata (bridges route with it),
|
|
220
|
-
// register nothing locally — resolve() falls through to the remote façade.
|
|
221
|
-
if (options.remotes && frond.name in options.remotes) {
|
|
222
|
-
log.child(frond.name).info('declared remote — not hosted locally');
|
|
223
|
-
// Its doors answer elsewhere, but what they LISTEN to was read here.
|
|
224
|
-
for (const handler of frond.handlers) {
|
|
225
|
-
const key = facadeKeyOf(handler.address, handler.surface);
|
|
226
|
-
const operations = operationModel.forHandler(handler);
|
|
227
|
-
effectiveByKey.set(key, operations);
|
|
228
|
-
emissions.note(contractsOf(operations), key);
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
const scope = container.createScope();
|
|
234
|
-
const frondLog = log.child(frond.name);
|
|
235
|
-
|
|
236
|
-
// `reads:` is what makes a cross-source reader exist here, and the list IS its
|
|
237
|
-
// environment — a source holding none of these is never opened. Registered under
|
|
238
|
-
// the type's own name, which is the key `depKeyOf` already derives for a plain
|
|
239
|
-
// parameter: `constructor(private reads: Reads)` and nothing else to say.
|
|
240
|
-
// Declaring `reads:` with nothing to build the reader is a boot that ignores a
|
|
241
|
-
// clause: the handler asking for `Reads` then dies at its first call, on a
|
|
242
|
-
// container message that names neither the clause nor what is missing.
|
|
243
|
-
if (frond.reads?.length && !options.sourcesFactory) {
|
|
244
|
-
frondLog.warn(
|
|
245
|
-
`[reads] ${frond.reads.join(', ')} — declared in frond.config.ts, but this boot passes no `
|
|
246
|
-
+ '`sourcesFactory`, so no reader is registered and a handler asking for `Reads` will fail '
|
|
247
|
-
+ 'at its first call. Pass one (`@fougere/adapter-duckdb`), or drop the clause.',
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
if (frond.reads?.length && options.sourcesFactory) {
|
|
251
|
-
// Resolved across the WHOLE app, not this frond's own entities: a cross-source
|
|
252
|
-
// query joins entities from different fronds by definition — `Progress` here,
|
|
253
|
-
// `Book` next door — so restricting the list to its own would make it useless.
|
|
254
|
-
// Naming one IS the authorization; that is what the declaration is for.
|
|
255
|
-
const named = frond.reads
|
|
256
|
-
.map((name) => entityByName.get(lowerFirst(name)))
|
|
257
|
-
.filter((entity): entity is NonNullable<typeof entity> => entity !== undefined);
|
|
258
|
-
if (named.length !== frond.reads.length) {
|
|
259
|
-
const missing = frond.reads.filter((name) => !entityByName.has(lowerFirst(name)));
|
|
260
|
-
frondLog.warn(
|
|
261
|
-
`[reads] ${missing.join(', ')} — named in frond.config.ts but scanned nowhere in this app, `
|
|
262
|
-
+ 'so a query naming one would find no table. Check the spelling, or the entity file.',
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
scope.registerValue('Reads', await options.sourcesFactory(named, frond.name));
|
|
266
|
-
frondLog.debug(`cross-source reader over ${named.length} entit(ies)`);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Who owns what, and the rule that makes owning mean something. Before anything is
|
|
270
|
-
// registered, so a bad line is named by this refusal rather than by the container's.
|
|
271
|
-
const owners = ownersOf(frond.providers);
|
|
272
|
-
refuseStorageInUserCode(frond, owners, (entity) => entityByName.has(entity));
|
|
273
|
-
refuseCrudOnOwned(frond, owners);
|
|
274
|
-
|
|
275
|
-
for (const provider of frond.providers) {
|
|
276
|
-
scope.register(nameOf(provider), provider.ctor, { deps: provider.deps });
|
|
277
|
-
}
|
|
278
|
-
// …and again under the port each one extends, so `private payment: Payment`
|
|
279
|
-
// reaches the realization instead of the base class it is declared against.
|
|
280
|
-
// Registered AFTER the loop above so a port key always wins over the base's
|
|
281
|
-
// own registration — same precedence as a declared repository over its default.
|
|
282
|
-
for (const [port, impl] of portBindings(frond.providers, (n) => scope.has(n), options.ports)) {
|
|
283
|
-
scope.register(port, impl.ctor, { deps: impl.deps });
|
|
284
|
-
boundPorts.add(port);
|
|
285
|
-
frondLog.debug(`port ${port} → ${impl.ctor.name}`);
|
|
286
|
-
}
|
|
287
|
-
if (frond.providers.length > 0) {
|
|
288
|
-
frondLog.debug(`${frond.providers.length} provider(s): ${frond.providers.map(nameOf).join(', ')}`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Register Storage for each entity — PascalCase type name (e.g. 'PostStorage')
|
|
292
|
-
// When a handler declares Crud(Entity, Output), scope the storage via .output(Output)
|
|
293
|
-
if (options.storageFactory) {
|
|
294
|
-
const unenforced: string[] = [];
|
|
295
|
-
for (const entity of frond.entities) {
|
|
296
|
-
const key = storageKeyOf(entity.name);
|
|
297
|
-
const source = options.sourceOf?.(entity.name) ?? 'db';
|
|
298
|
-
if (declares(entity.entityClass, 'unique') && options.enforces?.(source, 'unique') === false) {
|
|
299
|
-
unenforced.push(`${entity.name} in '${source}'`);
|
|
300
|
-
}
|
|
301
|
-
const baseStorage = options.storageFactory(entity.entityClass, entity.name);
|
|
302
|
-
|
|
303
|
-
// Check if the default handler (no surface) declares an output override
|
|
304
|
-
const defaultHandler = frond.handlers.find((h) => h.address === entity.name && !h.surface);
|
|
305
|
-
const outputSchema = defaultHandler?.outputOverride ?? (defaultHandler?.ctor as any)?.__output;
|
|
306
|
-
const scoped = outputSchema && outputSchema !== entity.entityClass
|
|
307
|
-
? baseStorage.output(outputSchema)
|
|
308
|
-
: baseStorage;
|
|
309
|
-
|
|
310
|
-
// Storage is a way out like the client surface — see `StorageGuard`.
|
|
311
|
-
const guarded = new StorageGuard(entity.entityClass.getFields(), entity.name).guard(scoped);
|
|
312
|
-
scope.registerValue(key, guarded);
|
|
313
|
-
|
|
314
|
-
// The default repository IS the guarded port — it already answers every gesture a
|
|
315
|
-
// declared one forwards, so the two forms have the same shape and a handler reads
|
|
316
|
-
// `repo.list()` either way. The wrapper that used to sit here (`{ storage: guarded }`)
|
|
317
|
-
// existed to make `repo.storage` true in both, back when `.storage` was the way in.
|
|
318
|
-
//
|
|
319
|
-
// Not registered for an OWNED entity: an aggregate's members are reached through it
|
|
320
|
-
// and nowhere else, and the default would be a second door under a name a handler
|
|
321
|
-
// can spell. Every member is skipped, not just the one the key is named after —
|
|
322
|
-
// that asymmetry was the whole hole.
|
|
323
|
-
const repoKey = repositoryKeyOf(entity.name);
|
|
324
|
-
const owner = owners.get(entity.name);
|
|
325
|
-
if (owner) {
|
|
326
|
-
frondLog.debug(`${entity.name} — owned by ${owner}, no default repository`);
|
|
327
|
-
} else if (!scope.has(repoKey)) {
|
|
328
|
-
scope.registerValue(repoKey, guarded);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (frond.entities.length > 0) {
|
|
332
|
-
frondLog.debug(`${frond.entities.length} entity storage(s): ${frond.entities.map((e) => e.name).join(', ')}`);
|
|
333
|
-
}
|
|
334
|
-
// The judge refuses a duplicate it can SEE — the row already stored. Two writes arriving
|
|
335
|
-
// together see the same absence, and only the place they land can refuse the second.
|
|
336
|
-
if (unenforced.length > 0) {
|
|
337
|
-
frondLog.warn(
|
|
338
|
-
`unique declared, and the source does not enforce it: ${unenforced.join(', ')} — `
|
|
339
|
-
+ 'two concurrent writes can both pass',
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Frames, after the ORMs and before anything that may ask for one. A frame is read
|
|
345
|
-
// from the same `deps` every other port is read from — asking for it IS declaring it,
|
|
346
|
-
// so nothing is registered for a frame nobody wants.
|
|
347
|
-
registerFrames(
|
|
348
|
-
scope,
|
|
349
|
-
[...frond.handlers, ...frond.providers, ...frond.presenters, ...frond.collectors].flatMap((d) => d.deps),
|
|
350
|
-
frond.providers,
|
|
351
|
-
{
|
|
352
|
-
entityByName,
|
|
353
|
-
frondOf,
|
|
354
|
-
hostedHere: (name) => !(options.remotes && name in options.remotes),
|
|
355
|
-
storageFactory: options.storageFactory,
|
|
356
|
-
sourceOf: options.sourceOf,
|
|
357
|
-
transacts: options.transacts,
|
|
358
|
-
transacted: options.transacted,
|
|
359
|
-
log: frondLog,
|
|
360
|
-
},
|
|
361
|
-
);
|
|
362
|
-
|
|
363
|
-
// Register presenters in scope — PascalCase type name (e.g. 'PostPresenter')
|
|
364
|
-
const presenterMap = new Map(frond.presenters.map((p) => [p.entityName, p]));
|
|
365
|
-
for (const presenter of frond.presenters) {
|
|
366
|
-
scope.register(presenterKeyOf(presenter.entityName), presenter.ctor, { deps: presenter.deps });
|
|
367
|
-
}
|
|
368
|
-
if (frond.presenters.length > 0) {
|
|
369
|
-
frondLog.debug(`${frond.presenters.length} presenter(s): ${frond.presenters.map((p) => p.entityName).join(', ')}`);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Register collectors in scope — PascalCase type name (e.g. 'UserCollector')
|
|
373
|
-
const collectorTypeNames = new Set(frond.collectors.map((c) => c.typeName));
|
|
374
|
-
for (const collector of frond.collectors) {
|
|
375
|
-
const key = collectorKeyOf(collector.typeName);
|
|
376
|
-
scope.register(key, collector.ctor, { deps: collector.deps });
|
|
377
|
-
}
|
|
378
|
-
if (frond.collectors.length > 0) {
|
|
379
|
-
frondLog.debug(`${frond.collectors.length} collector(s): ${frond.collectors.map((c) => c.typeName).join(', ')}`);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Build handler facades → registered in ROOT container (public contract)
|
|
383
|
-
const defaultHandlers = frond.handlers.filter((h) => !h.surface);
|
|
384
|
-
const surfaceHandlers = frond.handlers.filter((h) => h.surface);
|
|
385
|
-
const defaultHandlerMap = new Map(defaultHandlers.map((h) => [h.address, h]));
|
|
386
|
-
|
|
387
|
-
/** Build the door of a handler and register it under the audience it serves. */
|
|
388
|
-
const buildFacade = (
|
|
389
|
-
entity: EntityEntry | undefined,
|
|
390
|
-
handler: HandlerEntry,
|
|
391
|
-
targetScope: Container,
|
|
392
|
-
facadeKey: string,
|
|
393
|
-
) => {
|
|
394
|
-
if (inheritsCrud(handler.ctor) && !entity) {
|
|
395
|
-
// An installed Crud subject may be absent from the local scan.
|
|
396
|
-
frondLog.debug(`${handler.ctor.name} extends Crud() and no scanned entity is named `
|
|
397
|
-
+ `'${subjectOf(handler.ctor, handler.address)}' — installed entity, or a missing `
|
|
398
|
-
+ `one: no storage will be injected`);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const facade = new HandlerFacade(handler, targetScope, {
|
|
402
|
-
key: facadeKey,
|
|
403
|
-
frond: frond.name,
|
|
404
|
-
handlers: frond.handlers,
|
|
405
|
-
operations: operationModel.forHandler(handler),
|
|
406
|
-
collectors: collectorTypeNames,
|
|
407
|
-
presenter: presenterMap.get(handler.address),
|
|
408
|
-
presenterScope: scope,
|
|
409
|
-
middlewares: () => getMiddlewares(handler.address),
|
|
410
|
-
});
|
|
411
|
-
// Emissions use the same contracts and execution path as direct calls.
|
|
412
|
-
emissions.note(facade.contracts, facadeKey);
|
|
413
|
-
// The terms alongside the door, under the same audience — a surface that serves
|
|
414
|
-
// fewer ops describes fewer ops.
|
|
415
|
-
container.registerValue(contractsKeyOf(handler.address, handler.surface), facade.contracts);
|
|
416
|
-
effectiveByKey.set(facadeKey, facade.effectiveOperations);
|
|
417
|
-
|
|
418
|
-
const surfaces = new Set<string | undefined>([handler.surface]);
|
|
419
|
-
if (!handler.surface) {
|
|
420
|
-
for (const [surface, names] of Object.entries(frond.surfaces ?? {})) {
|
|
421
|
-
const isDeclared = names.some((name) =>
|
|
422
|
-
name.toLowerCase() === handler.address.toLowerCase());
|
|
423
|
-
const hasOwnDoor = surfaceHandlers.some((candidate) =>
|
|
424
|
-
candidate.surface === surface && candidate.address === handler.address);
|
|
425
|
-
if (isDeclared && !hasOwnDoor) surfaces.add(surface);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
for (const operation of facade.contracts.keys()) {
|
|
430
|
-
for (const surface of surfaces) {
|
|
431
|
-
const address = new RouteAddress({
|
|
432
|
-
entity: handler.address,
|
|
433
|
-
operation,
|
|
434
|
-
...(surface !== undefined ? { surface } : {}),
|
|
435
|
-
});
|
|
436
|
-
routeRegistry.register(new OperationRoute(
|
|
437
|
-
'local',
|
|
438
|
-
address,
|
|
439
|
-
(call) => facade.execute(operation, call.invocation),
|
|
440
|
-
));
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
const operations = facadeOperations(
|
|
445
|
-
handler.surface ? localDispatcher : dispatcher,
|
|
446
|
-
handler.address,
|
|
447
|
-
routeRegistry.operationNames(handler.address, handler.surface),
|
|
448
|
-
handler.surface,
|
|
449
|
-
);
|
|
450
|
-
container.registerValue(facadeKey, operations);
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
// A presenter is about an entity — computed fields sit on a shape — so this walks
|
|
454
|
-
// entities. Exposing the instance lazily; the bridge resolves it on first access.
|
|
455
|
-
for (const entity of frond.entities) {
|
|
456
|
-
if (!presenterMap.has(entity.name)) continue;
|
|
457
|
-
const presenterKey = presenterKeyOf(entity.name);
|
|
458
|
-
let presenterInstance: any;
|
|
459
|
-
container.registerValue(presenterKey, new Proxy({} as any, {
|
|
460
|
-
get(_target, prop) {
|
|
461
|
-
if (!presenterInstance) presenterInstance = scope.resolve(presenterKey);
|
|
462
|
-
return presenterInstance[prop];
|
|
463
|
-
},
|
|
464
|
-
}));
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// A facade is about a handler, so this walks HANDLERS. It walked entities before,
|
|
468
|
-
// which made an entity a precondition for being callable at all: a handler naming
|
|
469
|
-
// none was scanned, then never built, and nothing said so.
|
|
470
|
-
for (const handler of defaultHandlers) {
|
|
471
|
-
// Two ways to know the subject, and the explicit one wins: `Crud(Item)` names the
|
|
472
|
-
// entity it was built on, whatever the handler is called. Otherwise the handler's
|
|
473
|
-
// own name is the only thing pointing at one — and pointing at nothing is legal.
|
|
474
|
-
//
|
|
475
|
-
// By NAME, not by identity: the scanner loads an entity through its own loader and
|
|
476
|
-
// the handler imports it through the runtime's, so the same class arrives as two
|
|
477
|
-
// objects. `===` compares module instances, which is not the question being asked.
|
|
478
|
-
const crudTarget = targetOf(handler.ctor);
|
|
479
|
-
const subject = crudTarget?.name ? lowerFirst(crudTarget.name) : handler.address;
|
|
480
|
-
const entity = frond.entities.find((e) => e.name === subject);
|
|
481
|
-
const facadeKey = facadeKeyOf(handler.address);
|
|
482
|
-
buildFacade(entity, handler, scope, facadeKey);
|
|
483
|
-
frondLog.debug(`${facadeKey} [${Object.keys(container.resolve(facadeKey) as any).join(', ')}]`
|
|
484
|
-
+ (entity ? '' : ' — no entity of that name: no storage, no projection, no presenter'));
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// The dual, and it stays: a shape that declares no operation answers nothing. Said
|
|
488
|
-
// once per entity rather than deduced from a silence.
|
|
489
|
-
for (const entity of frond.entities) {
|
|
490
|
-
if (!defaultHandlerMap.has(entity.name)) {
|
|
491
|
-
frondLog.debug(`${entity.name} — entity only, no handler: exposes nothing`);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Surface handlers — create sub-scope per surface handler with scoped storage
|
|
496
|
-
//
|
|
497
|
-
// Pointing at nothing is legal HERE TOO. This loop used to `continue` when no entity
|
|
498
|
-
// carried the handler's name, so `handlers/public/SearchHandler.ts` with no `Search`
|
|
499
|
-
// entity got no door at all and no line saying why — while the very same handler at
|
|
500
|
-
// the default surface is built and logged. One rule, both surfaces.
|
|
501
|
-
for (const handler of surfaceHandlers) {
|
|
502
|
-
const entity = frond.entities.find((e) => e.name === handler.address);
|
|
503
|
-
const surfaceScope = scope.createScope();
|
|
504
|
-
|
|
505
|
-
// Register scoped storage if output override differs from entity — under the REPOSITORY
|
|
506
|
-
// key, which is what a Crud handler asks for, and under the port's own for a holder
|
|
507
|
-
// that legitimately names it. Registering only the latter left a named surface with
|
|
508
|
-
// no door at all once the façade stopped spelling the storage.
|
|
509
|
-
if (entity && options.storageFactory) {
|
|
510
|
-
const baseStorage = options.storageFactory(entity.entityClass, entity.name);
|
|
511
|
-
const outputSchema = handler.outputOverride ?? (handler.ctor as any).__output;
|
|
512
|
-
const scoped = outputSchema && outputSchema !== entity.entityClass
|
|
513
|
-
? baseStorage.output(outputSchema)
|
|
514
|
-
: baseStorage;
|
|
515
|
-
// The view is handed over so a filter on a field this door hides is SAID. The
|
|
516
|
-
// guard holds no logger — a warning is the boot's to voice, as a seed's report is.
|
|
517
|
-
const guarded = new StorageGuard(entity.entityClass.getFields(), entity.name, {
|
|
518
|
-
...(outputSchema && outputSchema !== entity.entityClass
|
|
519
|
-
? { view: (outputSchema as { getFields(): Fields }).getFields() }
|
|
520
|
-
: {}),
|
|
521
|
-
outOfView: (message) => frondLog.warn(message),
|
|
522
|
-
}).guard(scoped);
|
|
523
|
-
surfaceScope.registerValue(storageKeyOf(entity.name), guarded);
|
|
524
|
-
surfaceScope.registerValue(repositoryKeyOf(entity.name), guarded);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const facadeKey = facadeKeyOf(handler.address, handler.surface);
|
|
528
|
-
buildFacade(entity, handler, surfaceScope, facadeKey);
|
|
529
|
-
frondLog.debug(`${facadeKey} [${Object.keys(container.resolve(facadeKey) as any).join(', ')}]`
|
|
530
|
-
+ (entity ? '' : ' — no entity of that name: no storage, no projection, no presenter'));
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// A named surface is closed, so what it contains is a fact worth stating.
|
|
534
|
-
// Saying it at boot is the difference between a rule and a rule you can
|
|
535
|
-
// check: an entity you meant to serve and never wrote a handler for is
|
|
536
|
-
// absent HERE, in one line, instead of being discovered missing later.
|
|
537
|
-
const surfaceNames = [...new Set(surfaceHandlers.map((h) => h.surface as string))].sort();
|
|
538
|
-
for (const surfaceName of surfaceNames) {
|
|
539
|
-
const served = surfaceHandlers
|
|
540
|
-
.filter((h) => h.surface === surfaceName)
|
|
541
|
-
.map((h) => h.address)
|
|
542
|
-
.sort();
|
|
543
|
-
const absent = frond.entities.map((e) => e.name).filter((n) => !served.includes(n));
|
|
544
|
-
frondLog.info(
|
|
545
|
-
`surface '${surfaceName}' — ${served.length} entit${served.length === 1 ? 'y' : 'ies'}: ${served.join(', ')}` +
|
|
546
|
-
(absent.length > 0 ? ` (not served: ${absent.join(', ')})` : ''),
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
container.registerValue(`frond:${frond.name}`, scope);
|
|
551
|
-
frondLog.info(`registered — ${frond.entities.length} entities, ${frond.handlers.length} handlers, ${frond.seeds.length} seeds`);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// A `ports:` key that matched no port anywhere reads as a choice that was made, and
|
|
555
|
-
// was not. Said once, at the end, because the entry is app-wide while a port is a
|
|
556
|
-
// frond's — no single frond can tell whether a key is a typo or a neighbour's.
|
|
557
|
-
const unused = Object.keys(options.ports ?? {}).filter((port) => !boundPorts.has(port));
|
|
558
|
-
if (unused.length > 0) {
|
|
559
|
-
log.warn(
|
|
560
|
-
`[ports] ${unused.join(', ')} — named in fougere.config.ts, but no scanned class extends `
|
|
561
|
-
+ 'them, so nothing was chosen. Check the spelling, or drop the entry.',
|
|
562
|
-
);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// Once every door exists: what is announced here and what is listened to are both known.
|
|
566
|
-
emissions.register();
|
|
567
|
-
|
|
568
|
-
/** The last resort, held by the container so every resolution path shares it. */
|
|
569
|
-
container.setFallback?.((name) => {
|
|
570
|
-
if (!remoteRouter) return undefined;
|
|
571
|
-
if (!name.endsWith('Handler') || name.includes(':')) return undefined;
|
|
572
|
-
// Façade-shaped stand-in; routing happens lazily at the first call. Through
|
|
573
|
-
// `lowerFirst` because a DEPENDENCY names the type as written — `ProductHandler`,
|
|
574
|
-
// PascalCase — while a card declares `product`, so the raw strip asked the router for
|
|
575
|
-
// 'Product' and every by-type dependency on a remote handler answered NOT_FOUND.
|
|
576
|
-
return facadeOperations(
|
|
577
|
-
dispatcher,
|
|
578
|
-
lowerFirst(name.replace(/Handler$/, '')),
|
|
579
|
-
);
|
|
580
|
-
});
|
|
87
|
+
/** The app once it exists — a refusal before that releases the two levels that do. */
|
|
88
|
+
let built: App | undefined;
|
|
581
89
|
|
|
582
90
|
/**
|
|
583
91
|
* Everything this app holds, let go in reverse of how it was taken: what an extension took on
|
|
@@ -589,7 +97,12 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
589
97
|
// there and broken here, a refusing extension took the container and the connection
|
|
590
98
|
// down with it, which is the leak this gesture exists to prevent.
|
|
591
99
|
const refused: unknown[] = [];
|
|
592
|
-
|
|
100
|
+
const levels = [
|
|
101
|
+
...(built ? [() => appLifecycle.down(built!)] : []),
|
|
102
|
+
() => container.dispose(),
|
|
103
|
+
() => options.onDispose?.(),
|
|
104
|
+
];
|
|
105
|
+
for (const level of levels) {
|
|
593
106
|
try {
|
|
594
107
|
await level();
|
|
595
108
|
} catch (error) {
|
|
@@ -604,200 +117,401 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
604
117
|
}
|
|
605
118
|
};
|
|
606
119
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
throw
|
|
120
|
+
try {
|
|
121
|
+
// Boot chatter is debug by default; a host (e.g. the CLI) can quiet it.
|
|
122
|
+
const log = new Logger('boot:app');
|
|
123
|
+
|
|
124
|
+
// Builtins — registered under class name (PascalCase) for type-based DI.
|
|
125
|
+
// No level here and none anywhere: a logger consults `setLogLevel`'s value at each
|
|
126
|
+
// emission, so this instance survives a level change and so does every handler that
|
|
127
|
+
// was handed it. A frond declaring `class X extends Logger` takes this key over,
|
|
128
|
+
// like any other port.
|
|
129
|
+
container.registerValue('Logger', new Logger('app'));
|
|
130
|
+
container.register('Config', Config, { lifetime: 'singleton' });
|
|
131
|
+
log.debug('builtins registered (Logger, Config)');
|
|
132
|
+
|
|
133
|
+
// What this app hosts is HANDED IN — stated, scanned, or both (`hostedBy`). Producing
|
|
134
|
+
// it may read a disk; consuming it never does, which is the whole reason this file names
|
|
135
|
+
// no builtin and a Worker can run what it builds.
|
|
136
|
+
const scanStart = performance.now();
|
|
137
|
+
const { fronds, diagnostics } = await hostedBy(options);
|
|
138
|
+
// An app that states nothing AND scans nothing is a mistake — unless something else it
|
|
139
|
+
// declares brings its own entities, which an auth provider does. Refused here and not in
|
|
140
|
+
// `hostedBy`, which is handed the frond sources and cannot see the rest of the app. The
|
|
141
|
+
// condition is the KEYS, not the count: a scan that found nothing is an ordinary answer.
|
|
142
|
+
if (!options.fronds && !options.scan && !options.auth) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
'createApp needs `fronds:` (what this app states) or `scan:` (what a scanner found). '
|
|
145
|
+
+ 'Neither was given, and nothing else declares entities of its own.',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const operationModel = resolveEffectiveOperations(fronds, {
|
|
149
|
+
diagnostics,
|
|
150
|
+
remotes: options.remotes,
|
|
151
|
+
adapters: options.adapters,
|
|
152
|
+
});
|
|
153
|
+
const scanMs = (performance.now() - scanStart).toFixed(0);
|
|
154
|
+
const blocking = diagnostics.filter((d) => d.severity === 'blocking');
|
|
155
|
+
log.info(`read ${fronds.length} frond(s) in ${scanMs}ms`
|
|
156
|
+
+ (diagnostics.length ? ` — ${diagnostics.length} thing(s) the scan could not do` : ''));
|
|
157
|
+
|
|
158
|
+
/** Say what could not be read, at the one line everyone already watches. */
|
|
159
|
+
for (const d of blocking) log.error(`[${d.code}] ${d.message}`, d.cause);
|
|
160
|
+
for (const d of diagnostics) if (d.severity === 'warning') log.warn(`[${d.code}] ${d.message}`);
|
|
161
|
+
|
|
162
|
+
/** An ambiguous convention is not a partial scan. */
|
|
163
|
+
const invalidOperations = operationModel.resolutionDiagnostics
|
|
164
|
+
.filter((diagnostic) => diagnostic.severity === 'blocking');
|
|
165
|
+
if (invalidOperations.length > 0) {
|
|
166
|
+
const details = invalidOperations.map((d) =>
|
|
167
|
+
` [${d.code}]${d.subject ? ` ${d.subject}` : ''}\n ${d.message}\n ${d.filePath}`,
|
|
168
|
+
);
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Fougere boot refused: ${invalidOperations.length} unresolved operation contract(s):\n`
|
|
171
|
+
+ details.join('\n'),
|
|
172
|
+
);
|
|
631
173
|
}
|
|
632
|
-
};
|
|
633
174
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
+ `Call its operations through the façade instead.`,
|
|
645
|
-
);
|
|
175
|
+
// Auth runtime — built once from the lazy AuthConfig produced by a provider factory
|
|
176
|
+
// (e.g. betterAuth({...})) in fougere.config.ts. The provider receives our db +
|
|
177
|
+
// storageFactory so all auth writes flow through Storage.
|
|
178
|
+
let authRuntime: AuthRuntime | undefined;
|
|
179
|
+
if (options.auth) {
|
|
180
|
+
if (!options.storageFactory) {
|
|
181
|
+
throw new Error('createApp: `auth` is set but `storageFactory` is missing — auth providers need it to back their adapter.');
|
|
182
|
+
}
|
|
183
|
+
if (options.db === undefined) {
|
|
184
|
+
throw new Error('createApp: `auth` is set but `db` is missing — pass the storage handle through CreateAppOptions.db.');
|
|
646
185
|
}
|
|
647
|
-
|
|
186
|
+
log.info('initializing auth runtime');
|
|
187
|
+
authRuntime = await options.auth.create({
|
|
188
|
+
db: options.db,
|
|
189
|
+
storageFactory: options.storageFactory,
|
|
190
|
+
});
|
|
191
|
+
log.info(`auth ready — mounted at ${authRuntime.basePath}`);
|
|
648
192
|
}
|
|
649
|
-
throw new Error(notLoaded(entity));
|
|
650
|
-
};
|
|
651
193
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
194
|
+
// Remote routing — validated at boot: declaring remotes without a transport is a config error.
|
|
195
|
+
// A remote declaration wins over local presence: `remotes: { blog: url }` IS
|
|
196
|
+
// the topology statement — the frond's code may sit in fronds/**, it runs elsewhere.
|
|
197
|
+
const declaredRemotes = Object.entries(options.remotes ?? {});
|
|
198
|
+
if (declaredRemotes.length > 0 && !options.remoteTransport) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
'createApp: `remotes` is declared but `remoteTransport` is missing — pass one (e.g. from @fougere/transport-http).',
|
|
201
|
+
);
|
|
659
202
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
/** THE membership rule, stated once — every projection reads this and nothing else. */
|
|
663
|
-
const facadeFor = (entity: string, surface?: string): Record<string, Function> | undefined => {
|
|
664
|
-
if (!surface) return facadeAt(facadeKeyOf(entity), true);
|
|
665
|
-
|
|
666
|
-
const own = facadeAt(facadeKeyOf(entity, surface), false);
|
|
667
|
-
const declared = fronds.owner(entity)?.surfaces?.[surface];
|
|
668
|
-
if (!declared) return own;
|
|
669
|
-
if (!declared.some((n) => n.toLowerCase() === entity.toLowerCase())) return undefined;
|
|
670
|
-
if (own) return own;
|
|
671
|
-
|
|
672
|
-
const fallback = facadeAt(facadeKeyOf(entity), false);
|
|
673
|
-
return fallback
|
|
674
|
-
? facadeOperations(
|
|
675
|
-
localDispatcher,
|
|
676
|
-
entity,
|
|
677
|
-
routeRegistry.operationNames(entity, surface),
|
|
678
|
-
surface,
|
|
679
|
-
)
|
|
203
|
+
const remoteRouter = declaredRemotes.length > 0 && options.remoteTransport
|
|
204
|
+
? createRemoteRouter(Object.fromEntries(declaredRemotes), options.remoteTransport)
|
|
680
205
|
: undefined;
|
|
681
|
-
};
|
|
682
206
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
207
|
+
// What is running on this app — counted at the one door every caller goes through,
|
|
208
|
+
// so releasing it can wait for the work instead of pulling the floor out.
|
|
209
|
+
const inflight = new InFlight();
|
|
210
|
+
|
|
211
|
+
// Middleware storage — read at call time, not at boot time
|
|
212
|
+
const globalMiddlewares: AppMiddleware[] = [];
|
|
213
|
+
const scopedMiddlewares = new Map<string, AppMiddleware[]>();
|
|
214
|
+
/** What this app took on beyond its fronds. Its `up` is the last thing the boot does. */
|
|
215
|
+
const routeRegistry = new RouteRegistry();
|
|
216
|
+
const dispatchLifecycle = new DispatchLifecycle(
|
|
217
|
+
options.dispatchObservers,
|
|
218
|
+
(error, event) => log.error(
|
|
219
|
+
`[dispatch-observer] ${event.stage} ${event.call.address.toString()}`,
|
|
220
|
+
error,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
const dispatcher = new Dispatcher(routeRegistry, inflight, dispatchLifecycle);
|
|
224
|
+
const localDispatcher = new Dispatcher(
|
|
225
|
+
routeRegistry,
|
|
226
|
+
inflight,
|
|
227
|
+
dispatchLifecycle,
|
|
228
|
+
new LocalRoutePolicy((surface) => fronds.servedNames(surface)),
|
|
229
|
+
);
|
|
693
230
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
231
|
+
function getMiddlewares(entity: string): AppMiddleware[] {
|
|
232
|
+
const scoped = scopedMiddlewares.get(entity) ?? [];
|
|
233
|
+
return [...globalMiddlewares, ...scoped];
|
|
234
|
+
}
|
|
697
235
|
|
|
698
|
-
|
|
699
|
-
const declared = fronds.owner(entity)?.surfaces?.[surface];
|
|
700
|
-
if (!declared) return own;
|
|
701
|
-
return declared.some((name) => name.toLowerCase() === entity.toLowerCase())
|
|
702
|
-
? (own ?? effectiveByKey.get(facadeKeyOf(entity)))
|
|
703
|
-
: undefined;
|
|
704
|
-
};
|
|
236
|
+
assertOneOwnerPerKey(fronds, options.remotes);
|
|
705
237
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
|
|
238
|
+
// Every entity of every frond, by name — so a fact can be validated where it LANDS, and
|
|
239
|
+
// so a `reads:` clause can name a neighbour's.
|
|
240
|
+
const entityByName = fronds.schemas();
|
|
241
|
+
// Which frond holds an entity — what turns "a member is remote" into a refusal that
|
|
242
|
+
// names the frond rather than the entity, since `remotes:` is declared per frond.
|
|
243
|
+
const frondOf = new Map(fronds.flatMap((f) => f.entities.map((e) => [e.name, f.name] as const)));
|
|
244
|
+
const emissions = new Emissions(fronds, entityByName, container, log, options.onEmit);
|
|
245
|
+
/** Canonical operation tables, indexed by the same audience key as their facades. */
|
|
246
|
+
const effectiveByKey = new Map<string, EffectiveOperationsMap>();
|
|
713
247
|
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
248
|
+
const contractsOf = (operations: EffectiveOperationsMap): OperationsMap => new Map(
|
|
249
|
+
[...operations].map(([name, operation]) => [name, operation as OperationContract] as const),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Every port an implementation was bound to, so a `ports:` entry that named none
|
|
253
|
+
// can say so rather than look obeyed.
|
|
254
|
+
const boundPorts = new Set<string>();
|
|
255
|
+
|
|
256
|
+
// Register frond scopes
|
|
257
|
+
// What every frond is installed into, and reads while it is: one container, one route
|
|
258
|
+
// table, one emission list — so what a frond serves is there for the next one to find.
|
|
259
|
+
const assembly: Assembly = {
|
|
260
|
+
container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
|
|
261
|
+
boundPorts, operationModel, entityByName, frondOf, contractsOf, getMiddlewares,
|
|
262
|
+
log, options,
|
|
263
|
+
};
|
|
264
|
+
for (const frond of fronds) await installFrond(frond, assembly);
|
|
265
|
+
|
|
266
|
+
// A `ports:` key that matched no port anywhere reads as a choice that was made, and
|
|
267
|
+
// was not. Said once, at the end, because the entry is app-wide while a port is a
|
|
268
|
+
// frond's — no single frond can tell whether a key is a typo or a neighbour's.
|
|
269
|
+
const unused = Object.keys(options.ports ?? {}).filter((port) => !boundPorts.has(port));
|
|
270
|
+
if (unused.length > 0) {
|
|
271
|
+
log.warn(
|
|
272
|
+
`[ports] ${unused.join(', ')} — named in fougere.config.ts, but no scanned class extends `
|
|
273
|
+
+ 'them, so nothing was chosen. Check the spelling, or drop the entry.',
|
|
274
|
+
);
|
|
719
275
|
}
|
|
720
|
-
};
|
|
721
276
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
277
|
+
// Once every door exists: what is announced here and what is listened to are both known.
|
|
278
|
+
emissions.register();
|
|
279
|
+
|
|
280
|
+
/** The last resort, held by the container so every resolution path shares it. */
|
|
281
|
+
container.setFallback?.((name) => {
|
|
282
|
+
if (!remoteRouter) return undefined;
|
|
283
|
+
if (!name.endsWith('Handler') || name.includes(':')) return undefined;
|
|
284
|
+
// Façade-shaped stand-in; routing happens lazily at the first call. Through
|
|
285
|
+
// `lowerFirst` because a DEPENDENCY names the type as written — `ProductHandler`,
|
|
286
|
+
// PascalCase — while a card declares `product`, so the raw strip asked the router for
|
|
287
|
+
// 'Product' and every by-type dependency on a remote handler answered NOT_FOUND.
|
|
288
|
+
return facadeOperations(
|
|
289
|
+
dispatcher,
|
|
290
|
+
lowerFirst(name.replace(/Handler$/, '')),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
726
293
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
294
|
+
|
|
295
|
+
/** Stop taking calls, and resolve once the ones already running are done. */
|
|
296
|
+
const drain = async (timeoutMs?: number): Promise<void> => {
|
|
297
|
+
inflight.close();
|
|
298
|
+
if (timeoutMs === undefined) return inflight.whenIdle();
|
|
299
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
300
|
+
await Promise.race([
|
|
301
|
+
inflight.whenIdle().then(() => clearTimeout(timer)),
|
|
302
|
+
new Promise<never>((_, reject) => {
|
|
303
|
+
timer = setTimeout(
|
|
304
|
+
() => reject(new Error(`[drain] ${inflight.count} call(s) still running after ${timeoutMs}ms`)),
|
|
305
|
+
timeoutMs,
|
|
306
|
+
);
|
|
307
|
+
}),
|
|
308
|
+
]);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const resolve = <T>(name: string): T => {
|
|
312
|
+
try {
|
|
313
|
+
return container.resolve<T>(name);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (name.endsWith('Handler') && !name.includes(':') && !remoteRouter) {
|
|
316
|
+
throw new Error(notLoaded(name.replace(/Handler$/, '')));
|
|
317
|
+
}
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const schemaFor = async (entity: string): Promise<SchemaView> => {
|
|
323
|
+
const found = fronds.entity(entity);
|
|
324
|
+
if (found) return found.entityClass;
|
|
325
|
+
if (remoteRouter) {
|
|
326
|
+
const route = await remoteRouter.route(entity);
|
|
327
|
+
// A remote door that stores nothing publishes ops and no shape. Saying so beats
|
|
328
|
+
// handing back an empty schema, which would validate every input it was given.
|
|
329
|
+
if (!route.schema) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`'${entity}' is served by frond '${route.frond}' but stores no rows, so it has no schema. `
|
|
332
|
+
+ `Call its operations through the façade instead.`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return route.schema;
|
|
336
|
+
}
|
|
337
|
+
throw new Error(notLoaded(entity));
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const facadeAt = (key: string, topology: boolean): Record<string, Function> | undefined => {
|
|
341
|
+
try {
|
|
342
|
+
return topology
|
|
343
|
+
? resolve<Record<string, Function>>(key)
|
|
344
|
+
: container.resolve<Record<string, Function>>(key);
|
|
345
|
+
} catch {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** Said once per pair, so a door that registers in a loop says it once. */
|
|
351
|
+
const saidAbsent = new Set<string>();
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A surface is declared in the frond that serves it. When that frond runs in another
|
|
355
|
+
* process, this one never asked for its doors, and answering 'no' is the only thing a
|
|
356
|
+
* synchronous rule can do — so it says so rather than registering nothing in silence.
|
|
357
|
+
*/
|
|
358
|
+
const sayNoSurfaceAcross = (entity: string, surface: string): void => {
|
|
359
|
+
if (!remoteRouter || saidAbsent.has(`${surface}:${entity}`)) return;
|
|
360
|
+
saidAbsent.add(`${surface}:${entity}`);
|
|
361
|
+
log.warn(
|
|
362
|
+
`surface '${surface}' serves nothing for '${entity}' — the frond that declares it runs `
|
|
363
|
+
+ 'elsewhere, and a remote is asked for its doors at the first call, not at boot. '
|
|
364
|
+
+ 'The default door answers.',
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** THE membership rule, stated once — every projection reads this and nothing else. */
|
|
369
|
+
const facadeFor = (entity: string, surface?: string): Record<string, Function> | undefined => {
|
|
370
|
+
if (!surface) return facadeAt(facadeKeyOf(entity), true);
|
|
371
|
+
|
|
372
|
+
const own = facadeAt(facadeKeyOf(entity, surface), false);
|
|
373
|
+
const owner = fronds.owner(entity);
|
|
374
|
+
if (!owner) {
|
|
375
|
+
sayNoSurfaceAcross(entity, surface);
|
|
376
|
+
return own;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const declared = owner.surfaces?.[surface];
|
|
380
|
+
if (!declared) return own;
|
|
381
|
+
if (!declared.some((n) => n.toLowerCase() === entity.toLowerCase())) return undefined;
|
|
382
|
+
if (own) return own;
|
|
383
|
+
|
|
384
|
+
const fallback = facadeAt(facadeKeyOf(entity), false);
|
|
385
|
+
return fallback
|
|
386
|
+
? facadeOperations(
|
|
387
|
+
localDispatcher,
|
|
388
|
+
entity,
|
|
389
|
+
routeRegistry.operationNames(entity, surface),
|
|
390
|
+
surface,
|
|
391
|
+
)
|
|
392
|
+
: undefined;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
if (remoteRouter) {
|
|
396
|
+
const remoteFacades = new Map<string, Record<string, Function>>();
|
|
397
|
+
routeRegistry.addResolver(remoteRoutes((entity) => {
|
|
398
|
+
const known = remoteFacades.get(entity);
|
|
399
|
+
if (known) return known;
|
|
400
|
+
const facade = createRemoteFacade(entity, remoteRouter, getMiddlewares);
|
|
401
|
+
remoteFacades.set(entity, facade);
|
|
402
|
+
return facade;
|
|
403
|
+
}));
|
|
731
404
|
}
|
|
732
|
-
};
|
|
733
405
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
// Refused rather than replaced: two declarations of one name would make the answer
|
|
760
|
-
// depend on wiring order, and `discover` is in here precisely so it cannot be taken.
|
|
761
|
-
const address = new RouteAddress({ entity: 'rpc', operation: op });
|
|
762
|
-
if (routeRegistry.find(address)) {
|
|
763
|
-
throw new Error(`rpc operation '${op}' is already served; a second declaration would depend on wiring order`);
|
|
406
|
+
/** The terms beside a door, with the exact same named-surface fallback rule. */
|
|
407
|
+
const operationsFor = (entity: string, surface?: string): EffectiveOperationsMap | undefined => {
|
|
408
|
+
if (!surface) return effectiveByKey.get(facadeKeyOf(entity));
|
|
409
|
+
|
|
410
|
+
const own = effectiveByKey.get(facadeKeyOf(entity, surface));
|
|
411
|
+
const declared = fronds.owner(entity)?.surfaces?.[surface];
|
|
412
|
+
if (!declared) return own;
|
|
413
|
+
return declared.some((name) => name.toLowerCase() === entity.toLowerCase())
|
|
414
|
+
? (own ?? effectiveByKey.get(facadeKeyOf(entity)))
|
|
415
|
+
: undefined;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The storage an entity is backed by — the dual of `facadeFor`, which serves its client-facing
|
|
420
|
+
* door.
|
|
421
|
+
*/
|
|
422
|
+
const storageFor = (entity: string): unknown | undefined => {
|
|
423
|
+
const owner = fronds.owner(entity);
|
|
424
|
+
if (!owner) return undefined;
|
|
425
|
+
|
|
426
|
+
const key = storageKeyOf(entity);
|
|
427
|
+
try {
|
|
428
|
+
return container.resolve<Container>(`frond:${owner.name}`).resolve(key);
|
|
429
|
+
} catch {
|
|
430
|
+
return undefined;
|
|
764
431
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
)
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
if (typeof args[0] === 'string') {
|
|
777
|
-
const [entity, mw] = args as [string, AppMiddleware];
|
|
778
|
-
const list = scopedMiddlewares.get(entity) ?? [];
|
|
779
|
-
list.push(mw);
|
|
780
|
-
scopedMiddlewares.set(entity, list);
|
|
781
|
-
} else {
|
|
782
|
-
globalMiddlewares.push(args[0] as AppMiddleware);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/** The presenter of an entity, resolved through its owning frond's scope. */
|
|
435
|
+
const presenterFor = (entity: string): unknown | undefined => {
|
|
436
|
+
const owner = fronds.owner(entity);
|
|
437
|
+
if (!owner) return undefined;
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
return container.resolve<Container>(`frond:${owner.name}`).resolve(presenterKeyOf(entity));
|
|
441
|
+
} catch {
|
|
442
|
+
return undefined;
|
|
783
443
|
}
|
|
784
|
-
}
|
|
785
|
-
auth: authRuntime,
|
|
786
|
-
};
|
|
444
|
+
};
|
|
787
445
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
446
|
+
const app: App = {
|
|
447
|
+
container,
|
|
448
|
+
fronds,
|
|
449
|
+
// What this app publishes, straight from fougere.config.ts — the doors read it,
|
|
450
|
+
// so an undeclared adapter serves nothing whatever a host mounted.
|
|
451
|
+
adapters: options.adapters ?? {},
|
|
452
|
+
// Where a call goes, as DECLARED. Kept because a reader needs it beside what the
|
|
453
|
+
// runtime OBSERVED — `rpc.topology` calls a frond remote because it answered, never
|
|
454
|
+
// because a key said so, and the two disagree exactly when something is misconfigured.
|
|
455
|
+
remotes: Object.freeze({ ...(options.remotes ?? {}) }),
|
|
456
|
+
dispatch: (call) => dispatcher.dispatch(call),
|
|
457
|
+
local: localDispatcher,
|
|
458
|
+
resolve,
|
|
459
|
+
schemaFor,
|
|
460
|
+
facadeFor,
|
|
461
|
+
operationsFor,
|
|
462
|
+
listensTo: () => emissions.listensTo(),
|
|
463
|
+
deliver: (fact, payload) => emissions.deliver(fact, payload),
|
|
464
|
+
storageFor,
|
|
465
|
+
presenterFor,
|
|
466
|
+
dispose: release,
|
|
467
|
+
drain,
|
|
468
|
+
inFlight: () => inflight.count,
|
|
469
|
+
[Symbol.asyncDispose]: release,
|
|
470
|
+
serveRpc(op: string, answer: RpcAnswer): void {
|
|
471
|
+
// Refused rather than replaced: two declarations of one name would make the answer
|
|
472
|
+
// depend on wiring order, and `discover` is in here precisely so it cannot be taken.
|
|
473
|
+
const address = new RouteAddress({ entity: 'rpc', operation: op });
|
|
474
|
+
if (routeRegistry.find(address)) {
|
|
475
|
+
throw new Error(`rpc operation '${op}' is already served; a second declaration would depend on wiring order`);
|
|
476
|
+
}
|
|
477
|
+
routeRegistry.register(new OperationRoute(
|
|
478
|
+
'system',
|
|
479
|
+
address,
|
|
480
|
+
(call) => answer(call.invocation, call.address.surface),
|
|
481
|
+
));
|
|
482
|
+
},
|
|
483
|
+
extensions: () => appLifecycle.names(),
|
|
484
|
+
observe(observer) {
|
|
485
|
+
return dispatchLifecycle.add(observer);
|
|
486
|
+
},
|
|
487
|
+
use(...args: [AppMiddleware] | [string, AppMiddleware]): void {
|
|
488
|
+
if (typeof args[0] === 'string') {
|
|
489
|
+
const [entity, mw] = args as [string, AppMiddleware];
|
|
490
|
+
const list = scopedMiddlewares.get(entity) ?? [];
|
|
491
|
+
list.push(mw);
|
|
492
|
+
scopedMiddlewares.set(entity, list);
|
|
493
|
+
} else {
|
|
494
|
+
globalMiddlewares.push(args[0] as AppMiddleware);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
auth: authRuntime,
|
|
498
|
+
};
|
|
791
499
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
500
|
+
// The card is an rpc op like any other, so one registry answers and one refusal names
|
|
501
|
+
// what is served. A package's op is declared the same way, from outside.
|
|
502
|
+
app.serveRpc('discover', (_invocation, surface) => identityCardOf(app, surface));
|
|
503
|
+
|
|
504
|
+
// The last thing the boot does, and the first thing a release undoes. An extension may
|
|
505
|
+
// await here — which is what a provider needing to OPEN something could never do.
|
|
506
|
+
built = app;
|
|
799
507
|
await appLifecycle.up(app);
|
|
508
|
+
|
|
509
|
+
return app;
|
|
800
510
|
} catch (cause) {
|
|
511
|
+
// The caller cannot release what a failed boot took: it handed `onDispose` over before
|
|
512
|
+
// this call and never receives the app that would carry it back. Whoever opened a
|
|
513
|
+
// connection to give us would otherwise leak it on every refusal — a source, a scan
|
|
514
|
+
// that threw, a port bound twice, and not only an extension that refused to rise.
|
|
801
515
|
try {
|
|
802
516
|
await release();
|
|
803
517
|
} catch (refused) {
|
|
@@ -808,6 +522,4 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
808
522
|
}
|
|
809
523
|
throw cause;
|
|
810
524
|
}
|
|
811
|
-
|
|
812
|
-
return app;
|
|
813
|
-
}
|
|
525
|
+
}
|