@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
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { Context, definePluginGroup, type Plugin, usePlugin } from '@forgeax/engine-plugin';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
5
|
+
import {
|
|
6
|
+
attachHostWebSocketServer,
|
|
7
|
+
createBackendHost,
|
|
8
|
+
createFrontendHost,
|
|
9
|
+
createHostAssembly,
|
|
10
|
+
createHostTransport,
|
|
11
|
+
createHostWebSocketClient,
|
|
12
|
+
HostAssemblyError,
|
|
13
|
+
} from '../index';
|
|
14
|
+
|
|
15
|
+
function catalogFor(plugin: Plugin) {
|
|
16
|
+
return new Map([
|
|
17
|
+
[
|
|
18
|
+
'@fixture/plugin',
|
|
19
|
+
{
|
|
20
|
+
realm: 'engine' as const,
|
|
21
|
+
version: 'fixture-1',
|
|
22
|
+
load: async () => ({ default: plugin }),
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Engine host pair', () => {
|
|
29
|
+
it('activates a static frontend assembly through native Cordis Loader and unloads it', async () => {
|
|
30
|
+
const events: string[] = [];
|
|
31
|
+
const plugin: Plugin = {
|
|
32
|
+
name: 'fixture',
|
|
33
|
+
apply(ctx) {
|
|
34
|
+
events.push('apply');
|
|
35
|
+
ctx.effect(() => () => events.push('dispose'));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const catalog = catalogFor(plugin);
|
|
39
|
+
const host = await createFrontendHost({
|
|
40
|
+
catalog,
|
|
41
|
+
entries: [{ id: 'fixture', name: '@fixture/plugin' }],
|
|
42
|
+
});
|
|
43
|
+
expect(host.status.state).toBe('active');
|
|
44
|
+
expect(events).toEqual(['apply']);
|
|
45
|
+
await host.dispose();
|
|
46
|
+
expect(events).toEqual(['apply', 'dispose']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps backend assembly authoritative while preserving repeated Entry identity', async () => {
|
|
50
|
+
const plugin: Plugin = { name: 'fixture', apply() {} };
|
|
51
|
+
const catalog = catalogFor(plugin);
|
|
52
|
+
const backend = await createBackendHost({
|
|
53
|
+
catalog,
|
|
54
|
+
entries: [
|
|
55
|
+
{ id: 'one', name: '@fixture/plugin', config: { value: 1 } },
|
|
56
|
+
{ id: 'two', name: '@fixture/plugin', config: { value: 2 } },
|
|
57
|
+
],
|
|
58
|
+
modules: [{ name: '@fixture/plugin', realm: 'engine', version: 'fixture-1' }],
|
|
59
|
+
});
|
|
60
|
+
expect(backend.assembly.current.entries.map((entry) => entry.id)).toEqual(['one', 'two']);
|
|
61
|
+
expect('set' in backend.assembly).toBe(false);
|
|
62
|
+
const first = backend.assembly.current;
|
|
63
|
+
const next = await backend.update({
|
|
64
|
+
entries: [{ id: 'one', name: '@fixture/plugin', config: { value: 3 } }],
|
|
65
|
+
modules: first.modules,
|
|
66
|
+
config: { safe: true },
|
|
67
|
+
});
|
|
68
|
+
expect(next.revision).not.toBe(first.revision);
|
|
69
|
+
expect(next.config).toEqual({ safe: true });
|
|
70
|
+
await backend.dispose();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects a static Catalog whose code identity is older than the assembly', async () => {
|
|
74
|
+
const plugin: Plugin = { name: 'stale-catalog', apply() {} };
|
|
75
|
+
await expect(
|
|
76
|
+
createFrontendHost({
|
|
77
|
+
catalog: catalogFor(plugin),
|
|
78
|
+
assembly: createHostAssembly({
|
|
79
|
+
entries: [{ id: 'stale', name: '@fixture/plugin' }],
|
|
80
|
+
modules: [{ name: '@fixture/plugin', realm: 'engine', version: 'fixture-2' }],
|
|
81
|
+
}),
|
|
82
|
+
}),
|
|
83
|
+
).rejects.toMatchObject({
|
|
84
|
+
code: 'host-assembly-module-version-mismatch',
|
|
85
|
+
detail: { actual: 'fixture-1', expected: 'fixture-2' },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects a Catalog with no code identity for a versioned assembly', async () => {
|
|
90
|
+
const plugin: Plugin = { name: 'unidentified-catalog', apply() {} };
|
|
91
|
+
await expect(
|
|
92
|
+
createFrontendHost({
|
|
93
|
+
catalog: new Map([
|
|
94
|
+
[
|
|
95
|
+
'@fixture/plugin',
|
|
96
|
+
{ realm: 'engine' as const, load: async () => ({ default: plugin }) },
|
|
97
|
+
],
|
|
98
|
+
]),
|
|
99
|
+
assembly: createHostAssembly({
|
|
100
|
+
entries: [{ id: 'unidentified', name: '@fixture/plugin' }],
|
|
101
|
+
modules: [{ name: '@fixture/plugin', realm: 'engine', version: 'fixture-2' }],
|
|
102
|
+
}),
|
|
103
|
+
}),
|
|
104
|
+
).rejects.toMatchObject({
|
|
105
|
+
code: 'host-assembly-module-version-mismatch',
|
|
106
|
+
detail: { actual: 'unknown', expected: 'fixture-2' },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects a Catalog digest that is not named by the assembly', async () => {
|
|
111
|
+
const plugin: Plugin = { name: 'orphan-digest', apply() {} };
|
|
112
|
+
await expect(
|
|
113
|
+
createFrontendHost({
|
|
114
|
+
catalog: new Map([
|
|
115
|
+
[
|
|
116
|
+
'@fixture/plugin',
|
|
117
|
+
{
|
|
118
|
+
realm: 'engine' as const,
|
|
119
|
+
digest: 'old-code',
|
|
120
|
+
load: async () => ({ default: plugin }),
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
]),
|
|
124
|
+
assembly: createHostAssembly({
|
|
125
|
+
entries: [{ id: 'orphan-digest', name: '@fixture/plugin' }],
|
|
126
|
+
modules: [{ name: '@fixture/plugin', realm: 'engine', version: 'fixture-2' }],
|
|
127
|
+
}),
|
|
128
|
+
}),
|
|
129
|
+
).rejects.toMatchObject({
|
|
130
|
+
code: 'host-assembly-module-version-mismatch',
|
|
131
|
+
detail: { actual: 'old-code', expected: 'fixture-2' },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects a backend Catalog that is older than its paired declaration', async () => {
|
|
136
|
+
const plugin: Plugin = { name: 'backend-stale-catalog', apply() {} };
|
|
137
|
+
await expect(
|
|
138
|
+
createBackendHost({
|
|
139
|
+
catalog: catalogFor(plugin),
|
|
140
|
+
pairs: [
|
|
141
|
+
{
|
|
142
|
+
id: 'backend-stale',
|
|
143
|
+
backend: {
|
|
144
|
+
entry: { id: 'backend-stale-entry', name: '@fixture/plugin' },
|
|
145
|
+
module: { name: '@fixture/plugin', realm: 'engine', version: 'fixture-2' },
|
|
146
|
+
},
|
|
147
|
+
frontend: {
|
|
148
|
+
entry: { id: 'frontend-stale-entry', name: '@fixture/plugin' },
|
|
149
|
+
module: { name: '@fixture/plugin', realm: 'engine', version: 'fixture-2' },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
}),
|
|
154
|
+
).rejects.toMatchObject({
|
|
155
|
+
code: 'host-assembly-module-version-mismatch',
|
|
156
|
+
detail: { actual: 'fixture-1', expected: 'fixture-2' },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('rejects a backend update whose Catalog is older than its paired declaration', async () => {
|
|
161
|
+
const plugin: Plugin = { name: 'backend-update-stale-catalog', apply() {} };
|
|
162
|
+
const pair = {
|
|
163
|
+
id: 'backend-update-stale',
|
|
164
|
+
backend: {
|
|
165
|
+
entry: { id: 'backend-update-entry', name: '@fixture/plugin' },
|
|
166
|
+
module: { name: '@fixture/plugin', realm: 'engine' as const, version: 'fixture-1' },
|
|
167
|
+
},
|
|
168
|
+
frontend: {
|
|
169
|
+
entry: { id: 'frontend-update-entry', name: '@fixture/plugin' },
|
|
170
|
+
module: { name: '@fixture/plugin', realm: 'engine' as const, version: 'fixture-1' },
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const backend = await createBackendHost({ catalog: catalogFor(plugin), pairs: [pair] });
|
|
174
|
+
await expect(
|
|
175
|
+
backend.update({
|
|
176
|
+
pairs: [
|
|
177
|
+
{
|
|
178
|
+
...pair,
|
|
179
|
+
backend: {
|
|
180
|
+
...pair.backend,
|
|
181
|
+
module: { ...pair.backend.module, version: 'fixture-2' },
|
|
182
|
+
},
|
|
183
|
+
frontend: {
|
|
184
|
+
...pair.frontend,
|
|
185
|
+
module: { ...pair.frontend.module, version: 'fixture-2' },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
modules: [{ ...pair.frontend.module, version: 'fixture-2' }],
|
|
190
|
+
}),
|
|
191
|
+
).rejects.toMatchObject({
|
|
192
|
+
code: 'host-assembly-module-version-mismatch',
|
|
193
|
+
detail: { actual: 'fixture-1', expected: 'fixture-2' },
|
|
194
|
+
});
|
|
195
|
+
await backend.dispose();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('fetches backend assembly and reports actual frontend activation', async () => {
|
|
199
|
+
const reports: string[] = [];
|
|
200
|
+
const plugin: Plugin = { name: 'paired-fixture', apply() {} };
|
|
201
|
+
const pair = {
|
|
202
|
+
id: 'paired-fixture',
|
|
203
|
+
backend: {
|
|
204
|
+
entry: { id: 'paired-backend', name: '@fixture/plugin' },
|
|
205
|
+
module: { name: '@fixture/plugin', realm: 'engine' as const, version: 'fixture-1' },
|
|
206
|
+
},
|
|
207
|
+
frontend: {
|
|
208
|
+
entry: { id: 'paired-frontend', name: '@fixture/plugin' },
|
|
209
|
+
module: { name: '@fixture/plugin', realm: 'engine' as const, version: 'fixture-1' },
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
const backend = await createBackendHost({
|
|
213
|
+
catalog: catalogFor(plugin),
|
|
214
|
+
pairs: [pair],
|
|
215
|
+
onActivationReport: (report) => {
|
|
216
|
+
reports.push(`${report.state}:${report.revision}`);
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
const client = backend.transport.connect();
|
|
220
|
+
const frontend = await createFrontendHost({ catalog: catalogFor(plugin), transport: client });
|
|
221
|
+
expect(frontend.assembly.current.pairs[0]?.id).toBe('paired-fixture');
|
|
222
|
+
expect(frontend.status.state).toBe('active');
|
|
223
|
+
expect(reports.some((report) => report.startsWith('active:'))).toBe(true);
|
|
224
|
+
await frontend.dispose();
|
|
225
|
+
client.close();
|
|
226
|
+
await backend.dispose();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('stages an explicit bootstrap assembly when a promoted backend reconnects', async () => {
|
|
230
|
+
const provider: Plugin = {
|
|
231
|
+
name: 'staged-provider',
|
|
232
|
+
provide: 'physics',
|
|
233
|
+
apply(ctx) {
|
|
234
|
+
ctx.provide('physics', {});
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const gameChild: Plugin = {
|
|
238
|
+
name: 'staged-game-child',
|
|
239
|
+
inject: ['gameHost'],
|
|
240
|
+
apply() {},
|
|
241
|
+
};
|
|
242
|
+
const game = definePluginGroup({
|
|
243
|
+
name: 'staged-game',
|
|
244
|
+
children: () => [usePlugin(gameChild, undefined, { key: 'child' })],
|
|
245
|
+
});
|
|
246
|
+
const modules = [
|
|
247
|
+
{ name: '@fixture/staged-provider', realm: 'engine' as const, version: 'fixture-1' },
|
|
248
|
+
{ name: '@fixture/staged-game', realm: 'engine' as const, version: 'fixture-1' },
|
|
249
|
+
];
|
|
250
|
+
const catalog = new Map([
|
|
251
|
+
[
|
|
252
|
+
'@fixture/staged-provider',
|
|
253
|
+
{
|
|
254
|
+
realm: 'engine' as const,
|
|
255
|
+
version: 'fixture-1',
|
|
256
|
+
load: async () => ({ default: provider }),
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
[
|
|
260
|
+
'@fixture/staged-game',
|
|
261
|
+
{ realm: 'engine' as const, version: 'fixture-1', load: async () => ({ default: game }) },
|
|
262
|
+
],
|
|
263
|
+
]);
|
|
264
|
+
const bootstrap = createHostAssembly({
|
|
265
|
+
entries: [{ id: 'provider', name: '@fixture/staged-provider' }],
|
|
266
|
+
modules,
|
|
267
|
+
});
|
|
268
|
+
const full = createHostAssembly({
|
|
269
|
+
entries: [
|
|
270
|
+
{ id: 'provider', name: '@fixture/staged-provider' },
|
|
271
|
+
{ id: 'game', name: '@fixture/staged-game' },
|
|
272
|
+
],
|
|
273
|
+
modules,
|
|
274
|
+
});
|
|
275
|
+
const backend = await createBackendHost({ catalog, assembly: bootstrap });
|
|
276
|
+
await backend.update({ entries: full.entries, modules: full.modules, backendEntries: [] });
|
|
277
|
+
const client = backend.transport.connect();
|
|
278
|
+
const frontend = await createFrontendHost({ catalog, transport: client, assembly: bootstrap });
|
|
279
|
+
await frontend.context.plugin({
|
|
280
|
+
name: 'staged-game-host',
|
|
281
|
+
provide: 'gameHost',
|
|
282
|
+
apply(ctx) {
|
|
283
|
+
ctx.provide('gameHost', {});
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
await frontend.update(full);
|
|
287
|
+
expect(frontend.assembly.current.revision).toBe(full.revision);
|
|
288
|
+
await frontend.dispose();
|
|
289
|
+
client.close();
|
|
290
|
+
await backend.dispose();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('rejects a module code revision until the frontend Loader is reloaded', async () => {
|
|
294
|
+
const plugin: Plugin = { name: 'reload-fixture', apply() {} };
|
|
295
|
+
const initial = createHostAssembly({
|
|
296
|
+
entries: [{ id: 'reload-entry', name: '@fixture/plugin' }],
|
|
297
|
+
modules: [{ name: '@fixture/plugin', realm: 'engine', version: 'fixture-1' }],
|
|
298
|
+
});
|
|
299
|
+
const host = await createFrontendHost({ assembly: initial, catalog: catalogFor(plugin) });
|
|
300
|
+
const next = createHostAssembly({
|
|
301
|
+
entries: initial.entries,
|
|
302
|
+
modules: [{ name: '@fixture/plugin', realm: 'engine', version: 'fixture-2' }],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await expect(host.update(next)).rejects.toMatchObject({
|
|
306
|
+
code: 'host-assembly-reload-required',
|
|
307
|
+
detail: {
|
|
308
|
+
module: '@fixture/plugin',
|
|
309
|
+
actual: 'fixture-1@<catalog>',
|
|
310
|
+
expected: 'fixture-2@<catalog>',
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
expect(host.assembly.current.revision).toBe(initial.revision);
|
|
314
|
+
expect(host.status).toMatchObject({ state: 'active', revision: initial.revision });
|
|
315
|
+
await host.dispose();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('delivers a backend module URL across the real WebSocket boundary', async () => {
|
|
319
|
+
const server = new WebSocketServer({ port: 0 });
|
|
320
|
+
await once(server, 'listening');
|
|
321
|
+
const address = server.address();
|
|
322
|
+
if (address === null || typeof address === 'string') throw new Error('WebSocket port missing');
|
|
323
|
+
const moduleUrl = `data:text/javascript,${encodeURIComponent(
|
|
324
|
+
"export default { name: 'dynamic-fixture', apply() {} };",
|
|
325
|
+
)}`;
|
|
326
|
+
const pair = {
|
|
327
|
+
id: 'dynamic-fixture',
|
|
328
|
+
frontend: {
|
|
329
|
+
entry: { id: 'dynamic-frontend', name: 'fixture:dynamic' },
|
|
330
|
+
module: {
|
|
331
|
+
name: 'fixture:dynamic',
|
|
332
|
+
realm: 'engine' as const,
|
|
333
|
+
version: 'fixture-1',
|
|
334
|
+
url: moduleUrl,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
const transport = createHostTransport();
|
|
339
|
+
const reports: string[] = [];
|
|
340
|
+
const backend = await createBackendHost({
|
|
341
|
+
pairs: [pair],
|
|
342
|
+
transport,
|
|
343
|
+
onActivationReport: (report) => {
|
|
344
|
+
reports.push(report.state);
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
server.on('connection', (socket) => attachHostWebSocketServer(socket, transport));
|
|
348
|
+
const client = await createHostWebSocketClient(new WebSocket(`ws://127.0.0.1:${address.port}`));
|
|
349
|
+
const frontend = await createFrontendHost({ transport: client });
|
|
350
|
+
expect(frontend.assembly.current.modules[0]?.url).toBe(moduleUrl);
|
|
351
|
+
expect(frontend.status.state).toBe('active');
|
|
352
|
+
expect(reports).toContain('active');
|
|
353
|
+
await frontend.dispose();
|
|
354
|
+
client.close();
|
|
355
|
+
await backend.dispose();
|
|
356
|
+
transport.close();
|
|
357
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('withdraws the frontend capability when its backend connection closes', async () => {
|
|
361
|
+
const plugin: Plugin = { name: 'disconnect-fixture', apply() {} };
|
|
362
|
+
const pair = {
|
|
363
|
+
id: 'disconnect-fixture',
|
|
364
|
+
backend: {
|
|
365
|
+
entry: { id: 'disconnect-backend', name: '@fixture/plugin' },
|
|
366
|
+
module: { name: '@fixture/plugin', realm: 'engine' as const, version: 'fixture-1' },
|
|
367
|
+
},
|
|
368
|
+
frontend: {
|
|
369
|
+
entry: { id: 'disconnect-frontend', name: '@fixture/plugin' },
|
|
370
|
+
module: { name: '@fixture/plugin', realm: 'engine' as const, version: 'fixture-1' },
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
const reports: string[] = [];
|
|
374
|
+
const backend = await createBackendHost({
|
|
375
|
+
catalog: catalogFor(plugin),
|
|
376
|
+
pairs: [pair],
|
|
377
|
+
});
|
|
378
|
+
const client = backend.transport.connect();
|
|
379
|
+
const frontend = await createFrontendHost({
|
|
380
|
+
catalog: catalogFor(plugin),
|
|
381
|
+
transport: client,
|
|
382
|
+
reportStatus: (status) => {
|
|
383
|
+
reports.push(status.state);
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
backend.transport.close('backend stopped');
|
|
387
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
388
|
+
expect(client.connected).toBe(false);
|
|
389
|
+
expect(frontend.status).toMatchObject({
|
|
390
|
+
state: 'failed',
|
|
391
|
+
revision: frontend.assembly.current.revision,
|
|
392
|
+
error: { code: 'host-transport-failure' },
|
|
393
|
+
});
|
|
394
|
+
expect(reports).toContain('failed');
|
|
395
|
+
await frontend.dispose();
|
|
396
|
+
await backend.dispose();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('rejects stale calls and withdraws pending services on invalidation', async () => {
|
|
400
|
+
const transport = createHostTransport();
|
|
401
|
+
let release!: () => void;
|
|
402
|
+
const pending = new Promise<void>((resolve) => {
|
|
403
|
+
release = resolve;
|
|
404
|
+
});
|
|
405
|
+
transport.register('fixture', async ({ signal }) => {
|
|
406
|
+
await Promise.race([
|
|
407
|
+
pending,
|
|
408
|
+
new Promise<never>((_, reject) =>
|
|
409
|
+
signal.addEventListener('abort', () => reject(signal.reason)),
|
|
410
|
+
),
|
|
411
|
+
]);
|
|
412
|
+
return 'done';
|
|
413
|
+
});
|
|
414
|
+
const client = transport.connect();
|
|
415
|
+
const generation = transport.snapshot('fixture')?.generation;
|
|
416
|
+
expect(generation).toBe(1);
|
|
417
|
+
if (generation === undefined) throw new Error('fixture service generation is missing');
|
|
418
|
+
const request = client.request('fixture', undefined, { generation });
|
|
419
|
+
transport.invalidate('fixture');
|
|
420
|
+
await expect(request).rejects.toBeDefined();
|
|
421
|
+
await expect(client.request('fixture', undefined, { generation })).rejects.toMatchObject({
|
|
422
|
+
code: 'host-assembly-stale-request',
|
|
423
|
+
});
|
|
424
|
+
release();
|
|
425
|
+
client.close();
|
|
426
|
+
transport.close();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('keeps service generations monotonic across replacement and disposer races', async () => {
|
|
430
|
+
const transport = createHostTransport();
|
|
431
|
+
const handler = () => 'same-handler';
|
|
432
|
+
const first = transport.register('fixture', handler);
|
|
433
|
+
const client = transport.connect();
|
|
434
|
+
const firstGeneration = transport.snapshot('fixture')?.generation;
|
|
435
|
+
expect(firstGeneration).toBe(1);
|
|
436
|
+
const second = transport.register('fixture', handler);
|
|
437
|
+
first();
|
|
438
|
+
expect(transport.snapshot('fixture')?.generation).toBe(2);
|
|
439
|
+
if (firstGeneration === undefined) throw new Error('fixture generation is missing');
|
|
440
|
+
await expect(
|
|
441
|
+
client.request('fixture', undefined, { generation: firstGeneration }),
|
|
442
|
+
).rejects.toMatchObject({
|
|
443
|
+
code: 'host-assembly-stale-request',
|
|
444
|
+
});
|
|
445
|
+
await expect(client.request('fixture', undefined)).resolves.toBe('same-handler');
|
|
446
|
+
second();
|
|
447
|
+
client.close();
|
|
448
|
+
transport.close();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('rejects a tampered assembly before plugin activation', async () => {
|
|
452
|
+
const assembly = createHostAssembly({ entries: [], modules: [] });
|
|
453
|
+
const host = await createFrontendHost({
|
|
454
|
+
autoActivate: false,
|
|
455
|
+
assembly: { ...assembly, revision: 'tampered' },
|
|
456
|
+
}).catch((error) => error);
|
|
457
|
+
expect(host).toBeInstanceOf(HostAssemblyError);
|
|
458
|
+
expect((host as HostAssemblyError).code).toBe('host-assembly-revision-mismatch');
|
|
459
|
+
const context = new Context();
|
|
460
|
+
await context.fiber.dispose();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('exercises the WebSocket host boundary with request, cancellation, and close', async () => {
|
|
464
|
+
const server = new WebSocketServer({ port: 0 });
|
|
465
|
+
await once(server, 'listening');
|
|
466
|
+
const address = server.address();
|
|
467
|
+
if (address === null || typeof address === 'string') throw new Error('WebSocket port missing');
|
|
468
|
+
const transport = createHostTransport();
|
|
469
|
+
let cancelled = false;
|
|
470
|
+
let resolveCancellation: (() => void) | undefined;
|
|
471
|
+
const cancellationObserved = new Promise<void>((resolve) => {
|
|
472
|
+
resolveCancellation = resolve;
|
|
473
|
+
});
|
|
474
|
+
transport.register('echo', ({ payload }) => payload);
|
|
475
|
+
transport.register(
|
|
476
|
+
'pending',
|
|
477
|
+
({ signal }) =>
|
|
478
|
+
new Promise<never>((_, reject) => {
|
|
479
|
+
signal.addEventListener('abort', () => {
|
|
480
|
+
cancelled = true;
|
|
481
|
+
resolveCancellation?.();
|
|
482
|
+
reject(new Error('cancelled'));
|
|
483
|
+
});
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
server.on('connection', (socket) => attachHostWebSocketServer(socket, transport));
|
|
487
|
+
const client = await createHostWebSocketClient(new WebSocket(`ws://127.0.0.1:${address.port}`));
|
|
488
|
+
await expect(client.request('echo', { ok: true })).resolves.toEqual({ ok: true });
|
|
489
|
+
const preAborted = new AbortController();
|
|
490
|
+
preAborted.abort();
|
|
491
|
+
await expect(
|
|
492
|
+
client.request('echo', undefined, { signal: preAborted.signal }),
|
|
493
|
+
).rejects.toMatchObject({ code: 'host-assembly-request-aborted' });
|
|
494
|
+
const controller = new AbortController();
|
|
495
|
+
const pending = client.request('pending', undefined, { signal: controller.signal });
|
|
496
|
+
controller.abort();
|
|
497
|
+
await expect(pending).rejects.toMatchObject({ code: 'host-assembly-request-aborted' });
|
|
498
|
+
await cancellationObserved;
|
|
499
|
+
expect(cancelled).toBe(true);
|
|
500
|
+
client.close();
|
|
501
|
+
const closeClient = await createHostWebSocketClient(
|
|
502
|
+
new WebSocket(`ws://127.0.0.1:${address.port}`),
|
|
503
|
+
);
|
|
504
|
+
const pendingOnClose = closeClient.request('pending', undefined);
|
|
505
|
+
closeClient.close('test close');
|
|
506
|
+
await expect(pendingOnClose).rejects.toMatchObject({ code: 'host-transport-failure' });
|
|
507
|
+
transport.close();
|
|
508
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
509
|
+
});
|
|
510
|
+
});
|