@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.
@@ -0,0 +1,508 @@
1
+ import { Context, type Fiber, inspectCatalogPlugins, type Plugin } from '@forgeax/engine-plugin';
2
+ import {
3
+ type CatalogLoader,
4
+ type GamePluginEntry,
5
+ installCatalogLoader,
6
+ type PluginCatalog,
7
+ type PluginCatalogRecord,
8
+ type PluginRealm,
9
+ projectPluginEntries,
10
+ } from '@forgeax/engine-plugin/loader';
11
+ import {
12
+ assertHostModuleCatalogIdentity,
13
+ createHostAssembly,
14
+ type HostActivationEntry,
15
+ type HostActivationReport,
16
+ type HostAssembly,
17
+ HostAssemblyError,
18
+ type HostErrorSummary,
19
+ modulesFromCatalog,
20
+ validateHostAssembly,
21
+ } from './protocol.js';
22
+ import {
23
+ HOST_ACTIVATION_REPORT_SERVICE,
24
+ HOST_ASSEMBLY_SERVICE,
25
+ type HostTransportClient,
26
+ } from './transport.js';
27
+
28
+ export type FrontendHostState = 'created' | 'loading' | 'active' | 'failed' | 'disposed';
29
+
30
+ export interface FrontendHostStatus {
31
+ readonly state: FrontendHostState;
32
+ readonly revision: string;
33
+ readonly entries?: readonly HostActivationEntry[];
34
+ readonly error?: unknown;
35
+ }
36
+
37
+ export interface FrontendAssemblyState {
38
+ readonly current: HostAssembly;
39
+ readonly status: FrontendHostStatus;
40
+ }
41
+
42
+ export interface FrontendHostOptions {
43
+ /** App may provide its existing Engine Context; the host owns only its Fiber. */
44
+ readonly context?: Context;
45
+ readonly startupPlugins?: readonly Plugin[];
46
+ readonly catalog?: PluginCatalog;
47
+ /**
48
+ * Optional local assembly snapshot used for the first staged activation.
49
+ * When a transport is also present, the backend remains authoritative for
50
+ * later updates; this snapshot lets callers activate provider-only entries
51
+ * before their host-owned services are ready.
52
+ */
53
+ readonly assembly?: HostAssembly;
54
+ readonly entries?: readonly GamePluginEntry[];
55
+ readonly realm?: PluginRealm;
56
+ readonly config?: unknown;
57
+ readonly transport?: HostTransportClient;
58
+ readonly moduleVersions?: ReadonlyMap<string, string>;
59
+ readonly reportStatus?: (status: FrontendHostStatus) => void | Promise<void>;
60
+ readonly autoActivate?: boolean;
61
+ }
62
+
63
+ export interface FrontendHost {
64
+ readonly context: Context;
65
+ readonly loader?: CatalogLoader;
66
+ readonly assembly: FrontendAssemblyState;
67
+ readonly transport?: HostTransportClient;
68
+ readonly ownedContext: boolean;
69
+ readonly status: FrontendHostStatus;
70
+ activate(assembly?: HostAssembly): Promise<void>;
71
+ update(assembly: HostAssembly): Promise<void>;
72
+ dispose(): Promise<void>;
73
+ }
74
+
75
+ function hostFoundationPlugin(
76
+ assembly: FrontendAssemblyState,
77
+ transport: HostTransportClient | undefined,
78
+ ): Plugin {
79
+ return {
80
+ name: 'forgeax:frontend-host-foundation',
81
+ provide: ['hostAssembly', 'hostTransport'],
82
+ apply(ctx) {
83
+ ctx.provide('hostAssembly', assembly);
84
+ if (transport !== undefined) ctx.provide('hostTransport', transport);
85
+ },
86
+ };
87
+ }
88
+
89
+ function dynamicModuleRecord(
90
+ name: string,
91
+ realm: PluginRealm,
92
+ version: string,
93
+ url: string | undefined,
94
+ digest: string | undefined,
95
+ ): PluginCatalogRecord {
96
+ if (url === undefined) {
97
+ return {
98
+ realm,
99
+ version,
100
+ ...(digest === undefined ? {} : { digest }),
101
+ load: async () => {
102
+ throw new HostAssemblyError(
103
+ 'host-assembly-module-missing',
104
+ `module ${name} to have a browser URL or static Catalog record`,
105
+ 'Add the module to the frozen Catalog or publish its browser entry from the backend.',
106
+ { name },
107
+ );
108
+ },
109
+ };
110
+ }
111
+ return {
112
+ realm,
113
+ version,
114
+ ...(digest === undefined ? {} : { digest }),
115
+ load: () => import(/* @vite-ignore */ url),
116
+ };
117
+ }
118
+
119
+ function catalogForAssembly(
120
+ assembly: HostAssembly,
121
+ staticCatalog: PluginCatalog | undefined,
122
+ ): PluginCatalog {
123
+ const catalog = new Map<string, PluginCatalogRecord>();
124
+ for (const module of assembly.modules) {
125
+ const existing = staticCatalog?.get(module.name);
126
+ if (existing !== undefined) {
127
+ assertHostModuleCatalogIdentity(module, existing);
128
+ catalog.set(module.name, existing);
129
+ continue;
130
+ }
131
+ catalog.set(
132
+ module.name,
133
+ dynamicModuleRecord(module.name, module.realm, module.version, module.url, module.digest),
134
+ );
135
+ }
136
+ for (const [name, record] of staticCatalog ?? []) {
137
+ if (!catalog.has(name)) catalog.set(name, record);
138
+ }
139
+ return catalog;
140
+ }
141
+
142
+ function assertModuleVersions(
143
+ assembly: HostAssembly,
144
+ versions: ReadonlyMap<string, string> | undefined,
145
+ ): void {
146
+ if (versions === undefined) return;
147
+ for (const module of assembly.modules) {
148
+ const actual = versions.get(module.name);
149
+ if (actual === undefined || actual === module.version) continue;
150
+ throw new HostAssemblyError(
151
+ 'host-assembly-module-version-mismatch',
152
+ `module ${module.name} to use version ${module.version}`,
153
+ 'Refresh the browser module graph from the same backend assembly revision.',
154
+ { name: module.name, actual, expected: module.version },
155
+ );
156
+ }
157
+ }
158
+
159
+ function moduleIdentity(module: {
160
+ readonly version: string;
161
+ readonly digest?: string;
162
+ readonly url?: string;
163
+ }): string {
164
+ return `${module.version}@${module.digest ?? module.url ?? '<catalog>'}`;
165
+ }
166
+
167
+ /**
168
+ * The native Loader keeps the imported module graph for its lifetime. A new
169
+ * module URL or code identity therefore cannot be applied by Entry.update;
170
+ * accepting it would report a new assembly while executing the old code.
171
+ */
172
+ function assertModuleReloadBoundary(previous: HostAssembly, next: HostAssembly): void {
173
+ const previousModules = new Map(previous.modules.map((module) => [module.name, module]));
174
+ const nextModules = new Map(next.modules.map((module) => [module.name, module]));
175
+ const names = new Set([...previousModules.keys(), ...nextModules.keys()]);
176
+ for (const name of names) {
177
+ const before = previousModules.get(name);
178
+ const after = nextModules.get(name);
179
+ const beforeIdentity = before === undefined ? '<absent>' : moduleIdentity(before);
180
+ const afterIdentity = after === undefined ? '<absent>' : moduleIdentity(after);
181
+ if (
182
+ before === undefined ||
183
+ after === undefined ||
184
+ before.realm !== after.realm ||
185
+ beforeIdentity !== afterIdentity
186
+ ) {
187
+ throw new HostAssemblyError(
188
+ 'host-assembly-reload-required',
189
+ `module ${name} to keep its loaded code identity ${beforeIdentity}`,
190
+ 'Reload the frontend host to install the new module graph before activating this assembly.',
191
+ { module: name, actual: beforeIdentity, expected: afterIdentity },
192
+ );
193
+ }
194
+ }
195
+ }
196
+
197
+ function staticAssembly(options: FrontendHostOptions, realm: PluginRealm): HostAssembly {
198
+ const entries = options.entries ?? [];
199
+ const modules =
200
+ options.catalog === undefined ? [] : modulesFromCatalog(options.catalog, realm, 'static');
201
+ return createHostAssembly({
202
+ entries,
203
+ modules,
204
+ ...(options.config === undefined ? {} : { config: options.config }),
205
+ });
206
+ }
207
+
208
+ function errorSummary(error: unknown): HostErrorSummary | undefined {
209
+ if (
210
+ error === null ||
211
+ typeof error !== 'object' ||
212
+ typeof (error as { code?: unknown }).code !== 'string' ||
213
+ typeof (error as { expected?: unknown }).expected !== 'string' ||
214
+ typeof (error as { hint?: unknown }).hint !== 'string'
215
+ )
216
+ return undefined;
217
+ const detail = (error as { readonly detail?: unknown }).detail;
218
+ return {
219
+ code: (error as { readonly code: string }).code,
220
+ expected: (error as { readonly expected: string }).expected,
221
+ hint: (error as { readonly hint: string }).hint,
222
+ detail:
223
+ detail !== null && typeof detail === 'object'
224
+ ? (detail as Readonly<Record<string, unknown>>)
225
+ : { reason: String(detail ?? 'unknown failure') },
226
+ };
227
+ }
228
+
229
+ async function fetchInitialAssembly(
230
+ options: FrontendHostOptions,
231
+ realm: PluginRealm,
232
+ ): Promise<HostAssembly> {
233
+ // An explicit assembly is an intentional bootstrap snapshot. DevKit uses
234
+ // it to stage provider-only entries on reconnect before installing the
235
+ // GameHost service; the transport still supplies the authoritative full
236
+ // assembly immediately afterwards.
237
+ if (options.assembly !== undefined) return options.assembly;
238
+ if (options.transport !== undefined)
239
+ return options.transport.request<undefined, HostAssembly>(HOST_ASSEMBLY_SERVICE, undefined);
240
+ if (
241
+ options.entries !== undefined ||
242
+ options.catalog !== undefined ||
243
+ options.config !== undefined
244
+ )
245
+ return staticAssembly(options, realm);
246
+ throw new HostAssemblyError(
247
+ 'host-assembly-service-unavailable',
248
+ 'a static assembly or backend transport to be provided',
249
+ 'Pass the frozen assembly for a static player or connect the frontend host to a backend host.',
250
+ { service: HOST_ASSEMBLY_SERVICE },
251
+ );
252
+ }
253
+
254
+ function readiness(loader: CatalogLoader): readonly HostActivationEntry[] {
255
+ return inspectCatalogPlugins(loader).live.map((entry) => ({
256
+ entryId: entry.entryId,
257
+ fiberState: entry.fiberState,
258
+ ...(entry.failure === undefined
259
+ ? {}
260
+ : {
261
+ failure: {
262
+ code: entry.failure.code,
263
+ expected: entry.failure.expected,
264
+ hint: entry.failure.hint,
265
+ detail: entry.failure.detail,
266
+ },
267
+ }),
268
+ }));
269
+ }
270
+
271
+ function assertReady(entries: readonly HostActivationEntry[]): void {
272
+ const failed = entries.find(
273
+ (entry) => entry.fiberState !== 'active' && entry.fiberState !== 'disabled',
274
+ );
275
+ if (failed === undefined) return;
276
+ throw new HostAssemblyError(
277
+ 'host-assembly-not-ready',
278
+ `Entry ${failed.entryId} to reach active or disabled Fiber state`,
279
+ 'Inspect the Entry failure or waiting dependency and repair the frontend package graph.',
280
+ {
281
+ entryId: failed.entryId,
282
+ fiberState: failed.fiberState,
283
+ ...(failed.failure === undefined ? {} : { failure: failed.failure }),
284
+ },
285
+ );
286
+ }
287
+
288
+ /** Start one independent browser-side manager and its native Cordis Loader. */
289
+ export async function createFrontendHost(options: FrontendHostOptions = {}): Promise<FrontendHost> {
290
+ const realm = options.realm ?? 'engine';
291
+ const initial = await fetchInitialAssembly(options, realm);
292
+ const context = options.context ?? new Context();
293
+ const ownedContext = options.context === undefined;
294
+ const checked = validateHostAssembly(initial);
295
+ if (!checked.ok) throw checked.error;
296
+ let current = checked.value;
297
+ let status: FrontendHostStatus = {
298
+ state: 'created',
299
+ revision: current.revision,
300
+ };
301
+ const state: FrontendAssemblyState = {
302
+ get current() {
303
+ return current;
304
+ },
305
+ get status() {
306
+ return status;
307
+ },
308
+ };
309
+ const startupFibers: Fiber[] = [];
310
+ let foundationFiber: Fiber | undefined;
311
+ let loaderFiber: Fiber | undefined;
312
+ let loader: CatalogLoader | undefined;
313
+ let disposed = false;
314
+ let removeTransportDisconnect: (() => void) | undefined;
315
+ const report = async (next: FrontendHostStatus): Promise<void> => {
316
+ status = next;
317
+ await options.reportStatus?.(next);
318
+ if (options.transport !== undefined) {
319
+ const report: {
320
+ state: HostActivationReport['state'];
321
+ revision: string;
322
+ entries?: readonly HostActivationEntry[];
323
+ error?: HostErrorSummary;
324
+ } = {
325
+ state: next.state,
326
+ revision: next.revision,
327
+ };
328
+ if (next.entries !== undefined) report.entries = next.entries;
329
+ const failure = errorSummary(next.error);
330
+ if (failure !== undefined) report.error = failure;
331
+ try {
332
+ await options.transport.request(HOST_ACTIVATION_REPORT_SERVICE, report);
333
+ } catch (error) {
334
+ const failed: FrontendHostStatus = {
335
+ ...next,
336
+ state: next.state === 'active' ? 'failed' : next.state,
337
+ error,
338
+ };
339
+ status = failed;
340
+ await options.reportStatus?.(failed);
341
+ throw error;
342
+ }
343
+ }
344
+ };
345
+ if (options.transport !== undefined) {
346
+ removeTransportDisconnect = options.transport.onDisconnect((error) => {
347
+ if (disposed) return;
348
+ const failed: FrontendHostStatus = {
349
+ state: 'failed',
350
+ revision: current.revision,
351
+ error,
352
+ };
353
+ status = failed;
354
+ void Promise.resolve(options.reportStatus?.(failed)).catch(() => {});
355
+ });
356
+ }
357
+
358
+ try {
359
+ foundationFiber = await context.plugin(hostFoundationPlugin(state, options.transport));
360
+ for (const plugin of options.startupPlugins ?? [])
361
+ startupFibers.push(await context.plugin(plugin));
362
+ if (options.autoActivate !== false) {
363
+ // Activation is performed below through the returned host so its status
364
+ // callback and failure rollback cover the first assembly as well.
365
+ const host = {
366
+ context,
367
+ ...(options.transport === undefined ? {} : { transport: options.transport }),
368
+ assembly: state,
369
+ ...(ownedContext ? { ownedContext: true } : { ownedContext: false }),
370
+ get status() {
371
+ return status;
372
+ },
373
+ activate: async (_next?: HostAssembly) => {},
374
+ update: async (_next: HostAssembly) => {},
375
+ dispose: async () => {},
376
+ } as FrontendHost;
377
+ await activateFrontendHost(
378
+ host,
379
+ initial,
380
+ options,
381
+ realm,
382
+ () => loader,
383
+ (value) => {
384
+ loader = value.loader;
385
+ loaderFiber = value.fiber;
386
+ },
387
+ report,
388
+ () => {
389
+ current = initial;
390
+ },
391
+ );
392
+ }
393
+ } catch (error) {
394
+ removeTransportDisconnect?.();
395
+ await loaderFiber?.dispose();
396
+ for (const fiber of startupFibers.reverse()) await fiber.dispose();
397
+ await foundationFiber?.dispose();
398
+ if (ownedContext) await context.fiber.dispose();
399
+ throw error;
400
+ }
401
+
402
+ const host: FrontendHost = {
403
+ context,
404
+ ...(loader === undefined ? {} : { loader }),
405
+ ...(options.transport === undefined ? {} : { transport: options.transport }),
406
+ assembly: state,
407
+ ownedContext,
408
+ get status() {
409
+ return status;
410
+ },
411
+ async activate(next = current) {
412
+ await activateFrontendHost(
413
+ host,
414
+ next,
415
+ options,
416
+ realm,
417
+ () => loader,
418
+ (value) => {
419
+ loader = value.loader;
420
+ loaderFiber = value.fiber;
421
+ (host as { loader?: CatalogLoader }).loader = value.loader;
422
+ },
423
+ report,
424
+ () => {
425
+ current = next;
426
+ },
427
+ );
428
+ },
429
+ async update(next) {
430
+ await host.activate(next);
431
+ },
432
+ async dispose() {
433
+ if (disposed) return;
434
+ disposed = true;
435
+ removeTransportDisconnect?.();
436
+ await loaderFiber?.dispose();
437
+ for (const fiber of startupFibers.reverse()) await fiber.dispose();
438
+ await foundationFiber?.dispose();
439
+ if (ownedContext) await context.fiber.dispose();
440
+ status = { state: 'disposed', revision: current.revision };
441
+ },
442
+ };
443
+ return host;
444
+ }
445
+
446
+ async function activateFrontendHost(
447
+ host: FrontendHost,
448
+ next: HostAssembly,
449
+ options: FrontendHostOptions,
450
+ realm: PluginRealm,
451
+ getLoader: () => CatalogLoader | undefined,
452
+ setLoader: (value: { loader: CatalogLoader; fiber: Fiber }) => void,
453
+ report: (status: FrontendHostStatus) => Promise<void>,
454
+ commit: () => void,
455
+ ): Promise<void> {
456
+ if (host.status.state === 'disposed') {
457
+ throw new HostAssemblyError(
458
+ 'host-assembly-service-unavailable',
459
+ 'frontend host to remain active while activating an assembly',
460
+ 'Create a new frontend host for the next browser connection.',
461
+ { service: 'host-assembly' },
462
+ );
463
+ }
464
+ const checked = validateHostAssembly(next);
465
+ if (!checked.ok) {
466
+ await report({ state: 'failed', revision: next.revision, error: checked.error });
467
+ throw checked.error;
468
+ }
469
+ const activeLoader = getLoader();
470
+ try {
471
+ if (activeLoader !== undefined)
472
+ assertModuleReloadBoundary(host.assembly.current, checked.value);
473
+ await report({ state: 'loading', revision: next.revision });
474
+ assertModuleVersions(checked.value, options.moduleVersions);
475
+ let loader = activeLoader;
476
+ if (loader === undefined) {
477
+ const catalog = catalogForAssembly(checked.value, options.catalog);
478
+ const installed = await installCatalogLoader(host.context, catalog, realm);
479
+ loader = installed.loader;
480
+ setLoader(installed);
481
+ }
482
+ const entries = projectPluginEntries(checked.value.entries, realm, realm);
483
+ await loader.root.update(entries);
484
+ await loader.await();
485
+ const actualEntries = readiness(loader);
486
+ assertReady(actualEntries);
487
+ commit();
488
+ await report({ state: 'active', revision: checked.value.revision, entries: actualEntries });
489
+ } catch (error) {
490
+ if (error instanceof HostAssemblyError && error.code === 'host-assembly-reload-required') {
491
+ // The old Loader and its active Fibers are still authoritative. Do not
492
+ // publish a failed status for the candidate revision or disguise a
493
+ // required page reload as an in-place activation failure.
494
+ throw error;
495
+ }
496
+ const activeLoader = getLoader();
497
+ const actualEntries = activeLoader === undefined ? undefined : readiness(activeLoader);
498
+ await report({
499
+ state: 'failed',
500
+ revision: next.revision,
501
+ ...(actualEntries === undefined ? {} : { entries: actualEntries }),
502
+ error,
503
+ });
504
+ throw error;
505
+ }
506
+ }
507
+
508
+ export { HostAssemblyError };
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ export {
2
+ type BackendAssemblyAuthority,
3
+ type BackendHost,
4
+ type BackendHostOptions,
5
+ createBackendHost,
6
+ } from './backend.js';
7
+ export {
8
+ createFrontendHost,
9
+ type FrontendAssemblyState,
10
+ type FrontendHost,
11
+ type FrontendHostOptions,
12
+ type FrontendHostState,
13
+ type FrontendHostStatus,
14
+ } from './frontend.js';
15
+ export {
16
+ canonicalHostJson,
17
+ createHostAssembly,
18
+ HOST_ASSEMBLY_SCHEMA_VERSION,
19
+ type HostActivationEntry,
20
+ type HostActivationReport,
21
+ type HostAssembly,
22
+ HostAssemblyError,
23
+ type HostAssemblyErrorCode,
24
+ type HostAssemblyErrorDetailByCode,
25
+ type HostAssemblyFailure,
26
+ type HostAssemblyInput,
27
+ type HostAssemblyPair,
28
+ type HostAssemblyResult,
29
+ type HostAssemblySchemaVersion,
30
+ type HostAssemblyValidation,
31
+ type HostErrorSummary,
32
+ type HostModuleDescriptor,
33
+ type HostPluginEndpoint,
34
+ type HostPluginPair,
35
+ hostRevision,
36
+ modulesFromCatalog,
37
+ validateHostAssembly,
38
+ } from './protocol.js';
39
+ export {
40
+ attachHostWebSocketServer,
41
+ connectHostWebSocket,
42
+ createHostTransport,
43
+ createHostWebSocketClient,
44
+ HOST_ACTIVATION_REPORT_SERVICE,
45
+ HOST_ASSEMBLY_CHANGED_TOPIC,
46
+ HOST_ASSEMBLY_SERVICE,
47
+ type HostRequestOptions,
48
+ type HostServiceHandler,
49
+ type HostServiceRequest,
50
+ type HostServiceSnapshot,
51
+ type HostSocketLike,
52
+ type HostTransportClient,
53
+ type HostTransportServer,
54
+ } from './transport.js';