gesso-framework 0.6.6 → 0.6.8
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/CHANGELOG.md +18 -0
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +3 -3
- package/dist/{app-BvlIO1G9.js → app-bMsNOs1H.js} +2 -2
- package/dist/{app-BvlIO1G9.js.map → app-bMsNOs1H.js.map} +1 -1
- package/dist/{index-B8PU7Cf0.d.ts → index-D5bW2GUt.d.ts} +16 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +10 -8
- package/dist/index.js.map +1 -1
- package/dist/{persisted-pix1fS1D.js → persisted-i6TIWX8R.js} +5 -952
- package/dist/persisted-i6TIWX8R.js.map +1 -0
- package/dist/remote/index.d.ts +198 -0
- package/dist/remote/index.js +301 -0
- package/dist/remote/index.js.map +1 -0
- package/dist/{remote-xxij8zXe.js → remote-gRdwYo-q.js} +19 -4
- package/dist/remote-gRdwYo-q.js.map +1 -0
- package/dist/serveChannels-Cs22-p8X.js +952 -0
- package/dist/serveChannels-Cs22-p8X.js.map +1 -0
- package/dist/{ui-U39HjNFA.d.ts → ui-Bs6ywsAA.d.ts} +3 -2
- package/dist/{webmcp-CJVFvAHe.js → webmcp-F3OOy73w.js} +2 -2
- package/dist/{webmcp-CJVFvAHe.js.map → webmcp-F3OOy73w.js.map} +1 -1
- package/dist/worker/index.d.ts +2 -2
- package/dist/worker/index.js +2 -1
- package/package.json +6 -2
- package/dist/persisted-pix1fS1D.js.map +0 -1
- package/dist/remote-xxij8zXe.js.map +0 -1
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import { a as agentSurface, i as serveAgentPort } from "./remote-gRdwYo-q.js";
|
|
2
|
+
import { Subscription } from "rxjs";
|
|
3
|
+
//#region src/channel/structuralEquals.ts
|
|
4
|
+
/**
|
|
5
|
+
* Deep comparison for store read models.
|
|
6
|
+
*
|
|
7
|
+
* Projections and selectors allocate a fresh value on every
|
|
8
|
+
* evaluation, so reference equality reports a change on every
|
|
9
|
+
* unrelated state emission. That invalidates bindings and dirties
|
|
10
|
+
* nodes across the whole tree for state the view never read.
|
|
11
|
+
*
|
|
12
|
+
* Scope is deliberately narrow: primitives, arrays, and plain objects
|
|
13
|
+
* — what a projection is allowed to return. Anything else (class
|
|
14
|
+
* instances, Date, Map, Set, functions) compares by reference, which
|
|
15
|
+
* is conservative: it reports a change, so the UI updates when it did
|
|
16
|
+
* not need to rather than failing to update when it did.
|
|
17
|
+
*
|
|
18
|
+
* The same comparison becomes the equality half of the patch differ
|
|
19
|
+
* in Phase E, so a store behaves identically local or remote.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_DEPTH$1 = 100;
|
|
22
|
+
function structurallyEqual(a, b) {
|
|
23
|
+
return compare(a, b, 0);
|
|
24
|
+
}
|
|
25
|
+
function compare(a, b, depth) {
|
|
26
|
+
if (Object.is(a, b)) return true;
|
|
27
|
+
if (depth > MAX_DEPTH$1) return false;
|
|
28
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
29
|
+
const aIsArray = Array.isArray(a);
|
|
30
|
+
if (aIsArray !== Array.isArray(b)) return false;
|
|
31
|
+
if (aIsArray) {
|
|
32
|
+
const left = a;
|
|
33
|
+
const right = b;
|
|
34
|
+
if (left.length !== right.length) return false;
|
|
35
|
+
for (let i = 0; i < left.length; i++) if (!compare(left[i], right[i], depth + 1)) return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (!isPlainObject$1(a) || !isPlainObject$1(b)) return false;
|
|
39
|
+
const left = a;
|
|
40
|
+
const right = b;
|
|
41
|
+
const leftKeys = Object.keys(left);
|
|
42
|
+
if (leftKeys.length !== Object.keys(right).length) return false;
|
|
43
|
+
for (const key of leftKeys) {
|
|
44
|
+
if (!Object.prototype.hasOwnProperty.call(right, key)) return false;
|
|
45
|
+
if (!compare(left[key], right[key], depth + 1)) return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Objects created from an object literal or a null prototype. Class
|
|
51
|
+
* instances are excluded so they keep reference semantics.
|
|
52
|
+
*/
|
|
53
|
+
function isPlainObject$1(value) {
|
|
54
|
+
const prototype = Object.getPrototypeOf(value);
|
|
55
|
+
return prototype === Object.prototype || prototype === null;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/channel/ChannelToken.ts
|
|
59
|
+
/**
|
|
60
|
+
* Declares a channel.
|
|
61
|
+
*
|
|
62
|
+
* The name identifies it across the thread boundary and must be
|
|
63
|
+
* stable; unlike a class name it survives minification, which is why
|
|
64
|
+
* it is written out rather than derived.
|
|
65
|
+
*/
|
|
66
|
+
function channel(name, initial) {
|
|
67
|
+
if (name.length === 0) throw new Error("A channel needs a name: it is how the two threads agree on which one this is.");
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
initial
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Declares a channel from one object.
|
|
75
|
+
*
|
|
76
|
+
* export const Catalog = defineChannel('catalog', {
|
|
77
|
+
* view: {
|
|
78
|
+
* products: [] as readonly ProductRow[],
|
|
79
|
+
* status: 'loading' as ShelfStatus
|
|
80
|
+
* },
|
|
81
|
+
* commands: {} as {
|
|
82
|
+
* addToCart(id: string, quantity: number): void;
|
|
83
|
+
* move(from: number, to: number): void;
|
|
84
|
+
* }
|
|
85
|
+
* });
|
|
86
|
+
*
|
|
87
|
+
* export type CatalogView = ViewOf<typeof Catalog>;
|
|
88
|
+
*
|
|
89
|
+
* The same token `channel()` returns, declared once instead of three
|
|
90
|
+
* times. `channel<View, Commands>(name, initial)` wrote the view as an
|
|
91
|
+
* interface, then as an initial literal that had to agree with it, and
|
|
92
|
+
* a key added to one and forgotten in the other was a type error in a
|
|
93
|
+
* third file. Here the object is the type.
|
|
94
|
+
*
|
|
95
|
+
* A field whose initial value is narrower than the type it holds is
|
|
96
|
+
* given the type it holds: `[]` is `never[]` and `'loading'` is
|
|
97
|
+
* `string` unless it is said, which is what the `as` clauses above are
|
|
98
|
+
* for. `ViewOf` and `CommandsOf` name the resulting types wherever the
|
|
99
|
+
* application used to name its own interface.
|
|
100
|
+
*
|
|
101
|
+
* `channel()` is not deprecated and keeps working exactly as it did.
|
|
102
|
+
* An application with interfaces it wants to keep, because they are
|
|
103
|
+
* shared with something else or because the initial values are built
|
|
104
|
+
* elsewhere, has nothing to migrate.
|
|
105
|
+
*/
|
|
106
|
+
function defineChannel(name, spec) {
|
|
107
|
+
return channel(name, spec.view);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The keys a channel publishes.
|
|
111
|
+
*
|
|
112
|
+
* Structural in its parameter rather than generic over the token, so
|
|
113
|
+
* it does not have to agree with any particular command type to read
|
|
114
|
+
* what is only ever the initial value's shape.
|
|
115
|
+
*/
|
|
116
|
+
function viewKeys(token) {
|
|
117
|
+
return Object.keys(token.initial);
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/channel/ChannelProtocol.ts
|
|
121
|
+
function isChannelClientMessage(value) {
|
|
122
|
+
const type = value?.type;
|
|
123
|
+
return type === "channel:sync" || type === "channel:command";
|
|
124
|
+
}
|
|
125
|
+
function isChannelHostMessage(value) {
|
|
126
|
+
const type = value?.type;
|
|
127
|
+
return type === "channel:patch" || type === "channel:error";
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/channel/StorePatch.ts
|
|
131
|
+
/**
|
|
132
|
+
* Describes how to turn `previous` into `current` for one projection.
|
|
133
|
+
*
|
|
134
|
+
* Returns an empty list when nothing changed, which is the common case
|
|
135
|
+
* and the reason this exists: the point of a projection is that most
|
|
136
|
+
* state changes do not alter it, and the ones that do usually alter a
|
|
137
|
+
* small part.
|
|
138
|
+
*/
|
|
139
|
+
function diffProjection(projection, previous, current) {
|
|
140
|
+
const patches = [];
|
|
141
|
+
diff(projection, [], previous, current, patches);
|
|
142
|
+
return patches;
|
|
143
|
+
}
|
|
144
|
+
function diff(projection, path, previous, current, out) {
|
|
145
|
+
if (structurallyEqual(previous, current)) return;
|
|
146
|
+
if (Array.isArray(previous) && Array.isArray(current)) {
|
|
147
|
+
diffArray(projection, path, previous, current, out);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (isPlainObject(previous) && isPlainObject(current)) {
|
|
151
|
+
for (const key of Object.keys(current)) if (Object.prototype.hasOwnProperty.call(previous, key)) diff(projection, [...path, key], previous[key], current[key], out);
|
|
152
|
+
else out.push({
|
|
153
|
+
op: "set",
|
|
154
|
+
projection,
|
|
155
|
+
path: [...path, key],
|
|
156
|
+
value: current[key]
|
|
157
|
+
});
|
|
158
|
+
for (const key of Object.keys(previous)) if (!Object.prototype.hasOwnProperty.call(current, key)) out.push({
|
|
159
|
+
op: "delete",
|
|
160
|
+
projection,
|
|
161
|
+
path: [...path, key]
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
out.push({
|
|
166
|
+
op: "set",
|
|
167
|
+
projection,
|
|
168
|
+
path,
|
|
169
|
+
value: current
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Diffs two arrays by trimming the common prefix and suffix.
|
|
174
|
+
*
|
|
175
|
+
* This is not a minimal edit script — a shuffle degrades to replacing
|
|
176
|
+
* the middle wholesale. It is chosen because the operations lists
|
|
177
|
+
* actually undergo (append, prepend, remove one, edit in place) all
|
|
178
|
+
* reduce to a single small patch, and computing a true LCS on every
|
|
179
|
+
* store change would cost more than it saves.
|
|
180
|
+
*/
|
|
181
|
+
function diffArray(projection, path, previous, current, out) {
|
|
182
|
+
let start = 0;
|
|
183
|
+
while (start < previous.length && start < current.length && structurallyEqual(previous[start], current[start])) start++;
|
|
184
|
+
let previousEnd = previous.length - 1;
|
|
185
|
+
let currentEnd = current.length - 1;
|
|
186
|
+
while (previousEnd >= start && currentEnd >= start && structurallyEqual(previous[previousEnd], current[currentEnd])) {
|
|
187
|
+
previousEnd--;
|
|
188
|
+
currentEnd--;
|
|
189
|
+
}
|
|
190
|
+
const previousCount = previousEnd - start + 1;
|
|
191
|
+
const currentCount = currentEnd - start + 1;
|
|
192
|
+
if (previousCount === 0 && currentCount === 0) return;
|
|
193
|
+
if (previousCount === currentCount) {
|
|
194
|
+
for (let offset = 0; offset < previousCount; offset++) {
|
|
195
|
+
const index = start + offset;
|
|
196
|
+
diff(projection, [...path, index], previous[index], current[index], out);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
out.push({
|
|
201
|
+
op: "splice",
|
|
202
|
+
projection,
|
|
203
|
+
path,
|
|
204
|
+
index: start,
|
|
205
|
+
deleteCount: previousCount,
|
|
206
|
+
items: current.slice(start, currentEnd + 1)
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Applies patches to a projection value, sharing structure with the
|
|
211
|
+
* original everywhere the patch did not reach.
|
|
212
|
+
*
|
|
213
|
+
* Nothing handed in is mutated: bindings hold onto emitted values, so a
|
|
214
|
+
* replica that edited in place would change data a component already
|
|
215
|
+
* rendered.
|
|
216
|
+
*
|
|
217
|
+
* **Each container on a patched path is copied once per batch, not once
|
|
218
|
+
* per patch.** A batch of N patches into one K-key object used to cost
|
|
219
|
+
* N × K: every patch spread the whole object again to change one key.
|
|
220
|
+
* A snapshot keyed by id is exactly that shape — gessologic published
|
|
221
|
+
* `Record<netId, 0 | 1>` for ten thousand nets, about nine hundred
|
|
222
|
+
* patches a publish, and the render worker's patch phase fell minutes
|
|
223
|
+
* behind a 60 Hz stream it could never catch. So the batch remembers
|
|
224
|
+
* the containers it has copied, and writes into those in place: they
|
|
225
|
+
* are its own, made during this call and seen by nobody yet. Values
|
|
226
|
+
* that arrived inside a patch are never written into, because they are
|
|
227
|
+
* the patch's — a devtools log replays the same patch objects again.
|
|
228
|
+
*/
|
|
229
|
+
function applyPatches(root, patches) {
|
|
230
|
+
const owned = /* @__PURE__ */ new Set();
|
|
231
|
+
let next = root;
|
|
232
|
+
for (const patch of patches) next = applyOne(next, patch, owned);
|
|
233
|
+
return next;
|
|
234
|
+
}
|
|
235
|
+
function applyPatch(root, patch) {
|
|
236
|
+
return applyOne(root, patch, /* @__PURE__ */ new Set());
|
|
237
|
+
}
|
|
238
|
+
function applyOne(root, patch, owned) {
|
|
239
|
+
switch (patch.op) {
|
|
240
|
+
case "set": return setIn(root, patch.path, 0, patch.value, owned);
|
|
241
|
+
case "delete":
|
|
242
|
+
if (patch.path.length === 0) return;
|
|
243
|
+
return deleteIn(root, patch.path, 0, owned);
|
|
244
|
+
case "splice": return updateIn(root, patch.path, 0, owned, (node) => {
|
|
245
|
+
const array = Array.isArray(node) ? node : [];
|
|
246
|
+
const spliced = array.slice(0, patch.index).concat(patch.items, array.slice(patch.index + patch.deleteCount));
|
|
247
|
+
owned.add(spliced);
|
|
248
|
+
return spliced;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function setIn(node, path, index, value, owned) {
|
|
253
|
+
if (index === path.length) return value;
|
|
254
|
+
const key = path[index];
|
|
255
|
+
const copy = cloneContainer(node, key, owned);
|
|
256
|
+
setKey(copy, key, setIn(readKey(node, key), path, index + 1, value, owned));
|
|
257
|
+
return copy;
|
|
258
|
+
}
|
|
259
|
+
function deleteIn(node, path, index, owned) {
|
|
260
|
+
const key = path[index];
|
|
261
|
+
const copy = cloneContainer(node, key, owned);
|
|
262
|
+
if (index === path.length - 1) {
|
|
263
|
+
if (Array.isArray(copy)) copy.splice(Number(key), 1);
|
|
264
|
+
else delete copy[String(key)];
|
|
265
|
+
return copy;
|
|
266
|
+
}
|
|
267
|
+
setKey(copy, key, deleteIn(readKey(node, key), path, index + 1, owned));
|
|
268
|
+
return copy;
|
|
269
|
+
}
|
|
270
|
+
function updateIn(node, path, index, owned, update) {
|
|
271
|
+
if (index === path.length) return update(node);
|
|
272
|
+
const key = path[index];
|
|
273
|
+
const copy = cloneContainer(node, key, owned);
|
|
274
|
+
setKey(copy, key, updateIn(readKey(node, key), path, index + 1, owned, update));
|
|
275
|
+
return copy;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The container to write `key` into: `node` itself when this batch
|
|
279
|
+
* already copied it, and a fresh copy, remembered, when it did not.
|
|
280
|
+
*/
|
|
281
|
+
function cloneContainer(node, key, owned) {
|
|
282
|
+
if (typeof node === "object" && node !== null && owned.has(node)) return node;
|
|
283
|
+
let copy;
|
|
284
|
+
if (Array.isArray(node)) copy = node.slice();
|
|
285
|
+
else if (isPlainObject(node)) copy = { ...node };
|
|
286
|
+
else copy = typeof key === "number" ? [] : {};
|
|
287
|
+
owned.add(copy);
|
|
288
|
+
return copy;
|
|
289
|
+
}
|
|
290
|
+
function readKey(node, key) {
|
|
291
|
+
if (node === null || node === void 0) return;
|
|
292
|
+
return node[key];
|
|
293
|
+
}
|
|
294
|
+
function setKey(container, key, value) {
|
|
295
|
+
container[key] = value;
|
|
296
|
+
}
|
|
297
|
+
function isPlainObject(value) {
|
|
298
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
299
|
+
const prototype = Object.getPrototypeOf(value);
|
|
300
|
+
return prototype === Object.prototype || prototype === null;
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/channel/plainData.ts
|
|
304
|
+
/**
|
|
305
|
+
* Checks that a value can cross the barrier.
|
|
306
|
+
*
|
|
307
|
+
* `structurallyEqual` understands primitives, arrays and plain
|
|
308
|
+
* objects, and falls back to reference equality for everything else —
|
|
309
|
+
* which for a freshly built value reports "changed" every single time.
|
|
310
|
+
* A view key holding a `Date`, a `Map` or a domain object therefore
|
|
311
|
+
* re-emits on every unrelated update and dirties the subtree bound to
|
|
312
|
+
* it, forever, while looking perfectly correct.
|
|
313
|
+
*
|
|
314
|
+
* That failure is invisible in a test and shows up as a vague slowness
|
|
315
|
+
* much later, so `provide` checks each key's first emission and throws
|
|
316
|
+
* naming the path. The view-model layer is where rich objects become
|
|
317
|
+
* flat data; this is what makes that a rule rather than a convention.
|
|
318
|
+
*
|
|
319
|
+
* The check runs once per key, on the first value only. It is a
|
|
320
|
+
* development guard against a design mistake, not a validator on the
|
|
321
|
+
* hot path.
|
|
322
|
+
*/
|
|
323
|
+
const MAX_DEPTH = 100;
|
|
324
|
+
/**
|
|
325
|
+
* Returns the path to the first value that cannot cross, or null when
|
|
326
|
+
* the whole tree is plain data.
|
|
327
|
+
*/
|
|
328
|
+
function findUnplainPath(value, path = []) {
|
|
329
|
+
if (path.length > MAX_DEPTH) return format(path);
|
|
330
|
+
if (value === null) return null;
|
|
331
|
+
const type = typeof value;
|
|
332
|
+
if (type === "string" || type === "number" || type === "boolean" || type === "undefined") return null;
|
|
333
|
+
if (type === "function" || type === "symbol" || type === "bigint") return format(path);
|
|
334
|
+
if (Array.isArray(value)) {
|
|
335
|
+
for (let index = 0; index < value.length; index++) {
|
|
336
|
+
const found = findUnplainPath(value[index], [...path, index]);
|
|
337
|
+
if (found !== null) return found;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const prototype = Object.getPrototypeOf(value);
|
|
342
|
+
if (prototype !== Object.prototype && prototype !== null) return format(path);
|
|
343
|
+
for (const [key, member] of Object.entries(value)) {
|
|
344
|
+
const found = findUnplainPath(member, [...path, key]);
|
|
345
|
+
if (found !== null) return found;
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Throws when `value` cannot cross the barrier, naming the channel,
|
|
351
|
+
* the key and the path within it.
|
|
352
|
+
*/
|
|
353
|
+
function requirePlainData(channelName, key, value) {
|
|
354
|
+
const path = findUnplainPath(value);
|
|
355
|
+
if (path === null) return;
|
|
356
|
+
const where = path === "" ? `'${key}'` : `'${key}'${path}`;
|
|
357
|
+
throw new Error(`Channel '${channelName}' published ${where}, which is not plain data. Only primitives, arrays and plain objects cross the barrier: a Date, Map, Set, class instance or function compares by reference, so it would report a change on every update and rebuild the subtree bound to it. Flatten it in the view model.`);
|
|
358
|
+
}
|
|
359
|
+
function format(path) {
|
|
360
|
+
return path.map((step) => typeof step === "number" ? `[${step}]` : `.${step}`).join("");
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/channel/provide.ts
|
|
364
|
+
/**
|
|
365
|
+
* Publishes a channel from the thread that owns its data.
|
|
366
|
+
*
|
|
367
|
+
* Each view key is subscribed, diffed against what the other side last
|
|
368
|
+
* saw, and sent as patches. Whatever produced the observable — a bare
|
|
369
|
+
* subject or a stack of layers — stays here; only plain data crosses.
|
|
370
|
+
*
|
|
371
|
+
* Keys are subscribed on the first sync request, so a channel nobody
|
|
372
|
+
* is watching costs nothing.
|
|
373
|
+
*/
|
|
374
|
+
function provide(token, source, port) {
|
|
375
|
+
return new ProvidedChannel(token, source, port);
|
|
376
|
+
}
|
|
377
|
+
var ProvidedChannel = class {
|
|
378
|
+
token;
|
|
379
|
+
source;
|
|
380
|
+
port;
|
|
381
|
+
subscriptions = new Subscription();
|
|
382
|
+
/**
|
|
383
|
+
* What the other side is known to hold, seeded from the token's
|
|
384
|
+
* initial value — which the replica also starts from, so an app
|
|
385
|
+
* whose first emission equals the initial sends nothing at all.
|
|
386
|
+
*/
|
|
387
|
+
previous = /* @__PURE__ */ new Map();
|
|
388
|
+
checked = /* @__PURE__ */ new Set();
|
|
389
|
+
synced = false;
|
|
390
|
+
constructor(token, source, port) {
|
|
391
|
+
this.token = token;
|
|
392
|
+
this.source = source;
|
|
393
|
+
this.port = port;
|
|
394
|
+
for (const key of viewKeys(token)) this.previous.set(key, token.initial[key]);
|
|
395
|
+
this.port.onmessage = (event) => this.receive(event.data);
|
|
396
|
+
}
|
|
397
|
+
receive(data) {
|
|
398
|
+
if (!isChannelClientMessage(data)) return;
|
|
399
|
+
try {
|
|
400
|
+
if (data.type === "channel:sync") {
|
|
401
|
+
this.sync();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.runCommand(data.command, data.payload, data.rest);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
this.post({
|
|
407
|
+
type: "channel:error",
|
|
408
|
+
message: error instanceof Error ? error.message : String(error),
|
|
409
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
runCommand(name, payload, rest) {
|
|
414
|
+
const handler = this.source.commands?.[name];
|
|
415
|
+
if (handler === void 0) {
|
|
416
|
+
const names = Object.keys(this.source.commands ?? {}).sort().join(", ");
|
|
417
|
+
throw new Error(`Channel '${this.token.name}' has no command '${name}'. Declared commands: ${names.length > 0 ? names : "(none)"}.`);
|
|
418
|
+
}
|
|
419
|
+
handler(payload, ...rest ?? []);
|
|
420
|
+
}
|
|
421
|
+
sync() {
|
|
422
|
+
if (this.synced) {
|
|
423
|
+
this.resend();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.synced = true;
|
|
427
|
+
for (const key of viewKeys(this.token)) {
|
|
428
|
+
const observable = this.source.view[key];
|
|
429
|
+
if (observable === void 0) {
|
|
430
|
+
this.post({
|
|
431
|
+
type: "channel:error",
|
|
432
|
+
message: `Channel '${this.token.name}' declares view key '${key}' but nothing was provided for it.`
|
|
433
|
+
});
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
this.subscriptions.add(observable.subscribe({
|
|
437
|
+
next: (value) => this.publish(key, value),
|
|
438
|
+
error: (error) => this.post({
|
|
439
|
+
type: "channel:error",
|
|
440
|
+
message: `Channel '${this.token.name}' view key '${key}' errored: ${error instanceof Error ? error.message : String(error)}`,
|
|
441
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
442
|
+
})
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
publish(key, value) {
|
|
447
|
+
if (!this.checked.has(key)) {
|
|
448
|
+
this.checked.add(key);
|
|
449
|
+
try {
|
|
450
|
+
requirePlainData(this.token.name, key, value);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
this.post({
|
|
453
|
+
type: "channel:error",
|
|
454
|
+
message: error instanceof Error ? error.message : String(error),
|
|
455
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
456
|
+
});
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const patches = diffProjection(key, this.previous.get(key), value);
|
|
461
|
+
this.previous.set(key, value);
|
|
462
|
+
if (patches.length > 0) this.post({
|
|
463
|
+
type: "channel:patch",
|
|
464
|
+
patches
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
/** Re-sends every key in full, for a client that reattached. */
|
|
468
|
+
resend() {
|
|
469
|
+
const patches = [];
|
|
470
|
+
for (const [key, value] of this.previous) patches.push({
|
|
471
|
+
op: "set",
|
|
472
|
+
projection: key,
|
|
473
|
+
path: [],
|
|
474
|
+
value
|
|
475
|
+
});
|
|
476
|
+
if (patches.length > 0) this.post({
|
|
477
|
+
type: "channel:patch",
|
|
478
|
+
patches
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
post(message) {
|
|
482
|
+
this.port.postMessage(message);
|
|
483
|
+
}
|
|
484
|
+
dispose() {
|
|
485
|
+
this.subscriptions.unsubscribe();
|
|
486
|
+
this.port.onmessage = null;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/app/NodeReport.ts
|
|
491
|
+
/**
|
|
492
|
+
* A node as a path somebody can read: `App > TrackScreen > ActionRow >
|
|
493
|
+
* Button "Like"`.
|
|
494
|
+
*
|
|
495
|
+
* Written for error messages rather than for the inspector, and the
|
|
496
|
+
* difference decides the shape. A report is read beside the tree it
|
|
497
|
+
* came from, so an id is a handle; an error is read in a console or an
|
|
498
|
+
* overlay with no tree beside it, and `node-4821` there is a fact
|
|
499
|
+
* about nothing. The owner chain is what a person recognises, because
|
|
500
|
+
* it is the components they wrote.
|
|
501
|
+
*
|
|
502
|
+
* Owners arrive nearest-first, as `UiNodeReport.owners` computes them,
|
|
503
|
+
* and read outermost-first, as a path does. The accessible name is the
|
|
504
|
+
* leaf when there is one: two `Button`s in the same row are only told
|
|
505
|
+
* apart by what they say.
|
|
506
|
+
*/
|
|
507
|
+
function formatNodePath(owners, node) {
|
|
508
|
+
const names = owners.map((owner) => owner.name).reverse();
|
|
509
|
+
if (names.length === 0) return node.label === void 0 || node.label === "" ? `${node.type} ${node.id}` : `${node.type} "${node.label}"`;
|
|
510
|
+
const path = names.join(" > ");
|
|
511
|
+
return node.label === void 0 || node.label === "" ? path : `${path} "${node.label}"`;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Describes the stream driving a property.
|
|
515
|
+
*
|
|
516
|
+
* `timeOrigin` turns the binding's own reading into an epoch stamp:
|
|
517
|
+
* `performance.timeOrigin` on a thread that has one, and zero where
|
|
518
|
+
* the reading is already an epoch.
|
|
519
|
+
*/
|
|
520
|
+
function describeStream(binding, timeOrigin) {
|
|
521
|
+
const label = binding.observable?.label;
|
|
522
|
+
const labelled = typeof label === "string" && label !== "";
|
|
523
|
+
const emittedAt = binding.emittedAt();
|
|
524
|
+
return {
|
|
525
|
+
source: labelled ? label : `observable #${binding.id}`,
|
|
526
|
+
kind: labelled ? "cell" : "observable",
|
|
527
|
+
value: printPropValue(binding.value()),
|
|
528
|
+
emissions: binding.emissionCount(),
|
|
529
|
+
emittedAt: emittedAt === null ? null : timeOrigin + emittedAt,
|
|
530
|
+
connected: binding.connected()
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/** A stream as one line: what feeds the prop, and how long ago it said so. */
|
|
534
|
+
function formatStream(stream, now = Date.now()) {
|
|
535
|
+
const when = stream.emittedAt === null ? "no value yet" : `${formatAge(Math.max(0, now - stream.emittedAt))} ago`;
|
|
536
|
+
const emissions = `${stream.emissions} emission${stream.emissions === 1 ? "" : "s"}`;
|
|
537
|
+
return `${stream.source} · ${emissions} · ${when}${stream.connected ? "" : " · disconnected"}`;
|
|
538
|
+
}
|
|
539
|
+
/** An age in the largest unit that stays readable. */
|
|
540
|
+
function formatAge(ms) {
|
|
541
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
542
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
543
|
+
return `${Math.round(ms / 6e4)}m`;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* A value as one line a person can read.
|
|
547
|
+
*
|
|
548
|
+
* `JSON.stringify` alone is not enough: the two values a canvas UI
|
|
549
|
+
* puts in a property that it cannot handle are a `Set` (a node's
|
|
550
|
+
* visual states) and a function (an event handler), and it prints both
|
|
551
|
+
* as `{}`, which in an inspector reads as a bug in the application
|
|
552
|
+
* rather than one in the inspector.
|
|
553
|
+
*/
|
|
554
|
+
function printPropValue(value) {
|
|
555
|
+
if (value === void 0) return "undefined";
|
|
556
|
+
if (value === null) return "null";
|
|
557
|
+
if (typeof value === "function") return `ƒ ${value.name === "" ? "(anonymous)" : value.name}`;
|
|
558
|
+
if (typeof value === "symbol") return value.toString();
|
|
559
|
+
if (typeof value === "string") return value;
|
|
560
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
561
|
+
if (value instanceof Set) return `Set { ${[...value].map(printPropValue).join(", ")} }`;
|
|
562
|
+
if (value instanceof Map) return `Map { ${[...value].map(([key, entry]) => `${printPropValue(key)}: ${printPropValue(entry)}`).join(", ")} }`;
|
|
563
|
+
if (Array.isArray(value)) return `[${value.map(printPropValue).join(", ")}]`;
|
|
564
|
+
try {
|
|
565
|
+
return JSON.stringify(value) ?? String(value);
|
|
566
|
+
} catch {
|
|
567
|
+
return String(value);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** The report as text, for a console or a test failure. */
|
|
571
|
+
function formatNodeReport(report) {
|
|
572
|
+
const lines = [`${report.type} '${report.id}'`];
|
|
573
|
+
if (report.owners.length > 0) lines.push(`rendered by ${report.owners.map((owner) => owner.name).join(" inside ")}`);
|
|
574
|
+
if (report.modifiers.length > 0) lines.push(`modifiers: ${report.modifiers.join(", ")}`);
|
|
575
|
+
if (report.listens.length > 0) lines.push(`listens: ${report.listens.join(", ")}`);
|
|
576
|
+
if (report.beneath.length > 0) {
|
|
577
|
+
lines.push("beneath, at the pointer:");
|
|
578
|
+
for (const under of report.beneath) {
|
|
579
|
+
const owner = under.owner === void 0 ? "" : ` (${under.owner})`;
|
|
580
|
+
const listens = under.listens.length === 0 ? "" : `, listens: ${under.listens.join(", ")}`;
|
|
581
|
+
lines.push(` ${under.type} ${under.id}${owner}${listens}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (report.props.length > 0) {
|
|
585
|
+
lines.push("props:");
|
|
586
|
+
for (const prop of report.props) {
|
|
587
|
+
lines.push(` ${prop.name} = ${prop.value}${prop.source === void 0 ? "" : ` (${prop.source})`}`);
|
|
588
|
+
if (prop.stream !== void 0) lines.push(` ${formatStream(prop.stream)}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (report.environment.length > 0) {
|
|
592
|
+
lines.push("environment:");
|
|
593
|
+
for (const entry of report.environment) lines.push(` ${entry.key} = ${entry.value}${entry.provided ? " (provided here)" : ""}`);
|
|
594
|
+
}
|
|
595
|
+
if (report.semantics !== void 0) {
|
|
596
|
+
const parts = [
|
|
597
|
+
report.semantics.role === void 0 ? void 0 : `role ${report.semantics.role}`,
|
|
598
|
+
report.semantics.label === void 0 ? void 0 : `label ${report.semantics.label}`,
|
|
599
|
+
report.semantics.value === void 0 ? void 0 : `value ${report.semantics.value}`,
|
|
600
|
+
report.semantics.states === void 0 || report.semantics.states.length === 0 ? void 0 : `states ${report.semantics.states.join(", ")}`
|
|
601
|
+
].filter((part) => part !== void 0);
|
|
602
|
+
if (parts.length > 0) lines.push(`semantics: ${parts.join(" · ")}`);
|
|
603
|
+
}
|
|
604
|
+
lines.push(report.explanation);
|
|
605
|
+
return lines.join("\n");
|
|
606
|
+
}
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/worker/captureConsole.ts
|
|
609
|
+
const LEVELS = [
|
|
610
|
+
"log",
|
|
611
|
+
"info",
|
|
612
|
+
"warn",
|
|
613
|
+
"error",
|
|
614
|
+
"debug"
|
|
615
|
+
];
|
|
616
|
+
/**
|
|
617
|
+
* Copies every `console.*` call on `target` (the worker global's
|
|
618
|
+
* console by default) to `sink`. Returns a function that restores the
|
|
619
|
+
* original methods.
|
|
620
|
+
*
|
|
621
|
+
* Idempotent per target: a second capture on the same console replaces
|
|
622
|
+
* the first's sink rather than nesting, so toggling a panel on twice
|
|
623
|
+
* does not log twice.
|
|
624
|
+
*/
|
|
625
|
+
function captureConsole(sink, target = console) {
|
|
626
|
+
const installed = target[CAPTURED];
|
|
627
|
+
if (installed !== void 0) {
|
|
628
|
+
installed.sink = sink;
|
|
629
|
+
return installed.restore;
|
|
630
|
+
}
|
|
631
|
+
const originals = /* @__PURE__ */ new Map();
|
|
632
|
+
const state = {
|
|
633
|
+
sink,
|
|
634
|
+
restore: () => {
|
|
635
|
+
for (const [level, original] of originals) target[level] = original;
|
|
636
|
+
delete target[CAPTURED];
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
for (const level of LEVELS) {
|
|
640
|
+
const original = target[level];
|
|
641
|
+
originals.set(level, original);
|
|
642
|
+
target[level] = (...args) => {
|
|
643
|
+
original.apply(target, args);
|
|
644
|
+
try {
|
|
645
|
+
state.sink({
|
|
646
|
+
level,
|
|
647
|
+
args: args.map(formatConsoleArg),
|
|
648
|
+
at: Date.now()
|
|
649
|
+
});
|
|
650
|
+
} catch {}
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
target[CAPTURED] = state;
|
|
654
|
+
return state.restore;
|
|
655
|
+
}
|
|
656
|
+
const CAPTURED = Symbol.for("gesso:console-captured");
|
|
657
|
+
/**
|
|
658
|
+
* One console argument as the panel prints it.
|
|
659
|
+
*
|
|
660
|
+
* `printPropValue` already prints the values a canvas UI is likely to
|
|
661
|
+
* log; an Error is the one thing it prints badly (`{}`), and the one
|
|
662
|
+
* thing a developer most wants to read whole.
|
|
663
|
+
*/
|
|
664
|
+
function formatConsoleArg(value) {
|
|
665
|
+
if (value instanceof Error) return value.stack !== void 0 && value.stack !== "" ? value.stack : `${value.name}: ${value.message}`;
|
|
666
|
+
return printPropValue(value);
|
|
667
|
+
}
|
|
668
|
+
function isConsoleForwardingMessage(value) {
|
|
669
|
+
const message = value;
|
|
670
|
+
return message?.type === "gesso:console" && typeof message.enabled === "boolean";
|
|
671
|
+
}
|
|
672
|
+
function isConsoleEntryMessage(value) {
|
|
673
|
+
const message = value;
|
|
674
|
+
return message?.type === "gesso:console" && typeof message.entry === "object" && message.entry !== null;
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/worker/WorkerPorts.ts
|
|
678
|
+
/**
|
|
679
|
+
* Named `MessagePort`s into a worker.
|
|
680
|
+
*
|
|
681
|
+
* A worker's global `onmessage` is a single channel, so a worker that
|
|
682
|
+
* receives messages on it can host exactly one conversation. That is
|
|
683
|
+
* why a store in a data worker used to mean a worker per store: the
|
|
684
|
+
* client claimed the `Worker` object itself, and a second one had
|
|
685
|
+
* nowhere to go.
|
|
686
|
+
*
|
|
687
|
+
* A handshake fixes it. The client opens a `MessageChannel`, keeps one
|
|
688
|
+
* end and transfers the other with a name; the worker serves that name
|
|
689
|
+
* and the two ends talk privately from then on. The global channel is
|
|
690
|
+
* used once per conversation and carries nothing else.
|
|
691
|
+
*
|
|
692
|
+
* Nothing here knows what travels over a port. It is the transport the
|
|
693
|
+
* store replication in `../store/worker` runs on today and the barrier
|
|
694
|
+
* contract will run on next.
|
|
695
|
+
*/
|
|
696
|
+
function isPortHandshake(value) {
|
|
697
|
+
const message = value;
|
|
698
|
+
return message?.type === "gesso:port" && typeof message.key === "string";
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Stands for "whichever worker the shell spawned for the application".
|
|
702
|
+
*
|
|
703
|
+
* A registration inside the render worker cannot name that worker: it
|
|
704
|
+
* is created by the shell and its port only arrives with `init`, long
|
|
705
|
+
* after `useChannel` and `useService` have run. This sentinel is what a
|
|
706
|
+
* registration puts there instead, and the render worker swaps it for
|
|
707
|
+
* the real handle once the port shows up.
|
|
708
|
+
*
|
|
709
|
+
* Opening a port on it before then is a bug rather than a race, so it
|
|
710
|
+
* says so.
|
|
711
|
+
*/
|
|
712
|
+
const APPLICATION_WORKER = {
|
|
713
|
+
open() {
|
|
714
|
+
throw new Error("APPLICATION_WORKER was used directly. It is a placeholder the render worker replaces with the shell's port; reaching it means no application-logic worker was supplied. Pass appLogicWorker to createApp.");
|
|
715
|
+
},
|
|
716
|
+
spawned: false,
|
|
717
|
+
terminate() {}
|
|
718
|
+
};
|
|
719
|
+
/**
|
|
720
|
+
* A handle over an endpoint someone else owns.
|
|
721
|
+
*
|
|
722
|
+
* The shell spawns the application worker and hands the render worker
|
|
723
|
+
* one end of a channel to it; this is what the render worker opens
|
|
724
|
+
* named ports over. `terminate` is a no-op — the lifetime belongs to
|
|
725
|
+
* whoever created the endpoint, and a handle that could kill a worker
|
|
726
|
+
* it did not spawn would be a surprising thing to hand out.
|
|
727
|
+
*/
|
|
728
|
+
function portHandle(endpoint) {
|
|
729
|
+
return {
|
|
730
|
+
open(key) {
|
|
731
|
+
const channel = new MessageChannel();
|
|
732
|
+
endpoint.postMessage({
|
|
733
|
+
type: "gesso:port",
|
|
734
|
+
key
|
|
735
|
+
}, [channel.port2]);
|
|
736
|
+
return channel.port1;
|
|
737
|
+
},
|
|
738
|
+
get spawned() {
|
|
739
|
+
return true;
|
|
740
|
+
},
|
|
741
|
+
terminate() {}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function isHubMessage(value) {
|
|
745
|
+
return value?.type === "gesso:hub";
|
|
746
|
+
}
|
|
747
|
+
const BASE_INSTALLED = Symbol.for("gesso:port-base-installed");
|
|
748
|
+
const CONSOLE_RESTORE = Symbol.for("gesso:port-console-restore");
|
|
749
|
+
/**
|
|
750
|
+
* Starts or stops copying this worker's console to whoever posts to
|
|
751
|
+
* it, as `ConsoleEntryMessage`s. A host with no `postMessage` (a test's
|
|
752
|
+
* bare object) has nowhere to send them and forwards nothing.
|
|
753
|
+
*/
|
|
754
|
+
function setConsoleForwarding(host, enabled) {
|
|
755
|
+
host[CONSOLE_RESTORE]?.();
|
|
756
|
+
delete host[CONSOLE_RESTORE];
|
|
757
|
+
const post = host.postMessage;
|
|
758
|
+
if (!enabled || typeof post !== "function") return;
|
|
759
|
+
host[CONSOLE_RESTORE] = captureConsole((entry) => {
|
|
760
|
+
const message = {
|
|
761
|
+
type: "gesso:console",
|
|
762
|
+
entry
|
|
763
|
+
};
|
|
764
|
+
post.call(host, message);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* The handler every `servePorts` chain sits on top of.
|
|
769
|
+
*
|
|
770
|
+
* It owns the two things no individual server can: routing a hub port
|
|
771
|
+
* through the whole chain, and answering a handshake that nobody
|
|
772
|
+
* accepted. Both have to be innermost — the first because the chain is
|
|
773
|
+
* only complete once every server has wrapped `onmessage`, the second
|
|
774
|
+
* because "nobody accepted" is only known after every server has
|
|
775
|
+
* declined.
|
|
776
|
+
*/
|
|
777
|
+
function installBase(host) {
|
|
778
|
+
if (host[BASE_INSTALLED] === true) return;
|
|
779
|
+
host[BASE_INSTALLED] = true;
|
|
780
|
+
const previous = host.onmessage;
|
|
781
|
+
const registered = host[SERVED_NAMES] ?? [];
|
|
782
|
+
host.onmessage = (event) => {
|
|
783
|
+
if (isConsoleForwardingMessage(event.data)) {
|
|
784
|
+
setConsoleForwarding(host, event.data.enabled);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (isHubMessage(event.data)) {
|
|
788
|
+
const port = event.ports?.[0];
|
|
789
|
+
if (port === void 0) throw new Error("A hub message arrived with no port attached.");
|
|
790
|
+
port.onmessage = host.onmessage;
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (!isPortHandshake(event.data)) {
|
|
794
|
+
previous?.(event);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const port = event.ports?.[0];
|
|
798
|
+
if (port === void 0) throw new Error(`Port handshake for '${event.data.key}' arrived with no port attached.`);
|
|
799
|
+
const served = registered.flatMap((get) => [...get()]).sort();
|
|
800
|
+
const error = {
|
|
801
|
+
type: "port:error",
|
|
802
|
+
message: `Nothing is served under '${event.data.key}'. This worker serves: ${served.length > 0 ? served.join(", ") : "(nothing)"}.`
|
|
803
|
+
};
|
|
804
|
+
port.postMessage(error);
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Wraps a worker factory so the worker is created once and shared.
|
|
809
|
+
*
|
|
810
|
+
* A factory rather than a URL for the same reason the render worker
|
|
811
|
+
* takes one: a bundler only emits a chunk for a worker it can see
|
|
812
|
+
* constructed literally in the calling module.
|
|
813
|
+
*
|
|
814
|
+
* const data = workerHandle(
|
|
815
|
+
* () => new Worker(new URL('./data.worker.ts', import.meta.url), { type: 'module' })
|
|
816
|
+
* );
|
|
817
|
+
*/
|
|
818
|
+
function workerHandle(factory) {
|
|
819
|
+
let worker;
|
|
820
|
+
return {
|
|
821
|
+
open(key) {
|
|
822
|
+
worker ??= factory();
|
|
823
|
+
const channel = new MessageChannel();
|
|
824
|
+
worker.postMessage({
|
|
825
|
+
type: "gesso:port",
|
|
826
|
+
key
|
|
827
|
+
}, [channel.port2]);
|
|
828
|
+
return channel.port1;
|
|
829
|
+
},
|
|
830
|
+
get spawned() {
|
|
831
|
+
return worker !== void 0;
|
|
832
|
+
},
|
|
833
|
+
terminate() {
|
|
834
|
+
worker?.terminate();
|
|
835
|
+
worker = void 0;
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
function isPortErrorMessage(value) {
|
|
840
|
+
const message = value;
|
|
841
|
+
return message?.type === "port:error" && typeof message.message === "string";
|
|
842
|
+
}
|
|
843
|
+
/** Every name served on a host, across all `servePorts` calls on it. */
|
|
844
|
+
const SERVED_NAMES = Symbol.for("gesso:served-port-names");
|
|
845
|
+
/**
|
|
846
|
+
* Serves named ports inside a worker.
|
|
847
|
+
*
|
|
848
|
+
* Call it synchronously at the top level of the worker module, before
|
|
849
|
+
* any await, so no handshake is missed.
|
|
850
|
+
*
|
|
851
|
+
* `onPort` returns whether it took the port. Returning false passes
|
|
852
|
+
* the handshake to whatever was serving before, which is what lets two
|
|
853
|
+
* kinds of thing — stores and channels, during the migration — share
|
|
854
|
+
* one worker: each answers for its own names and declines the rest.
|
|
855
|
+
* When nobody accepts, the port is answered with an error naming
|
|
856
|
+
* everything the worker does serve, because a handshake that silently
|
|
857
|
+
* matched nothing leaves the client waiting forever with nothing said.
|
|
858
|
+
*
|
|
859
|
+
* `names` is only read to build that message.
|
|
860
|
+
*
|
|
861
|
+
* Returns a function that stops serving.
|
|
862
|
+
*/
|
|
863
|
+
function servePorts(onPort, names, host = self) {
|
|
864
|
+
const withNames = host;
|
|
865
|
+
const registered = withNames[SERVED_NAMES] ??= [];
|
|
866
|
+
registered.push(names);
|
|
867
|
+
installBase(host);
|
|
868
|
+
const previous = host.onmessage;
|
|
869
|
+
host.onmessage = (event) => {
|
|
870
|
+
if (!isPortHandshake(event.data)) {
|
|
871
|
+
previous?.(event);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const port = event.ports?.[0];
|
|
875
|
+
if (port === void 0) throw new Error(`Port handshake for '${event.data.key}' arrived with no port attached.`);
|
|
876
|
+
if (onPort(event.data.key, port)) return;
|
|
877
|
+
previous?.(event);
|
|
878
|
+
};
|
|
879
|
+
return () => {
|
|
880
|
+
host.onmessage = previous;
|
|
881
|
+
const index = registered.indexOf(names);
|
|
882
|
+
if (index >= 0) registered.splice(index, 1);
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/channel/serveChannels.ts
|
|
887
|
+
/**
|
|
888
|
+
* One served channel, with its source checked against its token.
|
|
889
|
+
*
|
|
890
|
+
* `ServedChannel` is erased on purpose, so one list can hold channels
|
|
891
|
+
* of every shape; the cost is that a view key the token declares and
|
|
892
|
+
* the source forgets is found at startup, by the error `provide`
|
|
893
|
+
* reports, rather than by the compiler. This is the typed seam: the
|
|
894
|
+
* source must hold an Observable for every key of the token's view and
|
|
895
|
+
* a handler for every command, and each handler takes the arguments
|
|
896
|
+
* the token declares, so none of them needs an annotation.
|
|
897
|
+
*
|
|
898
|
+
* serveChannels([
|
|
899
|
+
* serve(Catalog, { view: catalog, commands: { add: name => catalog.add(name) } })
|
|
900
|
+
* ]);
|
|
901
|
+
*
|
|
902
|
+
* The view may be any object with the right observables on it, which
|
|
903
|
+
* is often the domain object itself when its properties are named
|
|
904
|
+
* after the keys. Only the declared keys are read from it.
|
|
905
|
+
*/
|
|
906
|
+
function serve(token, source) {
|
|
907
|
+
return {
|
|
908
|
+
token,
|
|
909
|
+
source
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Publishes channels from an application worker.
|
|
914
|
+
*
|
|
915
|
+
* Call it synchronously at the top level of the worker module, before
|
|
916
|
+
* any await, so no handshake is missed:
|
|
917
|
+
*
|
|
918
|
+
* const catalog = new CatalogViewModel(new CatalogDomain(new OpfsStore()));
|
|
919
|
+
* serveChannels([
|
|
920
|
+
* serve(Catalog, { view: { products: catalog.products$ }, commands: { … } })
|
|
921
|
+
* ]);
|
|
922
|
+
*
|
|
923
|
+
* Everything above this call is the application's own — plain classes,
|
|
924
|
+
* plain observables, no framework import. This function is the entire
|
|
925
|
+
* seam between it and the view.
|
|
926
|
+
*
|
|
927
|
+
* Returns a function that stops serving and disposes what it provided.
|
|
928
|
+
*/
|
|
929
|
+
function serveChannels(channels, host) {
|
|
930
|
+
const byName = /* @__PURE__ */ new Map();
|
|
931
|
+
for (const served of channels) byName.set(served.token.name, served);
|
|
932
|
+
const provided = [];
|
|
933
|
+
const stop = servePorts((key, port) => {
|
|
934
|
+
if (key === "gesso:agent") {
|
|
935
|
+
serveAgentPort(port, (confirm) => agentSurface(channels, { confirm }));
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
const served = byName.get(key);
|
|
939
|
+
if (served === void 0) return false;
|
|
940
|
+
provided.push(provide(served.token, served.source, port));
|
|
941
|
+
return true;
|
|
942
|
+
}, () => [...byName.keys()], host);
|
|
943
|
+
return () => {
|
|
944
|
+
stop();
|
|
945
|
+
for (const channel of provided) channel.dispose();
|
|
946
|
+
provided.length = 0;
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
//#endregion
|
|
950
|
+
export { structurallyEqual as A, applyPatches as C, channel as D, isChannelHostMessage as E, defineChannel as O, applyPatch as S, isChannelClientMessage as T, printPropValue as _, isPortErrorMessage as a, findUnplainPath as b, servePorts as c, isConsoleEntryMessage as d, describeStream as f, formatStream as g, formatNodeReport as h, isHubMessage as i, viewKeys as k, workerHandle as l, formatNodePath as m, serveChannels as n, isPortHandshake as o, formatAge as p, APPLICATION_WORKER as r, portHandle as s, serve as t, captureConsole as u, ProvidedChannel as v, diffProjection as w, requirePlainData as x, provide as y };
|
|
951
|
+
|
|
952
|
+
//# sourceMappingURL=serveChannels-Cs22-p8X.js.map
|