@forgeax/engine-host 0.1.27
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 +202 -0
- package/README.md +32 -0
- package/dist/__tests__/host.test.d.ts +2 -0
- package/dist/__tests__/host.test.d.ts.map +1 -0
- package/dist/backend.d.ts +46 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.mjs +797 -0
- package/dist/backend.mjs.map +1 -0
- package/dist/frontend.d.ts +50 -0
- package/dist/frontend.d.ts.map +1 -0
- package/dist/frontend.mjs +715 -0
- package/dist/frontend.mjs.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1473 -0
- package/dist/index.mjs.map +1 -0
- package/dist/protocol.d.ts +153 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.mjs +351 -0
- package/dist/protocol.mjs.map +1 -0
- package/dist/transport.d.ts +64 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.mjs +575 -0
- package/dist/transport.mjs.map +1 -0
- package/package.json +88 -0
- package/src/__tests__/host.test.ts +510 -0
- package/src/backend.ts +342 -0
- package/src/frontend.ts +508 -0
- package/src/index.ts +54 -0
- package/src/protocol.ts +586 -0
- package/src/transport.ts +717 -0
package/src/backend.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Context, type Fiber, type Plugin } from '@forgeax/engine-plugin';
|
|
2
|
+
import {
|
|
3
|
+
bootstrapCatalogLoader,
|
|
4
|
+
type CatalogLoader,
|
|
5
|
+
type CatalogLoaderBootstrapResult,
|
|
6
|
+
type GamePluginEntry,
|
|
7
|
+
type PluginCatalog,
|
|
8
|
+
type PluginRealm,
|
|
9
|
+
projectPluginEntries,
|
|
10
|
+
} from '@forgeax/engine-plugin/loader';
|
|
11
|
+
import {
|
|
12
|
+
assertHostModuleCatalogIdentity,
|
|
13
|
+
createHostAssembly,
|
|
14
|
+
type HostActivationReport,
|
|
15
|
+
type HostAssembly,
|
|
16
|
+
HostAssemblyError,
|
|
17
|
+
type HostAssemblyInput,
|
|
18
|
+
type HostModuleDescriptor,
|
|
19
|
+
type HostPluginPair,
|
|
20
|
+
modulesFromCatalog,
|
|
21
|
+
validateHostAssembly,
|
|
22
|
+
} from './protocol.js';
|
|
23
|
+
import {
|
|
24
|
+
createHostTransport,
|
|
25
|
+
HOST_ACTIVATION_REPORT_SERVICE,
|
|
26
|
+
HOST_ASSEMBLY_CHANGED_TOPIC,
|
|
27
|
+
HOST_ASSEMBLY_SERVICE,
|
|
28
|
+
type HostTransportServer,
|
|
29
|
+
} from './transport.js';
|
|
30
|
+
|
|
31
|
+
export interface BackendAssemblyAuthority {
|
|
32
|
+
readonly current: HostAssembly;
|
|
33
|
+
readonly generation: number;
|
|
34
|
+
subscribe(listener: (assembly: HostAssembly) => void): () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BackendHostOptions {
|
|
38
|
+
/** Existing domain context may be supplied when App/ECS owns the root. */
|
|
39
|
+
readonly context?: Context;
|
|
40
|
+
/** Always-loaded bootstrap capabilities. Their effects remain Cordis-owned. */
|
|
41
|
+
readonly startupPlugins?: readonly Plugin[];
|
|
42
|
+
/** Optional native Catalog used to activate backend Entries. */
|
|
43
|
+
readonly catalog?: PluginCatalog;
|
|
44
|
+
readonly realm?: PluginRealm;
|
|
45
|
+
readonly entries?: readonly GamePluginEntry[];
|
|
46
|
+
/** Paired backend/frontend identities. Backend Entries are activated locally. */
|
|
47
|
+
readonly pairs?: readonly HostPluginPair[];
|
|
48
|
+
readonly assembly?: HostAssembly;
|
|
49
|
+
readonly modules?: readonly HostModuleDescriptor[];
|
|
50
|
+
readonly config?: unknown;
|
|
51
|
+
readonly transport?: HostTransportServer;
|
|
52
|
+
readonly onActivationReport?: (report: HostActivationReport) => void | Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BackendHost {
|
|
56
|
+
readonly context: Context;
|
|
57
|
+
readonly loader?: CatalogLoader;
|
|
58
|
+
readonly assembly: BackendAssemblyAuthority;
|
|
59
|
+
readonly transport: HostTransportServer;
|
|
60
|
+
readonly ownedContext: boolean;
|
|
61
|
+
/** Reconcile backend Entries and publish the matching frontend assembly atomically. */
|
|
62
|
+
update(input: HostAssemblyInput | readonly GamePluginEntry[]): Promise<HostAssembly>;
|
|
63
|
+
dispose(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function effectiveAssemblyInput(
|
|
67
|
+
current: HostAssembly,
|
|
68
|
+
input: HostAssemblyInput,
|
|
69
|
+
): HostAssemblyInput {
|
|
70
|
+
const preservePairs =
|
|
71
|
+
input.pairs === undefined && input.entries === undefined && input.modules === undefined;
|
|
72
|
+
const entriesById = new Map(current.entries.map((entry) => [entry.id, entry]));
|
|
73
|
+
const modulesByName = new Map(current.modules.map((module) => [module.name, module]));
|
|
74
|
+
const pairs = current.pairs.flatMap((pair) => {
|
|
75
|
+
const entry = entriesById.get(pair.entryId);
|
|
76
|
+
const module = modulesByName.get(pair.module.name);
|
|
77
|
+
return entry === undefined || module === undefined
|
|
78
|
+
? []
|
|
79
|
+
: [{ id: pair.id, frontend: { entry, module } }];
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
entries: input.entries ?? current.entries,
|
|
83
|
+
modules: input.modules ?? current.modules,
|
|
84
|
+
...(input.pairs !== undefined ? { pairs: input.pairs } : preservePairs ? { pairs } : {}),
|
|
85
|
+
...(input.config === undefined
|
|
86
|
+
? current.config === undefined
|
|
87
|
+
? {}
|
|
88
|
+
: { config: current.config }
|
|
89
|
+
: { config: input.config }),
|
|
90
|
+
...(input.backendEntries === undefined ? {} : { backendEntries: input.backendEntries }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function authorityOf(initial: HostAssembly): {
|
|
95
|
+
readonly authority: BackendAssemblyAuthority;
|
|
96
|
+
readonly publish: (input: HostAssemblyInput) => HostAssembly;
|
|
97
|
+
} {
|
|
98
|
+
let current = initial;
|
|
99
|
+
let generation = 1;
|
|
100
|
+
const listeners = new Set<(assembly: HostAssembly) => void>();
|
|
101
|
+
const authority: BackendAssemblyAuthority = {
|
|
102
|
+
get current() {
|
|
103
|
+
return current;
|
|
104
|
+
},
|
|
105
|
+
get generation() {
|
|
106
|
+
return generation;
|
|
107
|
+
},
|
|
108
|
+
subscribe(listener) {
|
|
109
|
+
listeners.add(listener);
|
|
110
|
+
return () => listeners.delete(listener);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
authority,
|
|
115
|
+
publish(input) {
|
|
116
|
+
const next = createHostAssembly(input);
|
|
117
|
+
const checked = validateHostAssembly(next);
|
|
118
|
+
if (!checked.ok) throw checked.error;
|
|
119
|
+
current = checked.value;
|
|
120
|
+
generation += 1;
|
|
121
|
+
for (const listener of listeners) listener(current);
|
|
122
|
+
return current;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hostFoundationPlugin(
|
|
128
|
+
assembly: BackendAssemblyAuthority,
|
|
129
|
+
transport: HostTransportServer,
|
|
130
|
+
): Plugin {
|
|
131
|
+
return {
|
|
132
|
+
name: 'forgeax:backend-host-foundation',
|
|
133
|
+
provide: ['hostAssembly', 'hostTransport'],
|
|
134
|
+
apply(ctx) {
|
|
135
|
+
ctx.provide('hostAssembly', assembly);
|
|
136
|
+
ctx.provide('hostTransport', transport);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertBackendCatalogIdentity(
|
|
142
|
+
catalog: PluginCatalog | undefined,
|
|
143
|
+
modules: readonly HostModuleDescriptor[],
|
|
144
|
+
): void {
|
|
145
|
+
if (catalog === undefined) return;
|
|
146
|
+
for (const module of modules) {
|
|
147
|
+
const record = catalog.get(module.name);
|
|
148
|
+
if (record !== undefined) assertHostModuleCatalogIdentity(module, record);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Start the backend side of the generic Engine host.
|
|
154
|
+
*
|
|
155
|
+
* The function only creates the Cordis root and loads declared bootstrap and
|
|
156
|
+
* Catalog entries. Project, App, World and game policy arrive as plugins.
|
|
157
|
+
*/
|
|
158
|
+
export async function createBackendHost(options: BackendHostOptions = {}): Promise<BackendHost> {
|
|
159
|
+
const context = options.context ?? new Context();
|
|
160
|
+
const ownedContext = options.context === undefined;
|
|
161
|
+
const realm = options.realm ?? 'engine';
|
|
162
|
+
const catalog = options.catalog;
|
|
163
|
+
const pairedBackendEntries =
|
|
164
|
+
options.pairs?.flatMap((pair) => (pair.backend === undefined ? [] : [pair.backend.entry])) ??
|
|
165
|
+
[];
|
|
166
|
+
const entries =
|
|
167
|
+
options.entries ??
|
|
168
|
+
options.assembly?.entries ??
|
|
169
|
+
options.pairs?.flatMap((pair) => (pair.frontend === undefined ? [] : [pair.frontend.entry])) ??
|
|
170
|
+
[];
|
|
171
|
+
const modules =
|
|
172
|
+
options.modules ??
|
|
173
|
+
options.assembly?.modules ??
|
|
174
|
+
(options.pairs === undefined
|
|
175
|
+
? catalog === undefined
|
|
176
|
+
? []
|
|
177
|
+
: modulesFromCatalog(catalog, realm)
|
|
178
|
+
: options.pairs.flatMap((pair) =>
|
|
179
|
+
pair.frontend === undefined ? [] : [pair.frontend.module],
|
|
180
|
+
));
|
|
181
|
+
const initialAssembly =
|
|
182
|
+
options.assembly ??
|
|
183
|
+
createHostAssembly({
|
|
184
|
+
entries,
|
|
185
|
+
modules,
|
|
186
|
+
...(options.pairs === undefined ? {} : { pairs: options.pairs }),
|
|
187
|
+
...(options.config === undefined ? {} : { config: options.config }),
|
|
188
|
+
});
|
|
189
|
+
const checkedInitial = validateHostAssembly(initialAssembly);
|
|
190
|
+
if (!checkedInitial.ok) {
|
|
191
|
+
if (ownedContext) await context.fiber.dispose();
|
|
192
|
+
throw checkedInitial.error;
|
|
193
|
+
}
|
|
194
|
+
const backendModules =
|
|
195
|
+
options.pairs === undefined
|
|
196
|
+
? modules
|
|
197
|
+
: options.pairs.flatMap((pair) => (pair.backend === undefined ? [] : [pair.backend.module]));
|
|
198
|
+
assertBackendCatalogIdentity(catalog, backendModules);
|
|
199
|
+
const authority = authorityOf(checkedInitial.value);
|
|
200
|
+
const assembly = authority.authority;
|
|
201
|
+
// A DevKit frontend may intentionally reconnect against the provider-only
|
|
202
|
+
// snapshot that bootstrapped this host, while the authority already points
|
|
203
|
+
// at the promoted full assembly from an earlier client. Keep that one
|
|
204
|
+
// staged revision valid for activation reports; all other revisions must
|
|
205
|
+
// still match the current authority exactly.
|
|
206
|
+
const bootstrapRevision = checkedInitial.value.revision;
|
|
207
|
+
const transport = options.transport ?? createHostTransport();
|
|
208
|
+
let foundationFiber: Fiber | undefined;
|
|
209
|
+
let loaderFiber: Fiber | undefined;
|
|
210
|
+
let loader: CatalogLoader | undefined;
|
|
211
|
+
let backendEntries =
|
|
212
|
+
options.pairs === undefined
|
|
213
|
+
? (options.entries ?? options.assembly?.entries ?? [])
|
|
214
|
+
: pairedBackendEntries;
|
|
215
|
+
const startupFibers: Fiber[] = [];
|
|
216
|
+
let unregisterAssemblyService: (() => void) | undefined;
|
|
217
|
+
let unregisterActivationService: (() => void) | undefined;
|
|
218
|
+
let unsubscribeAssembly: (() => void) | undefined;
|
|
219
|
+
try {
|
|
220
|
+
foundationFiber = await context.plugin(hostFoundationPlugin(assembly, transport));
|
|
221
|
+
for (const plugin of options.startupPlugins ?? [])
|
|
222
|
+
startupFibers.push(await context.plugin(plugin));
|
|
223
|
+
if (catalog !== undefined) {
|
|
224
|
+
const bootstrapped = await bootstrapCatalogLoader(context, catalog, realm, {
|
|
225
|
+
catalogDigest: checkedInitial.value.revision,
|
|
226
|
+
supportedRealms: [realm],
|
|
227
|
+
});
|
|
228
|
+
if (!bootstrapped.ok) throw bootstrapped.error;
|
|
229
|
+
loaderFiber = bootstrapped.value.fiber;
|
|
230
|
+
loader = bootstrapped.value.loader;
|
|
231
|
+
if (backendEntries.length > 0) {
|
|
232
|
+
await loader.root.update(projectPluginEntries(backendEntries, realm, realm));
|
|
233
|
+
await loader.await();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
unregisterAssemblyService = transport.register(HOST_ASSEMBLY_SERVICE, () => assembly.current);
|
|
237
|
+
unregisterActivationService = transport.register(
|
|
238
|
+
HOST_ACTIVATION_REPORT_SERVICE,
|
|
239
|
+
async ({ payload }) => {
|
|
240
|
+
const report = payload as HostActivationReport;
|
|
241
|
+
if (
|
|
242
|
+
report.revision !== assembly.current.revision &&
|
|
243
|
+
report.revision !== bootstrapRevision
|
|
244
|
+
) {
|
|
245
|
+
throw new HostAssemblyError(
|
|
246
|
+
'host-assembly-revision-mismatch',
|
|
247
|
+
`activation report revision ${report.revision} to match ${assembly.current.revision}`,
|
|
248
|
+
'Discard the stale frontend report and fetch the current backend assembly.',
|
|
249
|
+
{ actual: report.revision, expected: assembly.current.revision },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
await options.onActivationReport?.(report);
|
|
253
|
+
return { accepted: true };
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
unsubscribeAssembly = assembly.subscribe((next) => {
|
|
257
|
+
transport.publish(HOST_ASSEMBLY_CHANGED_TOPIC, next);
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
unregisterActivationService?.();
|
|
261
|
+
unregisterAssemblyService?.();
|
|
262
|
+
unsubscribeAssembly?.();
|
|
263
|
+
await loaderFiber?.dispose();
|
|
264
|
+
for (const fiber of startupFibers.reverse()) await fiber.dispose();
|
|
265
|
+
await foundationFiber?.dispose();
|
|
266
|
+
if (ownedContext) await context.fiber.dispose();
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let disposed = false;
|
|
271
|
+
const host: BackendHost = {
|
|
272
|
+
context,
|
|
273
|
+
...(loader === undefined ? {} : { loader }),
|
|
274
|
+
assembly,
|
|
275
|
+
transport,
|
|
276
|
+
ownedContext,
|
|
277
|
+
async update(nextInput) {
|
|
278
|
+
if (disposed) {
|
|
279
|
+
throw new HostAssemblyError(
|
|
280
|
+
'host-assembly-service-unavailable',
|
|
281
|
+
'backend host to remain active while updating Entries',
|
|
282
|
+
'Create a new host instance before updating the disposed host.',
|
|
283
|
+
{ service: 'host-assembly' },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const input: HostAssemblyInput = Array.isArray(nextInput)
|
|
287
|
+
? {
|
|
288
|
+
entries: nextInput as readonly GamePluginEntry[],
|
|
289
|
+
backendEntries: nextInput as readonly GamePluginEntry[],
|
|
290
|
+
}
|
|
291
|
+
: (nextInput as HostAssemblyInput);
|
|
292
|
+
const effectiveInput = effectiveAssemblyInput(assembly.current, input);
|
|
293
|
+
const candidate = createHostAssembly(effectiveInput);
|
|
294
|
+
const checkedCandidate = validateHostAssembly(candidate);
|
|
295
|
+
if (!checkedCandidate.ok) throw checkedCandidate.error;
|
|
296
|
+
const candidateBackendModules =
|
|
297
|
+
input.pairs === undefined
|
|
298
|
+
? checkedCandidate.value.modules
|
|
299
|
+
: input.pairs.flatMap((pair) =>
|
|
300
|
+
pair.backend === undefined ? [] : [pair.backend.module],
|
|
301
|
+
);
|
|
302
|
+
assertBackendCatalogIdentity(catalog, candidateBackendModules);
|
|
303
|
+
const nextBackendEntries =
|
|
304
|
+
input.backendEntries ??
|
|
305
|
+
(input.pairs === undefined
|
|
306
|
+
? (input.entries ?? backendEntries)
|
|
307
|
+
: input.pairs.flatMap((pair) =>
|
|
308
|
+
pair.backend === undefined ? [] : [pair.backend.entry],
|
|
309
|
+
));
|
|
310
|
+
if (loader === undefined && nextBackendEntries.length > 0) {
|
|
311
|
+
throw new HostAssemblyError(
|
|
312
|
+
'host-assembly-service-unavailable',
|
|
313
|
+
'a CatalogLoader to be installed before updating Entries',
|
|
314
|
+
'Provide a backend Catalog when the host owns plugin Entry activation.',
|
|
315
|
+
{ service: 'loader' },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (loader !== undefined) {
|
|
319
|
+
await loader.root.update(projectPluginEntries(nextBackendEntries, realm, realm));
|
|
320
|
+
await loader.await();
|
|
321
|
+
}
|
|
322
|
+
backendEntries = nextBackendEntries;
|
|
323
|
+
return authority.publish(effectiveInput);
|
|
324
|
+
},
|
|
325
|
+
async dispose() {
|
|
326
|
+
if (disposed) return;
|
|
327
|
+
disposed = true;
|
|
328
|
+
unsubscribeAssembly?.();
|
|
329
|
+
unregisterActivationService?.();
|
|
330
|
+
unregisterAssemblyService?.();
|
|
331
|
+
transport.close();
|
|
332
|
+
await loaderFiber?.dispose();
|
|
333
|
+
for (const fiber of startupFibers.reverse()) await fiber.dispose();
|
|
334
|
+
await foundationFiber?.dispose();
|
|
335
|
+
if (ownedContext) await context.fiber.dispose();
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
return host;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export type { CatalogLoaderBootstrapResult };
|
|
342
|
+
export { HostAssemblyError };
|