@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
package/dist/backend.mjs
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { Context } from '@forgeax/engine-plugin';
|
|
2
|
+
import { bootstrapCatalogLoader, projectPluginEntries } from '@forgeax/engine-plugin/loader';
|
|
3
|
+
|
|
4
|
+
// src/backend.ts
|
|
5
|
+
|
|
6
|
+
// src/protocol.ts
|
|
7
|
+
var HOST_ASSEMBLY_SCHEMA_VERSION = 1;
|
|
8
|
+
var HostAssemblyError = class extends Error {
|
|
9
|
+
code;
|
|
10
|
+
expected;
|
|
11
|
+
hint;
|
|
12
|
+
detail;
|
|
13
|
+
constructor(code, expected, hint, detail) {
|
|
14
|
+
super(`${code}: ${expected}`);
|
|
15
|
+
this.name = "HostAssemblyError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.expected = expected;
|
|
18
|
+
this.hint = hint;
|
|
19
|
+
this.detail = detail;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function assertHostModuleCatalogIdentity(module, record) {
|
|
23
|
+
if (record.version !== void 0 && record.version !== module.version) {
|
|
24
|
+
throw new HostAssemblyError(
|
|
25
|
+
"host-assembly-module-version-mismatch",
|
|
26
|
+
`module ${module.name} to load catalog version ${module.version}`,
|
|
27
|
+
"Regenerate the static Catalog from the same backend package revision.",
|
|
28
|
+
{ name: module.name, actual: record.version, expected: module.version }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (record.digest !== void 0 && module.digest !== void 0 && record.digest !== module.digest) {
|
|
32
|
+
throw new HostAssemblyError(
|
|
33
|
+
"host-assembly-module-version-mismatch",
|
|
34
|
+
`module ${module.name} to load catalog digest ${module.digest ?? "none"}`,
|
|
35
|
+
"Regenerate the static Catalog from the same backend package bytes.",
|
|
36
|
+
{ name: module.name, actual: record.digest, expected: module.digest ?? "none" }
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const versionMatches = record.version !== void 0 && record.version === module.version;
|
|
40
|
+
const digestMatches = module.digest !== void 0 && record.digest !== void 0 && record.digest === module.digest;
|
|
41
|
+
const staticWithoutIdentity = module.version === "static" && module.digest === void 0 && record.version === void 0 && record.digest === void 0;
|
|
42
|
+
if (!versionMatches && !digestMatches && !staticWithoutIdentity) {
|
|
43
|
+
throw new HostAssemblyError(
|
|
44
|
+
"host-assembly-module-version-mismatch",
|
|
45
|
+
`module ${module.name} to have a matching catalog code identity for ${module.version}`,
|
|
46
|
+
"Add the generated module version or digest to the static Catalog.",
|
|
47
|
+
{
|
|
48
|
+
name: module.name,
|
|
49
|
+
actual: record.version ?? record.digest ?? "unknown",
|
|
50
|
+
expected: module.version
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function canonicalHostJson(value) {
|
|
56
|
+
if (value === void 0) return "undefined";
|
|
57
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
58
|
+
if (Array.isArray(value)) return `[${value.map(canonicalHostJson).join(",")}]`;
|
|
59
|
+
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalHostJson(item)}`).join(",")}}`;
|
|
60
|
+
}
|
|
61
|
+
function hostRevision(value) {
|
|
62
|
+
let hash = 2166136261;
|
|
63
|
+
for (const char of canonicalHostJson(value)) {
|
|
64
|
+
hash ^= char.codePointAt(0) ?? 0;
|
|
65
|
+
hash = Math.imul(hash, 16777619);
|
|
66
|
+
}
|
|
67
|
+
return `fnv1a:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
68
|
+
}
|
|
69
|
+
function cloneEntry(entry) {
|
|
70
|
+
const config = entry.group ? entry.config?.map(cloneEntry) : entry.config;
|
|
71
|
+
return {
|
|
72
|
+
id: entry.id,
|
|
73
|
+
name: entry.name,
|
|
74
|
+
...config === void 0 ? {} : { config },
|
|
75
|
+
...entry.group === void 0 ? {} : { group: entry.group },
|
|
76
|
+
...entry.disabled === void 0 ? {} : { disabled: entry.disabled },
|
|
77
|
+
...entry.inject === void 0 ? {} : { inject: entry.inject },
|
|
78
|
+
...entry.realm === void 0 ? {} : { realm: entry.realm }
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function projectPairs(pairs) {
|
|
82
|
+
const entries = [];
|
|
83
|
+
const modules = [];
|
|
84
|
+
const projections = [];
|
|
85
|
+
for (const pair of pairs) {
|
|
86
|
+
if (pair.backend !== void 0 && pair.frontend !== void 0 && pair.backend.module.version !== pair.frontend.module.version) {
|
|
87
|
+
throw new HostAssemblyError(
|
|
88
|
+
"host-assembly-module-version-mismatch",
|
|
89
|
+
`paired module ${pair.id} to use one code version on both hosts`,
|
|
90
|
+
"Resolve backend and frontend package entries from the same locked package revision.",
|
|
91
|
+
{
|
|
92
|
+
name: pair.id,
|
|
93
|
+
actual: pair.backend.module.version,
|
|
94
|
+
expected: pair.frontend.module.version
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (pair.frontend === void 0) continue;
|
|
99
|
+
entries.push(pair.frontend.entry);
|
|
100
|
+
modules.push(pair.frontend.module);
|
|
101
|
+
projections.push({
|
|
102
|
+
id: pair.id,
|
|
103
|
+
entryId: pair.frontend.entry.id,
|
|
104
|
+
module: pair.frontend.module
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { entries, modules, projections };
|
|
108
|
+
}
|
|
109
|
+
function assemblyPairsFromInput(input) {
|
|
110
|
+
if (input.pairs !== void 0) {
|
|
111
|
+
const projected = projectPairs(input.pairs);
|
|
112
|
+
return {
|
|
113
|
+
entries: input.entries ?? projected.entries,
|
|
114
|
+
modules: input.modules ?? projected.modules,
|
|
115
|
+
pairs: projected.projections
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const entries = input.entries ?? [];
|
|
119
|
+
const modules = [...input.modules ?? []];
|
|
120
|
+
for (const [index, entry] of entries.entries()) {
|
|
121
|
+
if (modules[index] !== void 0 || modules.some((module) => module.name === entry.name))
|
|
122
|
+
continue;
|
|
123
|
+
modules.push({
|
|
124
|
+
name: entry.name,
|
|
125
|
+
realm: entry.realm ?? "engine",
|
|
126
|
+
version: "unknown"
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
entries,
|
|
131
|
+
modules,
|
|
132
|
+
pairs: entries.map((entry, index) => ({
|
|
133
|
+
id: entry.id,
|
|
134
|
+
entryId: entry.id,
|
|
135
|
+
module: modules[index]?.name === entry.name ? modules[index] : modules.find((module) => module.name === entry.name) ?? {
|
|
136
|
+
name: entry.name,
|
|
137
|
+
realm: entry.realm ?? "engine",
|
|
138
|
+
version: "unknown"
|
|
139
|
+
}
|
|
140
|
+
}))
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function createHostAssembly(input) {
|
|
144
|
+
const projected = assemblyPairsFromInput(input);
|
|
145
|
+
const entries = projected.entries.map(cloneEntry);
|
|
146
|
+
const modules = projected.modules.map((module) => ({ ...module }));
|
|
147
|
+
const pairs = projected.pairs.map((pair) => ({ ...pair, module: { ...pair.module } }));
|
|
148
|
+
const identity = {
|
|
149
|
+
schemaVersion: HOST_ASSEMBLY_SCHEMA_VERSION,
|
|
150
|
+
entries,
|
|
151
|
+
modules,
|
|
152
|
+
pairs,
|
|
153
|
+
...input.config === void 0 ? {} : { config: input.config }
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
...identity,
|
|
157
|
+
revision: hostRevision(identity)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function validateEntry(entry, path) {
|
|
161
|
+
if (entry === null || typeof entry !== "object") return `${path} must be an Entry object`;
|
|
162
|
+
if (typeof entry.id !== "string" || entry.id.length === 0) return `${path}.id must be non-empty`;
|
|
163
|
+
if (typeof entry.name !== "string" || entry.name.length === 0)
|
|
164
|
+
return `${path}.name must be non-empty`;
|
|
165
|
+
if (entry.group === true) {
|
|
166
|
+
if (!Array.isArray(entry.config)) return `${path}.config must be an Entry array for a Group`;
|
|
167
|
+
for (const [index, child] of entry.config.entries()) {
|
|
168
|
+
const reason = validateEntry(child, `${path}.config[${index}]`);
|
|
169
|
+
if (reason !== void 0) return reason;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return void 0;
|
|
173
|
+
}
|
|
174
|
+
function validateHostAssembly(assembly) {
|
|
175
|
+
if (assembly === null || typeof assembly !== "object") {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: new HostAssemblyError(
|
|
179
|
+
"host-assembly-invalid",
|
|
180
|
+
"assembly to be an object",
|
|
181
|
+
"Regenerate the frontend assembly from the active backend authority.",
|
|
182
|
+
{ reason: "assembly is not an object" }
|
|
183
|
+
)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (assembly.schemaVersion !== HOST_ASSEMBLY_SCHEMA_VERSION) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: new HostAssemblyError(
|
|
190
|
+
"host-assembly-invalid",
|
|
191
|
+
`assembly schema ${HOST_ASSEMBLY_SCHEMA_VERSION}`,
|
|
192
|
+
"Regenerate the frontend assembly with the matching Engine host package.",
|
|
193
|
+
{ reason: `unsupported schema ${String(assembly.schemaVersion)}` }
|
|
194
|
+
)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (!Array.isArray(assembly.entries) || !Array.isArray(assembly.modules)) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
error: new HostAssemblyError(
|
|
201
|
+
"host-assembly-invalid",
|
|
202
|
+
"assembly entries and modules to be arrays",
|
|
203
|
+
"Regenerate the frontend assembly from the active backend authority.",
|
|
204
|
+
{ reason: "entries or modules is not an array" }
|
|
205
|
+
)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!Array.isArray(assembly.pairs)) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: new HostAssemblyError(
|
|
212
|
+
"host-assembly-invalid",
|
|
213
|
+
"assembly pairs to be an array",
|
|
214
|
+
"Regenerate the assembly from the backend host using the matching host package.",
|
|
215
|
+
{ reason: "pairs is not an array" }
|
|
216
|
+
)
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const seen = /* @__PURE__ */ new Set();
|
|
220
|
+
for (const [index, entry] of assembly.entries.entries()) {
|
|
221
|
+
const reason = validateEntry(entry, `entries[${index}]`);
|
|
222
|
+
if (reason !== void 0) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
error: new HostAssemblyError(
|
|
226
|
+
"host-assembly-invalid",
|
|
227
|
+
"all assembly entries to be valid native EntryOptions",
|
|
228
|
+
"Repair the backend Entry projection before publishing it to a browser.",
|
|
229
|
+
{ reason }
|
|
230
|
+
)
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (seen.has(entry.id)) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
error: new HostAssemblyError(
|
|
237
|
+
"host-assembly-invalid",
|
|
238
|
+
"assembly Entry ids to be unique",
|
|
239
|
+
"Give repeated plugin instances independent stable ids.",
|
|
240
|
+
{ reason: `duplicate Entry id ${entry.id}` }
|
|
241
|
+
)
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
seen.add(entry.id);
|
|
245
|
+
}
|
|
246
|
+
const seenModules = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const [index, module] of assembly.modules.entries()) {
|
|
248
|
+
if (module === null || typeof module !== "object" || typeof module.name !== "string" || typeof module.realm !== "string" || typeof module.version !== "string" || module.name.length === 0 || module.version.length === 0 || module.url !== void 0 && typeof module.url !== "string" || module.digest !== void 0 && (typeof module.digest !== "string" || module.digest.length === 0)) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
error: new HostAssemblyError(
|
|
252
|
+
"host-assembly-invalid",
|
|
253
|
+
"module names and versions to be non-empty",
|
|
254
|
+
"Regenerate the module projection from the resolved package metadata.",
|
|
255
|
+
{ reason: `invalid module at index ${index}` }
|
|
256
|
+
)
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const previousModule = seenModules.get(module.name);
|
|
260
|
+
if (previousModule !== void 0 && (previousModule.realm !== module.realm || previousModule.version !== module.version || previousModule.url !== module.url || previousModule.digest !== module.digest)) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
error: new HostAssemblyError(
|
|
264
|
+
"host-assembly-invalid",
|
|
265
|
+
"assembly module identity to be consistent for repeated Entries",
|
|
266
|
+
"Reuse one resolved module identity when a package has multiple Entry instances.",
|
|
267
|
+
{ reason: `module ${module.name} has conflicting identities` }
|
|
268
|
+
)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
seenModules.set(module.name, module);
|
|
272
|
+
}
|
|
273
|
+
const seenPairIds = /* @__PURE__ */ new Set();
|
|
274
|
+
for (const [index, pair] of assembly.pairs.entries()) {
|
|
275
|
+
const pairModule = pair?.module;
|
|
276
|
+
if (pair === null || typeof pair !== "object" || typeof pair.id !== "string" || pair.id.length === 0 || typeof pair.entryId !== "string" || pair.entryId.length === 0 || pairModule === null || pairModule === void 0 || typeof pairModule !== "object" || typeof pairModule.name !== "string" || typeof pairModule.realm !== "string" || typeof pairModule.version !== "string" || pairModule.name.length === 0 || pairModule.version.length === 0 || pairModule.url !== void 0 && typeof pairModule.url !== "string" || pairModule.digest !== void 0 && (typeof pairModule.digest !== "string" || pairModule.digest.length === 0)) {
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
error: new HostAssemblyError(
|
|
280
|
+
"host-assembly-invalid",
|
|
281
|
+
"assembly pair identities and modules to be valid",
|
|
282
|
+
"Regenerate paired frontend entries from the backend package manifest.",
|
|
283
|
+
{ reason: `invalid pair at index ${index}` }
|
|
284
|
+
)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (seenPairIds.has(pair.id)) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
error: new HostAssemblyError(
|
|
291
|
+
"host-assembly-invalid",
|
|
292
|
+
"assembly pair ids to be unique",
|
|
293
|
+
"Give each paired package instance an independent stable id.",
|
|
294
|
+
{ reason: `duplicate pair ${pair.id}` }
|
|
295
|
+
)
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
seenPairIds.add(pair.id);
|
|
299
|
+
if (!assembly.entries.some((entry) => entry.id === pair.entryId)) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
error: new HostAssemblyError(
|
|
303
|
+
"host-assembly-invalid",
|
|
304
|
+
`pair ${pair.id} to reference an assembly Entry`,
|
|
305
|
+
"Keep paired identity and frontend Entry projection under one backend authority.",
|
|
306
|
+
{ reason: `missing frontend Entry ${pair.entryId}` }
|
|
307
|
+
)
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const module = assembly.modules.find(
|
|
311
|
+
(candidate) => candidate.name === pairModule.name && candidate.realm === pairModule.realm && candidate.version === pairModule.version && candidate.url === pairModule.url && candidate.digest === pairModule.digest
|
|
312
|
+
);
|
|
313
|
+
if (module === void 0) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
error: new HostAssemblyError(
|
|
317
|
+
"host-assembly-invalid",
|
|
318
|
+
`pair ${pair.id} to reference a resolved frontend module`,
|
|
319
|
+
"Keep module/code identity in the backend-derived assembly projection.",
|
|
320
|
+
{ reason: `missing frontend module ${pairModule.name}` }
|
|
321
|
+
)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const expected = hostRevision({
|
|
326
|
+
schemaVersion: assembly.schemaVersion,
|
|
327
|
+
entries: assembly.entries,
|
|
328
|
+
modules: assembly.modules,
|
|
329
|
+
pairs: assembly.pairs,
|
|
330
|
+
...assembly.config === void 0 ? {} : { config: assembly.config }
|
|
331
|
+
});
|
|
332
|
+
if (expected !== assembly.revision) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: new HostAssemblyError(
|
|
336
|
+
"host-assembly-revision-mismatch",
|
|
337
|
+
"assembly revision to match its entries and modules",
|
|
338
|
+
"Discard the stale response and request the current backend assembly again.",
|
|
339
|
+
{ actual: assembly.revision, expected }
|
|
340
|
+
)
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return { ok: true, value: assembly };
|
|
344
|
+
}
|
|
345
|
+
function modulesFromCatalog(catalog, realm, version = "static") {
|
|
346
|
+
return [...catalog.entries()].filter(([, record]) => record.realm === realm).map(([name, record]) => ({
|
|
347
|
+
name,
|
|
348
|
+
realm,
|
|
349
|
+
version: record.version ?? version,
|
|
350
|
+
...record.digest === void 0 ? {} : { digest: record.digest }
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/transport.ts
|
|
355
|
+
var HOST_ASSEMBLY_SERVICE = "host/assembly.get";
|
|
356
|
+
var HOST_ACTIVATION_REPORT_SERVICE = "host/assembly.report";
|
|
357
|
+
var HOST_ASSEMBLY_CHANGED_TOPIC = "host/assembly.changed";
|
|
358
|
+
function createHostTransport() {
|
|
359
|
+
const services = /* @__PURE__ */ new Map();
|
|
360
|
+
const generations = /* @__PURE__ */ new Map();
|
|
361
|
+
const clients = /* @__PURE__ */ new Set();
|
|
362
|
+
let closed = false;
|
|
363
|
+
const closeClient = (state, reason) => {
|
|
364
|
+
if (!state.connected) return;
|
|
365
|
+
state.connected = false;
|
|
366
|
+
for (const controller of state.pending) controller.abort();
|
|
367
|
+
state.pending.clear();
|
|
368
|
+
for (const callbacks of state.subscriptions.values()) callbacks.clear();
|
|
369
|
+
state.subscriptions.clear();
|
|
370
|
+
clients.delete(state);
|
|
371
|
+
const error = new HostAssemblyError(
|
|
372
|
+
"host-transport-failure",
|
|
373
|
+
"the host connection to remain available",
|
|
374
|
+
"Reconnect the frontend host before issuing another request.",
|
|
375
|
+
{ service: "host/socket", reason: String(reason ?? "host connection closed") }
|
|
376
|
+
);
|
|
377
|
+
for (const listener of state.disconnectListeners) listener(error);
|
|
378
|
+
state.disconnectListeners.clear();
|
|
379
|
+
};
|
|
380
|
+
const server = {
|
|
381
|
+
get connectedClients() {
|
|
382
|
+
return clients.size;
|
|
383
|
+
},
|
|
384
|
+
register(service, handler) {
|
|
385
|
+
if (closed) throw new Error("host transport is closed");
|
|
386
|
+
const previous = services.get(service);
|
|
387
|
+
const generation = (generations.get(service) ?? 0) + 1;
|
|
388
|
+
generations.set(service, generation);
|
|
389
|
+
for (const controller of previous?.pending ?? []) controller.abort();
|
|
390
|
+
const identity = Symbol(service);
|
|
391
|
+
services.set(service, {
|
|
392
|
+
identity,
|
|
393
|
+
generation,
|
|
394
|
+
handler,
|
|
395
|
+
pending: /* @__PURE__ */ new Set()
|
|
396
|
+
});
|
|
397
|
+
return () => {
|
|
398
|
+
const current = services.get(service);
|
|
399
|
+
if (current?.identity !== identity) return;
|
|
400
|
+
for (const controller of current.pending) controller.abort();
|
|
401
|
+
generations.set(service, current.generation + 1);
|
|
402
|
+
services.delete(service);
|
|
403
|
+
};
|
|
404
|
+
},
|
|
405
|
+
invalidate(service) {
|
|
406
|
+
const current = services.get(service);
|
|
407
|
+
if (current === void 0) return;
|
|
408
|
+
for (const controller of current.pending) controller.abort();
|
|
409
|
+
generations.set(service, current.generation + 1);
|
|
410
|
+
services.delete(service);
|
|
411
|
+
},
|
|
412
|
+
snapshot(service) {
|
|
413
|
+
const current = services.get(service);
|
|
414
|
+
return current === void 0 ? void 0 : { service, generation: current.generation };
|
|
415
|
+
},
|
|
416
|
+
publish(topic, payload) {
|
|
417
|
+
for (const state of clients) {
|
|
418
|
+
for (const listener of state.subscriptions.get(topic) ?? []) listener(payload);
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
connect() {
|
|
422
|
+
if (closed) throw new Error("host transport is closed");
|
|
423
|
+
const state = {
|
|
424
|
+
connected: true,
|
|
425
|
+
subscriptions: /* @__PURE__ */ new Map(),
|
|
426
|
+
pending: /* @__PURE__ */ new Set(),
|
|
427
|
+
disconnectListeners: /* @__PURE__ */ new Set()
|
|
428
|
+
};
|
|
429
|
+
clients.add(state);
|
|
430
|
+
const client = {
|
|
431
|
+
get connected() {
|
|
432
|
+
return state.connected;
|
|
433
|
+
},
|
|
434
|
+
get generation() {
|
|
435
|
+
let current = 0;
|
|
436
|
+
for (const service of services.values()) current = Math.max(current, service.generation);
|
|
437
|
+
return current;
|
|
438
|
+
},
|
|
439
|
+
async request(service, payload, options = {}) {
|
|
440
|
+
if (!state.connected) {
|
|
441
|
+
throw new HostAssemblyError(
|
|
442
|
+
"host-assembly-service-unavailable",
|
|
443
|
+
`service ${service} to be available on the current connection`,
|
|
444
|
+
"Reconnect the frontend host before issuing another request.",
|
|
445
|
+
{ service }
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (options.signal?.aborted) {
|
|
449
|
+
throw new HostAssemblyError(
|
|
450
|
+
"host-assembly-request-aborted",
|
|
451
|
+
`request ${service} to start with a live AbortSignal`,
|
|
452
|
+
"Start a fresh request with a live AbortSignal.",
|
|
453
|
+
{ service }
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
const record = services.get(service);
|
|
457
|
+
if (record === void 0) {
|
|
458
|
+
const invalidatedGeneration = generations.get(service);
|
|
459
|
+
if (options.generation !== void 0 && invalidatedGeneration !== void 0 && options.generation < invalidatedGeneration) {
|
|
460
|
+
throw new HostAssemblyError(
|
|
461
|
+
"host-assembly-stale-request",
|
|
462
|
+
`request generation ${options.generation} to match the invalidated service ${service}`,
|
|
463
|
+
"Refresh the client capability after the backend service is enabled again.",
|
|
464
|
+
{ service, generation: options.generation }
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
throw new HostAssemblyError(
|
|
468
|
+
"host-assembly-service-unavailable",
|
|
469
|
+
`service ${service} to be registered`,
|
|
470
|
+
"Wait for the backend plugin to activate before using this capability.",
|
|
471
|
+
{ service }
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const generation = options.generation ?? record.generation;
|
|
475
|
+
if (generation !== record.generation) {
|
|
476
|
+
throw new HostAssemblyError(
|
|
477
|
+
"host-assembly-stale-request",
|
|
478
|
+
`request generation ${generation} to match service ${service}`,
|
|
479
|
+
"Refresh the client capability and retry only when the owning business contract permits it.",
|
|
480
|
+
{ service, generation }
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const controller = new AbortController();
|
|
484
|
+
const abortFromCaller = () => controller.abort();
|
|
485
|
+
options.signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
486
|
+
record.pending.add(controller);
|
|
487
|
+
state.pending.add(controller);
|
|
488
|
+
let removeAbortRequest;
|
|
489
|
+
try {
|
|
490
|
+
if (controller.signal.aborted) {
|
|
491
|
+
throw new HostAssemblyError(
|
|
492
|
+
"host-assembly-request-aborted",
|
|
493
|
+
`request ${service} not to be aborted before execution`,
|
|
494
|
+
"Start a fresh request with a live AbortSignal.",
|
|
495
|
+
{ service }
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
let abortReject;
|
|
499
|
+
const aborted = new Promise((_, reject) => {
|
|
500
|
+
abortReject = reject;
|
|
501
|
+
});
|
|
502
|
+
const abortRequest = () => {
|
|
503
|
+
abortReject?.(
|
|
504
|
+
new HostAssemblyError(
|
|
505
|
+
"host-assembly-request-aborted",
|
|
506
|
+
`request ${service} to finish before its service is closed or invalidated`,
|
|
507
|
+
"Treat the capability as withdrawn and refresh the service before retrying.",
|
|
508
|
+
{ service }
|
|
509
|
+
)
|
|
510
|
+
);
|
|
511
|
+
};
|
|
512
|
+
controller.signal.addEventListener("abort", abortRequest, { once: true });
|
|
513
|
+
removeAbortRequest = () => controller.signal.removeEventListener("abort", abortRequest);
|
|
514
|
+
const result = await Promise.race([
|
|
515
|
+
Promise.resolve(
|
|
516
|
+
record.handler({
|
|
517
|
+
service,
|
|
518
|
+
payload,
|
|
519
|
+
generation,
|
|
520
|
+
signal: controller.signal
|
|
521
|
+
})
|
|
522
|
+
),
|
|
523
|
+
aborted
|
|
524
|
+
]);
|
|
525
|
+
if (controller.signal.aborted) {
|
|
526
|
+
throw new HostAssemblyError(
|
|
527
|
+
"host-assembly-request-aborted",
|
|
528
|
+
`request ${service} to finish before its service is invalidated`,
|
|
529
|
+
"Treat the capability as withdrawn and refresh the service before retrying.",
|
|
530
|
+
{ service }
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return result;
|
|
534
|
+
} finally {
|
|
535
|
+
removeAbortRequest?.();
|
|
536
|
+
record.pending.delete(controller);
|
|
537
|
+
state.pending.delete(controller);
|
|
538
|
+
options.signal?.removeEventListener("abort", abortFromCaller);
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
subscribe(topic, listener) {
|
|
542
|
+
if (!state.connected) return () => {
|
|
543
|
+
};
|
|
544
|
+
const callbacks = state.subscriptions.get(topic) ?? /* @__PURE__ */ new Set();
|
|
545
|
+
callbacks.add(listener);
|
|
546
|
+
state.subscriptions.set(topic, callbacks);
|
|
547
|
+
return () => {
|
|
548
|
+
callbacks.delete(listener);
|
|
549
|
+
if (callbacks.size === 0) state.subscriptions.delete(topic);
|
|
550
|
+
};
|
|
551
|
+
},
|
|
552
|
+
onDisconnect(listener) {
|
|
553
|
+
if (!state.connected) {
|
|
554
|
+
listener(
|
|
555
|
+
new HostAssemblyError(
|
|
556
|
+
"host-transport-failure",
|
|
557
|
+
"the host connection to remain available",
|
|
558
|
+
"Reconnect the frontend host before issuing another request.",
|
|
559
|
+
{ service: "host/socket", reason: "host connection is already closed" }
|
|
560
|
+
)
|
|
561
|
+
);
|
|
562
|
+
return () => {
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
state.disconnectListeners.add(listener);
|
|
566
|
+
return () => state.disconnectListeners.delete(listener);
|
|
567
|
+
},
|
|
568
|
+
close(reason) {
|
|
569
|
+
closeClient(state, reason);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
return client;
|
|
573
|
+
},
|
|
574
|
+
close(reason) {
|
|
575
|
+
if (closed) return;
|
|
576
|
+
closed = true;
|
|
577
|
+
for (const service of services.values())
|
|
578
|
+
for (const controller of service.pending) controller.abort();
|
|
579
|
+
services.clear();
|
|
580
|
+
for (const client of [...clients]) closeClient(client, reason);
|
|
581
|
+
generations.clear();
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
return server;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/backend.ts
|
|
588
|
+
function effectiveAssemblyInput(current, input) {
|
|
589
|
+
const preservePairs = input.pairs === void 0 && input.entries === void 0 && input.modules === void 0;
|
|
590
|
+
const entriesById = new Map(current.entries.map((entry) => [entry.id, entry]));
|
|
591
|
+
const modulesByName = new Map(current.modules.map((module) => [module.name, module]));
|
|
592
|
+
const pairs = current.pairs.flatMap((pair) => {
|
|
593
|
+
const entry = entriesById.get(pair.entryId);
|
|
594
|
+
const module = modulesByName.get(pair.module.name);
|
|
595
|
+
return entry === void 0 || module === void 0 ? [] : [{ id: pair.id, frontend: { entry, module } }];
|
|
596
|
+
});
|
|
597
|
+
return {
|
|
598
|
+
entries: input.entries ?? current.entries,
|
|
599
|
+
modules: input.modules ?? current.modules,
|
|
600
|
+
...input.pairs !== void 0 ? { pairs: input.pairs } : preservePairs ? { pairs } : {},
|
|
601
|
+
...input.config === void 0 ? current.config === void 0 ? {} : { config: current.config } : { config: input.config },
|
|
602
|
+
...input.backendEntries === void 0 ? {} : { backendEntries: input.backendEntries }
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function authorityOf(initial) {
|
|
606
|
+
let current = initial;
|
|
607
|
+
let generation = 1;
|
|
608
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
609
|
+
const authority = {
|
|
610
|
+
get current() {
|
|
611
|
+
return current;
|
|
612
|
+
},
|
|
613
|
+
get generation() {
|
|
614
|
+
return generation;
|
|
615
|
+
},
|
|
616
|
+
subscribe(listener) {
|
|
617
|
+
listeners.add(listener);
|
|
618
|
+
return () => listeners.delete(listener);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
return {
|
|
622
|
+
authority,
|
|
623
|
+
publish(input) {
|
|
624
|
+
const next = createHostAssembly(input);
|
|
625
|
+
const checked = validateHostAssembly(next);
|
|
626
|
+
if (!checked.ok) throw checked.error;
|
|
627
|
+
current = checked.value;
|
|
628
|
+
generation += 1;
|
|
629
|
+
for (const listener of listeners) listener(current);
|
|
630
|
+
return current;
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
function hostFoundationPlugin(assembly, transport) {
|
|
635
|
+
return {
|
|
636
|
+
name: "forgeax:backend-host-foundation",
|
|
637
|
+
provide: ["hostAssembly", "hostTransport"],
|
|
638
|
+
apply(ctx) {
|
|
639
|
+
ctx.provide("hostAssembly", assembly);
|
|
640
|
+
ctx.provide("hostTransport", transport);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
function assertBackendCatalogIdentity(catalog, modules) {
|
|
645
|
+
if (catalog === void 0) return;
|
|
646
|
+
for (const module of modules) {
|
|
647
|
+
const record = catalog.get(module.name);
|
|
648
|
+
if (record !== void 0) assertHostModuleCatalogIdentity(module, record);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async function createBackendHost(options = {}) {
|
|
652
|
+
const context = options.context ?? new Context();
|
|
653
|
+
const ownedContext = options.context === void 0;
|
|
654
|
+
const realm = options.realm ?? "engine";
|
|
655
|
+
const catalog = options.catalog;
|
|
656
|
+
const pairedBackendEntries = options.pairs?.flatMap((pair) => pair.backend === void 0 ? [] : [pair.backend.entry]) ?? [];
|
|
657
|
+
const entries = options.entries ?? options.assembly?.entries ?? options.pairs?.flatMap((pair) => pair.frontend === void 0 ? [] : [pair.frontend.entry]) ?? [];
|
|
658
|
+
const modules = options.modules ?? options.assembly?.modules ?? (options.pairs === void 0 ? catalog === void 0 ? [] : modulesFromCatalog(catalog, realm) : options.pairs.flatMap(
|
|
659
|
+
(pair) => pair.frontend === void 0 ? [] : [pair.frontend.module]
|
|
660
|
+
));
|
|
661
|
+
const initialAssembly = options.assembly ?? createHostAssembly({
|
|
662
|
+
entries,
|
|
663
|
+
modules,
|
|
664
|
+
...options.pairs === void 0 ? {} : { pairs: options.pairs },
|
|
665
|
+
...options.config === void 0 ? {} : { config: options.config }
|
|
666
|
+
});
|
|
667
|
+
const checkedInitial = validateHostAssembly(initialAssembly);
|
|
668
|
+
if (!checkedInitial.ok) {
|
|
669
|
+
if (ownedContext) await context.fiber.dispose();
|
|
670
|
+
throw checkedInitial.error;
|
|
671
|
+
}
|
|
672
|
+
const backendModules = options.pairs === void 0 ? modules : options.pairs.flatMap((pair) => pair.backend === void 0 ? [] : [pair.backend.module]);
|
|
673
|
+
assertBackendCatalogIdentity(catalog, backendModules);
|
|
674
|
+
const authority = authorityOf(checkedInitial.value);
|
|
675
|
+
const assembly = authority.authority;
|
|
676
|
+
const bootstrapRevision = checkedInitial.value.revision;
|
|
677
|
+
const transport = options.transport ?? createHostTransport();
|
|
678
|
+
let foundationFiber;
|
|
679
|
+
let loaderFiber;
|
|
680
|
+
let loader;
|
|
681
|
+
let backendEntries = options.pairs === void 0 ? options.entries ?? options.assembly?.entries ?? [] : pairedBackendEntries;
|
|
682
|
+
const startupFibers = [];
|
|
683
|
+
let unregisterAssemblyService;
|
|
684
|
+
let unregisterActivationService;
|
|
685
|
+
let unsubscribeAssembly;
|
|
686
|
+
try {
|
|
687
|
+
foundationFiber = await context.plugin(hostFoundationPlugin(assembly, transport));
|
|
688
|
+
for (const plugin of options.startupPlugins ?? [])
|
|
689
|
+
startupFibers.push(await context.plugin(plugin));
|
|
690
|
+
if (catalog !== void 0) {
|
|
691
|
+
const bootstrapped = await bootstrapCatalogLoader(context, catalog, realm, {
|
|
692
|
+
catalogDigest: checkedInitial.value.revision,
|
|
693
|
+
supportedRealms: [realm]
|
|
694
|
+
});
|
|
695
|
+
if (!bootstrapped.ok) throw bootstrapped.error;
|
|
696
|
+
loaderFiber = bootstrapped.value.fiber;
|
|
697
|
+
loader = bootstrapped.value.loader;
|
|
698
|
+
if (backendEntries.length > 0) {
|
|
699
|
+
await loader.root.update(projectPluginEntries(backendEntries, realm, realm));
|
|
700
|
+
await loader.await();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
unregisterAssemblyService = transport.register(HOST_ASSEMBLY_SERVICE, () => assembly.current);
|
|
704
|
+
unregisterActivationService = transport.register(
|
|
705
|
+
HOST_ACTIVATION_REPORT_SERVICE,
|
|
706
|
+
async ({ payload }) => {
|
|
707
|
+
const report = payload;
|
|
708
|
+
if (report.revision !== assembly.current.revision && report.revision !== bootstrapRevision) {
|
|
709
|
+
throw new HostAssemblyError(
|
|
710
|
+
"host-assembly-revision-mismatch",
|
|
711
|
+
`activation report revision ${report.revision} to match ${assembly.current.revision}`,
|
|
712
|
+
"Discard the stale frontend report and fetch the current backend assembly.",
|
|
713
|
+
{ actual: report.revision, expected: assembly.current.revision }
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
await options.onActivationReport?.(report);
|
|
717
|
+
return { accepted: true };
|
|
718
|
+
}
|
|
719
|
+
);
|
|
720
|
+
unsubscribeAssembly = assembly.subscribe((next) => {
|
|
721
|
+
transport.publish(HOST_ASSEMBLY_CHANGED_TOPIC, next);
|
|
722
|
+
});
|
|
723
|
+
} catch (error) {
|
|
724
|
+
unregisterActivationService?.();
|
|
725
|
+
unregisterAssemblyService?.();
|
|
726
|
+
unsubscribeAssembly?.();
|
|
727
|
+
await loaderFiber?.dispose();
|
|
728
|
+
for (const fiber of startupFibers.reverse()) await fiber.dispose();
|
|
729
|
+
await foundationFiber?.dispose();
|
|
730
|
+
if (ownedContext) await context.fiber.dispose();
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
let disposed = false;
|
|
734
|
+
const host = {
|
|
735
|
+
context,
|
|
736
|
+
...loader === void 0 ? {} : { loader },
|
|
737
|
+
assembly,
|
|
738
|
+
transport,
|
|
739
|
+
ownedContext,
|
|
740
|
+
async update(nextInput) {
|
|
741
|
+
if (disposed) {
|
|
742
|
+
throw new HostAssemblyError(
|
|
743
|
+
"host-assembly-service-unavailable",
|
|
744
|
+
"backend host to remain active while updating Entries",
|
|
745
|
+
"Create a new host instance before updating the disposed host.",
|
|
746
|
+
{ service: "host-assembly" }
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
const input = Array.isArray(nextInput) ? {
|
|
750
|
+
entries: nextInput,
|
|
751
|
+
backendEntries: nextInput
|
|
752
|
+
} : nextInput;
|
|
753
|
+
const effectiveInput = effectiveAssemblyInput(assembly.current, input);
|
|
754
|
+
const candidate = createHostAssembly(effectiveInput);
|
|
755
|
+
const checkedCandidate = validateHostAssembly(candidate);
|
|
756
|
+
if (!checkedCandidate.ok) throw checkedCandidate.error;
|
|
757
|
+
const candidateBackendModules = input.pairs === void 0 ? checkedCandidate.value.modules : input.pairs.flatMap(
|
|
758
|
+
(pair) => pair.backend === void 0 ? [] : [pair.backend.module]
|
|
759
|
+
);
|
|
760
|
+
assertBackendCatalogIdentity(catalog, candidateBackendModules);
|
|
761
|
+
const nextBackendEntries = input.backendEntries ?? (input.pairs === void 0 ? input.entries ?? backendEntries : input.pairs.flatMap(
|
|
762
|
+
(pair) => pair.backend === void 0 ? [] : [pair.backend.entry]
|
|
763
|
+
));
|
|
764
|
+
if (loader === void 0 && nextBackendEntries.length > 0) {
|
|
765
|
+
throw new HostAssemblyError(
|
|
766
|
+
"host-assembly-service-unavailable",
|
|
767
|
+
"a CatalogLoader to be installed before updating Entries",
|
|
768
|
+
"Provide a backend Catalog when the host owns plugin Entry activation.",
|
|
769
|
+
{ service: "loader" }
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
if (loader !== void 0) {
|
|
773
|
+
await loader.root.update(projectPluginEntries(nextBackendEntries, realm, realm));
|
|
774
|
+
await loader.await();
|
|
775
|
+
}
|
|
776
|
+
backendEntries = nextBackendEntries;
|
|
777
|
+
return authority.publish(effectiveInput);
|
|
778
|
+
},
|
|
779
|
+
async dispose() {
|
|
780
|
+
if (disposed) return;
|
|
781
|
+
disposed = true;
|
|
782
|
+
unsubscribeAssembly?.();
|
|
783
|
+
unregisterActivationService?.();
|
|
784
|
+
unregisterAssemblyService?.();
|
|
785
|
+
transport.close();
|
|
786
|
+
await loaderFiber?.dispose();
|
|
787
|
+
for (const fiber of startupFibers.reverse()) await fiber.dispose();
|
|
788
|
+
await foundationFiber?.dispose();
|
|
789
|
+
if (ownedContext) await context.fiber.dispose();
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
return host;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export { HostAssemblyError, createBackendHost };
|
|
796
|
+
//# sourceMappingURL=backend.mjs.map
|
|
797
|
+
//# sourceMappingURL=backend.mjs.map
|