@forgeax/engine-host 0.1.27 → 0.1.29

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.
@@ -9,6 +9,7 @@ import {
9
9
  createHostAssembly,
10
10
  createHostTransport,
11
11
  createHostWebSocketClient,
12
+ HOST_ACTIVATION_REPORT_SERVICE,
12
13
  HostAssemblyError,
13
14
  } from '../index';
14
15
 
@@ -26,6 +27,59 @@ function catalogFor(plugin: Plugin) {
26
27
  }
27
28
 
28
29
  describe('Engine host pair', () => {
30
+ it('admits one explicit bootstrap revision and preserves the owner activation callback', async () => {
31
+ const ownerReports: string[] = [];
32
+ const subscriberReports: string[] = [];
33
+ const backend = await createBackendHost({
34
+ config: { phase: 'owner' },
35
+ onActivationReport: (report) => {
36
+ ownerReports.push(report.revision);
37
+ },
38
+ });
39
+ const client = backend.transport.connect();
40
+ const remove = backend.subscribeActivationReports((report) => {
41
+ subscriberReports.push(report.revision);
42
+ });
43
+ try {
44
+ const original = backend.assembly.current;
45
+ const bootstrap = await backend.update(
46
+ { config: { phase: 'bootstrap' } },
47
+ { bootstrap: true },
48
+ );
49
+ const full = await backend.update({ config: { phase: 'full' } });
50
+ await client.request(HOST_ACTIVATION_REPORT_SERVICE, {
51
+ state: 'active',
52
+ revision: bootstrap.revision,
53
+ });
54
+ expect(ownerReports).toEqual([bootstrap.revision]);
55
+ expect(subscriberReports).toEqual([bootstrap.revision]);
56
+ await expect(
57
+ client.request(HOST_ACTIVATION_REPORT_SERVICE, {
58
+ state: 'active',
59
+ revision: original.revision,
60
+ }),
61
+ ).rejects.toMatchObject({ code: 'host-assembly-revision-mismatch' });
62
+ remove();
63
+ await client.request(HOST_ACTIVATION_REPORT_SERVICE, {
64
+ state: 'active',
65
+ revision: full.revision,
66
+ });
67
+ expect(ownerReports).toEqual([bootstrap.revision, full.revision]);
68
+ expect(subscriberReports).toEqual([bootstrap.revision]);
69
+ await backend.update({ config: { phase: 'next-bootstrap' } }, { bootstrap: true });
70
+ await expect(
71
+ client.request(HOST_ACTIVATION_REPORT_SERVICE, {
72
+ state: 'active',
73
+ revision: bootstrap.revision,
74
+ }),
75
+ ).rejects.toMatchObject({ code: 'host-assembly-revision-mismatch' });
76
+ } finally {
77
+ remove();
78
+ client.close();
79
+ await backend.dispose();
80
+ }
81
+ expect(() => backend.subscribeActivationReports(() => {})).toThrow(HostAssemblyError);
82
+ });
29
83
  it('activates a static frontend assembly through native Cordis Loader and unloads it', async () => {
30
84
  const events: string[] = [];
31
85
  const plugin: Plugin = {
@@ -357,6 +411,61 @@ describe('Engine host pair', () => {
357
411
  await new Promise<void>((resolve) => server.close(() => resolve()));
358
412
  });
359
413
 
414
+ it('preserves unknown remote business codes across the real WebSocket boundary', async () => {
415
+ const server = new WebSocketServer({ port: 0 });
416
+ await once(server, 'listening');
417
+ const address = server.address();
418
+ if (address === null || typeof address === 'string') throw new Error('WebSocket port missing');
419
+ const failures = {
420
+ capability: {
421
+ code: 'view-engine-capability-unavailable',
422
+ expected: 'Engine workspace capability project.open to be installed',
423
+ hint: 'Build and install the matching Engine workspace plugin before opening a project.',
424
+ detail: { capability: 'project.open' },
425
+ },
426
+ asset: {
427
+ code: 'view-workspace-asset-unsupported',
428
+ expected: 'Engine asset.open to support asset kind material',
429
+ hint: 'Use an Engine type-preview capability for this asset kind.',
430
+ detail: { kind: 'material' },
431
+ },
432
+ } as const;
433
+ const transport = createHostTransport();
434
+ const unregister = transport.register('forgeax.view.workspace.call', ({ payload }) => {
435
+ const failure =
436
+ (payload as { readonly operation?: unknown }).operation === 'asset.open'
437
+ ? failures.asset
438
+ : failures.capability;
439
+ throw Object.assign(new Error(failure.hint), failure);
440
+ });
441
+ server.on('connection', (socket) => attachHostWebSocketServer(socket, transport));
442
+ let client: Awaited<ReturnType<typeof createHostWebSocketClient>> | undefined;
443
+ try {
444
+ client = await createHostWebSocketClient(new WebSocket(`ws://127.0.0.1:${address.port}`));
445
+ for (const failure of [failures.capability, failures.asset]) {
446
+ await expect(
447
+ client.request('forgeax.view.workspace.call', {
448
+ operation: failure === failures.asset ? 'asset.open' : 'project.open',
449
+ }),
450
+ ).rejects.toMatchObject({
451
+ code: 'host-transport-failure',
452
+ expected: failure.expected,
453
+ hint: failure.hint,
454
+ detail: {
455
+ service: 'host/socket',
456
+ remoteCode: failure.code,
457
+ ...failure.detail,
458
+ },
459
+ });
460
+ }
461
+ } finally {
462
+ client?.close();
463
+ unregister();
464
+ transport.close();
465
+ await new Promise<void>((resolve) => server.close(() => resolve()));
466
+ }
467
+ });
468
+
360
469
  it('withdraws the frontend capability when its backend connection closes', async () => {
361
470
  const plugin: Plugin = { name: 'disconnect-fixture', apply() {} };
362
471
  const pair = {
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createHostStartup } from '../startup.js';
3
+
4
+ describe('generic host startup', () => {
5
+ it('owns only supplied Cordis startup entries and releases them', async () => {
6
+ let disposed = false;
7
+ const startup = await createHostStartup({
8
+ startupPlugins: [
9
+ {
10
+ name: 'fixture-startup',
11
+ apply(ctx) {
12
+ ctx.effect(
13
+ () => () => {
14
+ disposed = true;
15
+ },
16
+ 'fixture-startup',
17
+ );
18
+ },
19
+ },
20
+ ],
21
+ });
22
+
23
+ expect(startup.ownedContext).toBe(true);
24
+ await startup.dispose();
25
+ expect(disposed).toBe(true);
26
+ });
27
+ });
package/src/backend.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  modulesFromCatalog,
21
21
  validateHostAssembly,
22
22
  } from './protocol.js';
23
+ import { createHostStartup, type HostStartup } from './startup.js';
23
24
  import {
24
25
  createHostTransport,
25
26
  HOST_ACTIVATION_REPORT_SERVICE,
@@ -59,7 +60,15 @@ export interface BackendHost {
59
60
  readonly transport: HostTransportServer;
60
61
  readonly ownedContext: boolean;
61
62
  /** Reconcile backend Entries and publish the matching frontend assembly atomically. */
62
- update(input: HostAssemblyInput | readonly GamePluginEntry[]): Promise<HostAssembly>;
63
+ update(
64
+ input: HostAssemblyInput | readonly GamePluginEntry[],
65
+ /** Replace the one admitted bootstrap revision for staged frontend reconnects. */
66
+ options?: { readonly bootstrap?: boolean },
67
+ ): Promise<HostAssembly>;
68
+ /** Observe accepted reports without replacing the owning host's callback. */
69
+ subscribeActivationReports(
70
+ listener: (report: HostActivationReport) => void | Promise<void>,
71
+ ): () => void;
63
72
  dispose(): Promise<void>;
64
73
  }
65
74
 
@@ -157,7 +166,6 @@ function assertBackendCatalogIdentity(
157
166
  */
158
167
  export async function createBackendHost(options: BackendHostOptions = {}): Promise<BackendHost> {
159
168
  const context = options.context ?? new Context();
160
- const ownedContext = options.context === undefined;
161
169
  const realm = options.realm ?? 'engine';
162
170
  const catalog = options.catalog;
163
171
  const pairedBackendEntries =
@@ -188,7 +196,7 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
188
196
  });
189
197
  const checkedInitial = validateHostAssembly(initialAssembly);
190
198
  if (!checkedInitial.ok) {
191
- if (ownedContext) await context.fiber.dispose();
199
+ if (options.context === undefined) await context.fiber.dispose();
192
200
  throw checkedInitial.error;
193
201
  }
194
202
  const backendModules =
@@ -203,23 +211,27 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
203
211
  // at the promoted full assembly from an earlier client. Keep that one
204
212
  // staged revision valid for activation reports; all other revisions must
205
213
  // still match the current authority exactly.
206
- const bootstrapRevision = checkedInitial.value.revision;
214
+ let bootstrapRevision = checkedInitial.value.revision;
215
+ const activationListeners = new Set<(report: HostActivationReport) => void | Promise<void>>();
207
216
  const transport = options.transport ?? createHostTransport();
208
- let foundationFiber: Fiber | undefined;
217
+ let startup: HostStartup | undefined;
209
218
  let loaderFiber: Fiber | undefined;
210
219
  let loader: CatalogLoader | undefined;
211
220
  let backendEntries =
212
221
  options.pairs === undefined
213
222
  ? (options.entries ?? options.assembly?.entries ?? [])
214
223
  : pairedBackendEntries;
215
- const startupFibers: Fiber[] = [];
216
224
  let unregisterAssemblyService: (() => void) | undefined;
217
225
  let unregisterActivationService: (() => void) | undefined;
218
226
  let unsubscribeAssembly: (() => void) | undefined;
219
227
  try {
220
- foundationFiber = await context.plugin(hostFoundationPlugin(assembly, transport));
221
- for (const plugin of options.startupPlugins ?? [])
222
- startupFibers.push(await context.plugin(plugin));
228
+ startup = await createHostStartup({
229
+ context,
230
+ startupPlugins: [
231
+ hostFoundationPlugin(assembly, transport),
232
+ ...(options.startupPlugins ?? []),
233
+ ],
234
+ });
223
235
  if (catalog !== undefined) {
224
236
  const bootstrapped = await bootstrapCatalogLoader(context, catalog, realm, {
225
237
  catalogDigest: checkedInitial.value.revision,
@@ -250,6 +262,7 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
250
262
  );
251
263
  }
252
264
  await options.onActivationReport?.(report);
265
+ for (const listener of activationListeners) await listener(report);
253
266
  return { accepted: true };
254
267
  },
255
268
  );
@@ -261,9 +274,7 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
261
274
  unregisterAssemblyService?.();
262
275
  unsubscribeAssembly?.();
263
276
  await loaderFiber?.dispose();
264
- for (const fiber of startupFibers.reverse()) await fiber.dispose();
265
- await foundationFiber?.dispose();
266
- if (ownedContext) await context.fiber.dispose();
277
+ await startup?.dispose();
267
278
  throw error;
268
279
  }
269
280
 
@@ -273,8 +284,22 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
273
284
  ...(loader === undefined ? {} : { loader }),
274
285
  assembly,
275
286
  transport,
276
- ownedContext,
277
- async update(nextInput) {
287
+ ownedContext: options.context === undefined,
288
+ subscribeActivationReports(listener) {
289
+ if (disposed) {
290
+ throw new HostAssemblyError(
291
+ 'host-assembly-service-unavailable',
292
+ 'the backend host to remain active while observing activation reports',
293
+ 'Subscribe on an active host instance.',
294
+ { service: HOST_ACTIVATION_REPORT_SERVICE },
295
+ );
296
+ }
297
+ activationListeners.add(listener);
298
+ return () => {
299
+ activationListeners.delete(listener);
300
+ };
301
+ },
302
+ async update(nextInput, updateOptions) {
278
303
  if (disposed) {
279
304
  throw new HostAssemblyError(
280
305
  'host-assembly-service-unavailable',
@@ -320,19 +345,19 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
320
345
  await loader.await();
321
346
  }
322
347
  backendEntries = nextBackendEntries;
348
+ if (updateOptions?.bootstrap === true) bootstrapRevision = checkedCandidate.value.revision;
323
349
  return authority.publish(effectiveInput);
324
350
  },
325
351
  async dispose() {
326
352
  if (disposed) return;
327
353
  disposed = true;
354
+ activationListeners.clear();
328
355
  unsubscribeAssembly?.();
329
356
  unregisterActivationService?.();
330
357
  unregisterAssemblyService?.();
331
358
  transport.close();
332
359
  await loaderFiber?.dispose();
333
- for (const fiber of startupFibers.reverse()) await fiber.dispose();
334
- await foundationFiber?.dispose();
335
- if (ownedContext) await context.fiber.dispose();
360
+ await startup?.dispose();
336
361
  },
337
362
  };
338
363
  return host;
package/src/frontend.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  modulesFromCatalog,
20
20
  validateHostAssembly,
21
21
  } from './protocol.js';
22
+ import { createHostStartup, type HostStartup } from './startup.js';
22
23
  import {
23
24
  HOST_ACTIVATION_REPORT_SERVICE,
24
25
  HOST_ASSEMBLY_SERVICE,
@@ -306,8 +307,7 @@ export async function createFrontendHost(options: FrontendHostOptions = {}): Pro
306
307
  return status;
307
308
  },
308
309
  };
309
- const startupFibers: Fiber[] = [];
310
- let foundationFiber: Fiber | undefined;
310
+ let startup: HostStartup | undefined;
311
311
  let loaderFiber: Fiber | undefined;
312
312
  let loader: CatalogLoader | undefined;
313
313
  let disposed = false;
@@ -356,9 +356,13 @@ export async function createFrontendHost(options: FrontendHostOptions = {}): Pro
356
356
  }
357
357
 
358
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));
359
+ startup = await createHostStartup({
360
+ context,
361
+ startupPlugins: [
362
+ hostFoundationPlugin(state, options.transport),
363
+ ...(options.startupPlugins ?? []),
364
+ ],
365
+ });
362
366
  if (options.autoActivate !== false) {
363
367
  // Activation is performed below through the returned host so its status
364
368
  // callback and failure rollback cover the first assembly as well.
@@ -393,9 +397,7 @@ export async function createFrontendHost(options: FrontendHostOptions = {}): Pro
393
397
  } catch (error) {
394
398
  removeTransportDisconnect?.();
395
399
  await loaderFiber?.dispose();
396
- for (const fiber of startupFibers.reverse()) await fiber.dispose();
397
- await foundationFiber?.dispose();
398
- if (ownedContext) await context.fiber.dispose();
400
+ await startup?.dispose();
399
401
  throw error;
400
402
  }
401
403
 
@@ -434,9 +436,7 @@ export async function createFrontendHost(options: FrontendHostOptions = {}): Pro
434
436
  disposed = true;
435
437
  removeTransportDisconnect?.();
436
438
  await loaderFiber?.dispose();
437
- for (const fiber of startupFibers.reverse()) await fiber.dispose();
438
- await foundationFiber?.dispose();
439
- if (ownedContext) await context.fiber.dispose();
439
+ await startup?.dispose();
440
440
  status = { state: 'disposed', revision: current.revision };
441
441
  },
442
442
  };
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  modulesFromCatalog,
37
37
  validateHostAssembly,
