@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.
- package/README.md +9 -0
- package/dist/backend.d.ts +7 -1
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.mjs +20 -2
- package/dist/backend.mjs.map +1 -1
- package/dist/frontend.mjs.map +1 -1
- package/dist/index.mjs +23 -3
- package/dist/index.mjs.map +1 -1
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.mjs.map +1 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.mjs +3 -1
- package/dist/transport.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/host.test.ts +109 -0
- package/src/backend.ts +29 -3
- package/src/protocol.ts +6 -1
- package/src/transport.ts +2 -0
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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': {
|
|
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
|
|