@forgeax/engine-host 0.1.28 → 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 = {
package/src/backend.ts CHANGED
@@ -60,7 +60,15 @@ export interface BackendHost {
60
60
  readonly transport: HostTransportServer;
61
61
  readonly ownedContext: boolean;
62
62
  /** Reconcile backend Entries and publish the matching frontend assembly atomically. */
63
- 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;
64
72
  dispose(): Promise<void>;
65
73
  }
66
74
 
@@ -203,7 +211,8 @@ 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
217
  let startup: HostStartup | undefined;
209
218
  let loaderFiber: Fiber | undefined;
@@ -253,6 +262,7 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
253
262
  );
254
263
  }
255
264
  await options.onActivationReport?.(report);
265
+ for (const listener of activationListeners) await listener(report);
256
266
  return { accepted: true };
257
267
  },
258
268
  );
@@ -275,7 +285,21 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
275
285
  assembly,
276
286
  transport,
277
287
  ownedContext: options.context === undefined,
278
- async update(nextInput) {
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) {
279
303
  if (disposed) {
280
304
  throw new HostAssemblyError(
281
305
  'host-assembly-service-unavailable',
@@ -321,11 +345,13 @@ export async function createBackendHost(options: BackendHostOptions = {}): Promi
321
345
  await loader.await();
322
346
  }
323
347
  backendEntries = nextBackendEntries;
348
+ if (updateOptions?.bootstrap === true) bootstrapRevision = checkedCandidate.value.revision;
324
349
  return authority.publish(effectiveInput);
325
350
  },
326
351
  async dispose() {
327
352
  if (disposed) return;
328
353
  disposed = true;
354
+ activationListeners.clear();
329
355
  unsubscribeAssembly?.();
330
356
  unregisterActivationService?.();
331
357
  unregisterAssemblyService?.();
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/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