38
38
  } from './protocol.js';
39
+ export { createHostStartup, type HostStartup, type HostStartupOptions } from './startup.js';
39
40
  export {
40
41
  attachHostWebSocketServer,
41
42
  connectHostWebSocket,
package/src/protocol.ts CHANGED
@@ -116,7 +116,12 @@ export interface HostAssemblyErrorDetailByCode {
116
116
  'host-assembly-service-unavailable': { readonly service: string };
117
117
  'host-assembly-stale-request': { readonly service: string; readonly generation: number };
118
118
  'host-assembly-request-aborted': { readonly service: string };
119
- 'host-transport-failure': { readonly service: string; readonly reason: string };
119
+ 'host-transport-failure': {
120
+ readonly service: string;
121
+ readonly reason: string;
122
+ /** Business error code received from a remote host service, if any. */
123
+ readonly remoteCode?: string;
124
+ };
120
125
  'host-assembly-not-ready': {
121
126
  readonly entryId: string;
122
127
  readonly fiberState: string;
package/src/startup.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { Context, type Fiber, type Plugin } from '@forgeax/engine-plugin';
2
+
3
+ /**
4
+ * The product-neutral host bootstrap. It owns only a Cordis Context and the
5
+ * supplied startup entries; assembly, transport, Project, and App are all
6
+ * ordinary plugins layered above this seam.
7
+ */
8
+ export interface HostStartupOptions {
9
+ readonly context?: Context;
10
+ readonly startupPlugins?: readonly Plugin[];
11
+ }
12
+
13
+ export interface HostStartup {
14
+ readonly context: Context;
15
+ readonly ownedContext: boolean;
16
+ readonly fibers: readonly Fiber[];
17
+ dispose(): Promise<void>;
18
+ }
19
+
20
+ export async function createHostStartup(options: HostStartupOptions = {}): Promise<HostStartup> {
21
+ const context = options.context ?? new Context();
22
+ const ownedContext = options.context === undefined;
23
+ const fibers: Fiber[] = [];
24
+ let disposed = false;
25
+ try {
26
+ for (const plugin of options.startupPlugins ?? []) fibers.push(await context.plugin(plugin));
27
+ } catch (error) {
28
+ for (const fiber of fibers.reverse()) await fiber.dispose();
29
+ if (ownedContext) await context.fiber.dispose();
30
+ throw error;
31
+ }
32
+ return {
33
+ context,
34
+ ownedContext,
35
+ fibers,
36
+ async dispose(): Promise<void> {
37
+ if (disposed) return;
38
+ disposed = true;
39
+ for (const fiber of [...fibers].reverse()) await fiber.dispose();
40
+ if (ownedContext) await context.fiber.dispose();
41
+ },
42
+ };
43
+ }
package/src/transport.ts CHANGED
@@ -431,8 +431,10 @@ function errorFromSummary(service: string, summary: HostErrorSummary): HostAssem
431
431
  );
432
432
  }
433
433
  return new HostAssemblyError('host-transport-failure', summary.expected, summary.hint, {
434
+ ...detail,
434
435
  service,
435
436
  reason: summary.detail.reason ? String(summary.detail.reason) : summary.code,
437
+ remoteCode: summary.code,
436
438
  });
437
439
  }
438
440