@earendil-works/chord 1.2.0 → 1.3.0
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 +58 -0
- package/README.md +14 -13
- package/dist/cell.d.ts +30 -9
- package/dist/cell.d.ts.map +1 -1
- package/dist/cell.js +52 -12
- package/dist/cell.js.map +1 -1
- package/dist/cluster.d.ts +4 -3
- package/dist/cluster.d.ts.map +1 -1
- package/dist/cluster.js +58 -42
- package/dist/cluster.js.map +1 -1
- package/dist/connection.d.ts +171 -58
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +676 -217
- package/dist/connection.js.map +1 -1
- package/dist/contract.d.ts +120 -45
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +104 -15
- package/dist/contract.js.map +1 -1
- package/dist/declare.d.ts +11 -48
- package/dist/declare.d.ts.map +1 -1
- package/dist/declare.js +1 -21
- package/dist/declare.js.map +1 -1
- package/dist/delta/index.d.ts +2 -1
- package/dist/delta/index.d.ts.map +1 -1
- package/dist/delta/index.js +4 -0
- package/dist/delta/index.js.map +1 -1
- package/dist/errors.d.ts +4 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -1
- package/dist/host.d.ts +214 -120
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +446 -241
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +47 -12
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +200 -14
- package/dist/link.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +7 -2
- package/dist/loader.js.map +1 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +35 -9
- package/dist/manager.js.map +1 -1
- package/dist/object.d.ts +438 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/{record.js → object.js} +614 -335
- package/dist/object.js.map +1 -0
- package/dist/output.d.ts +85 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +350 -0
- package/dist/output.js.map +1 -0
- package/dist/registry.d.ts +3 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/reload.d.ts +35 -27
- package/dist/reload.d.ts.map +1 -1
- package/dist/reload.js +256 -192
- package/dist/reload.js.map +1 -1
- package/dist/remote.d.ts +130 -35
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +493 -189
- package/dist/remote.js.map +1 -1
- package/dist/types.d.ts +39 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/websocket.d.ts +4 -5
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +3 -2
- package/dist/websocket.js.map +1 -1
- package/dist/wire.d.ts +210 -129
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +108 -55
- package/dist/wire.js.map +1 -1
- package/docs/guide.md +267 -203
- package/docs/spec.md +141 -114
- package/docs/tutorial.md +257 -235
- package/package.json +6 -1
- package/dist/record.d.ts +0 -320
- package/dist/record.d.ts.map +0 -1
- package/dist/record.js.map +0 -1
package/dist/remote.js
CHANGED
|
@@ -1,145 +1,468 @@
|
|
|
1
1
|
import { Value } from "typebox/value";
|
|
2
|
+
import { HYDRATION } from "./cell.js";
|
|
3
|
+
import { takesNoArgs } from "./contract.js";
|
|
2
4
|
import { applyImmutable, decoder } from "./delta/index.js";
|
|
3
|
-
import { ChordError } from "./errors.js";
|
|
4
|
-
import { CtxImpl,
|
|
5
|
+
import { ChordError, invalid } from "./errors.js";
|
|
6
|
+
import { CtxImpl, notifyStatus, REF_BASE, raceSignal, refsByProxy, } from "./object.js";
|
|
5
7
|
import { fromWireError } from "./wire.js";
|
|
6
8
|
const noop = () => { };
|
|
9
|
+
function deferred() {
|
|
10
|
+
let resolve = noop;
|
|
11
|
+
let reject = noop;
|
|
12
|
+
const promise = new Promise((done, fail) => {
|
|
13
|
+
resolve = done;
|
|
14
|
+
reject = fail;
|
|
15
|
+
});
|
|
16
|
+
promise.catch(noop);
|
|
17
|
+
return { promise, resolve, reject };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One hold per host and key (type, normalized input, principal key, contract fingerprint), shared by every reference to
|
|
21
|
+
* it: one hold over the link, one stream per state member, calls over the same hold. References are views onto it.
|
|
22
|
+
*
|
|
23
|
+
* A hold implies the state of its variant (unless no view wants state): the provider answers with a unit, the shape
|
|
24
|
+
* followed by a snapshot of each state member the binding lacks. A shape is applied only once all of its snapshots are
|
|
25
|
+
* here, so a live binding always has every state member of its variant. After the first hydration the values stay
|
|
26
|
+
* readable while the binding re-holds (link loss): status `loading`, last values kept.
|
|
27
|
+
*/
|
|
28
|
+
export class RemoteBinding {
|
|
29
|
+
host;
|
|
30
|
+
key;
|
|
31
|
+
contract;
|
|
32
|
+
at;
|
|
33
|
+
input;
|
|
34
|
+
/** Forwarded principal (omitted for the host's own principal). */
|
|
35
|
+
principal;
|
|
36
|
+
/** `ended`: gone with `error`, or closed (no views, no linger). */
|
|
37
|
+
status = "loading";
|
|
38
|
+
error;
|
|
39
|
+
/** The applied shape (kept while re-holding). */
|
|
40
|
+
shape;
|
|
41
|
+
/** Every state member of the applied shape has a value (stays true while re-holding). */
|
|
42
|
+
hydrated = false;
|
|
43
|
+
/** The current hold; undefined while resolving the route or after a link loss. */
|
|
44
|
+
hold;
|
|
45
|
+
/** What the provider was told: state for this hold (`state: false` / `sub` / `unsub`). */
|
|
46
|
+
stateOn = false;
|
|
47
|
+
views = new Set();
|
|
48
|
+
members = new Map();
|
|
49
|
+
/** Lingering since (no views): set by the host. */
|
|
50
|
+
lingerTimer;
|
|
51
|
+
#listeners = new Map();
|
|
52
|
+
/** A shape waiting for its snapshots. */
|
|
53
|
+
#pending;
|
|
54
|
+
/** Resolved while a shape is applied for the current hold (calls wait for it). */
|
|
55
|
+
#live = deferred();
|
|
56
|
+
/** A shape was applied once (a re-hold says `again`). */
|
|
57
|
+
#applied = false;
|
|
58
|
+
/** Waiting for the first hydration (`whenHydrated`). */
|
|
59
|
+
#hydrationWaiters = new Set();
|
|
60
|
+
constructor(host, fields) {
|
|
61
|
+
this.host = host;
|
|
62
|
+
this.key = fields.key;
|
|
63
|
+
this.contract = fields.contract;
|
|
64
|
+
this.at = fields.at;
|
|
65
|
+
this.input = fields.input;
|
|
66
|
+
this.principal = fields.principal;
|
|
67
|
+
}
|
|
68
|
+
get ended() {
|
|
69
|
+
return this.status === "ended";
|
|
70
|
+
}
|
|
71
|
+
/** Views that want state. */
|
|
72
|
+
get stateViews() {
|
|
73
|
+
let count = 0;
|
|
74
|
+
for (const view of this.views)
|
|
75
|
+
if (view.wantsState)
|
|
76
|
+
count++;
|
|
77
|
+
return count;
|
|
78
|
+
}
|
|
79
|
+
/** Whether the next hold should carry state: some view wants it, or nobody is left (lingering keeps it). */
|
|
80
|
+
get wantsState() {
|
|
81
|
+
return this.views.size === 0 ? this.stateOn : this.stateViews > 0;
|
|
82
|
+
}
|
|
83
|
+
/** Whether a re-hold is `again` (the binding already has a shape). */
|
|
84
|
+
get applied() {
|
|
85
|
+
return this.#applied;
|
|
86
|
+
}
|
|
87
|
+
// ─── views ───
|
|
88
|
+
addView(view) {
|
|
89
|
+
this.views.add(view);
|
|
90
|
+
if (this.lingerTimer !== undefined) {
|
|
91
|
+
clearTimeout(this.lingerTimer);
|
|
92
|
+
this.lingerTimer = undefined;
|
|
93
|
+
}
|
|
94
|
+
this.#syncState();
|
|
95
|
+
view.update();
|
|
96
|
+
}
|
|
97
|
+
removeView(view) {
|
|
98
|
+
if (!this.views.delete(view))
|
|
99
|
+
return;
|
|
100
|
+
for (const set of this.#listeners.values())
|
|
101
|
+
for (const readable of set)
|
|
102
|
+
if (readable.view === view)
|
|
103
|
+
set.delete(readable);
|
|
104
|
+
if (this.views.size === 0) {
|
|
105
|
+
this.host.bindingIdle(this);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.#syncState();
|
|
109
|
+
}
|
|
110
|
+
/** Tells the provider to start or stop state when the views' wish changed. */
|
|
111
|
+
#syncState() {
|
|
112
|
+
if (this.ended || this.views.size === 0)
|
|
113
|
+
return;
|
|
114
|
+
const wanted = this.stateViews > 0;
|
|
115
|
+
if (wanted === this.stateOn)
|
|
116
|
+
return;
|
|
117
|
+
this.stateOn = wanted;
|
|
118
|
+
if (!wanted) {
|
|
119
|
+
this.hydrated = false;
|
|
120
|
+
this.members.clear();
|
|
121
|
+
if (this.#pending !== undefined)
|
|
122
|
+
this.#pending.missing.clear();
|
|
123
|
+
}
|
|
124
|
+
if (this.hold !== undefined)
|
|
125
|
+
this.hold.channel.wantState(this.hold.id, wanted);
|
|
126
|
+
for (const view of this.views)
|
|
127
|
+
view.update();
|
|
128
|
+
}
|
|
129
|
+
listen(name, readable) {
|
|
130
|
+
let set = this.#listeners.get(name);
|
|
131
|
+
if (set === undefined) {
|
|
132
|
+
set = new Set();
|
|
133
|
+
this.#listeners.set(name, set);
|
|
134
|
+
}
|
|
135
|
+
set.add(readable);
|
|
136
|
+
}
|
|
137
|
+
unlisten(name, readable) {
|
|
138
|
+
this.#listeners.get(name)?.delete(readable);
|
|
139
|
+
}
|
|
140
|
+
/** Whether `name` is a state member of the applied variant (as the consumer's contract describes it). */
|
|
141
|
+
inVariant(name) {
|
|
142
|
+
const variant = this.shape?.variant;
|
|
143
|
+
return variant !== undefined && this.contract.description.variants[variant]?.[name]?.kind === "state";
|
|
144
|
+
}
|
|
145
|
+
/** The members' value as views read it: throws `not_ready` before the first hydration, `not_in_variant` outside it. */
|
|
146
|
+
read(name) {
|
|
147
|
+
if (this.status === "ended")
|
|
148
|
+
throw this.terminalError();
|
|
149
|
+
if (!this.hydrated)
|
|
150
|
+
throw new ChordError("not_ready", `${this.key}.${name} is not hydrated yet`);
|
|
151
|
+
if (!this.inVariant(name))
|
|
152
|
+
throw new ChordError("not_in_variant", `${this.key}: ${name} is not a state member of variant ${this.shape?.variant}`);
|
|
153
|
+
return this.members.get(name)?.value;
|
|
154
|
+
}
|
|
155
|
+
/** Resolves once the binding has its state (rejects if it ends first). */
|
|
156
|
+
whenHydrated() {
|
|
157
|
+
if (this.hydrated)
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
if (this.ended)
|
|
160
|
+
return Promise.reject(this.terminalError());
|
|
161
|
+
return new Promise((resolve, reject) => this.#hydrationWaiters.add({ resolve, reject }));
|
|
162
|
+
}
|
|
163
|
+
#settleHydration(error) {
|
|
164
|
+
const waiters = [...this.#hydrationWaiters];
|
|
165
|
+
this.#hydrationWaiters.clear();
|
|
166
|
+
for (const waiter of waiters) {
|
|
167
|
+
if (error === undefined)
|
|
168
|
+
waiter.resolve();
|
|
169
|
+
else
|
|
170
|
+
waiter.reject(error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
readable(name) {
|
|
174
|
+
return this.status !== "ended" && this.hydrated && this.inVariant(name);
|
|
175
|
+
}
|
|
176
|
+
terminalError() {
|
|
177
|
+
return this.error ?? new ChordError("released", `reference ${this.key} released`);
|
|
178
|
+
}
|
|
179
|
+
// ─── driven by the host and the link ───
|
|
180
|
+
bound(channel, id) {
|
|
181
|
+
this.hold = { channel, id };
|
|
182
|
+
this.stateOn = this.wantsState;
|
|
183
|
+
}
|
|
184
|
+
/** A unit begins: `listed` are the members whose snapshots follow. */
|
|
185
|
+
shaped(shape, listed) {
|
|
186
|
+
if (this.ended)
|
|
187
|
+
return;
|
|
188
|
+
const previous = this.#pending;
|
|
189
|
+
const missing = new Set(listed);
|
|
190
|
+
// A newer shape before the older one's snapshots (relays may reorder them): wait for both.
|
|
191
|
+
if (previous !== undefined)
|
|
192
|
+
for (const name of previous.missing)
|
|
193
|
+
missing.add(name);
|
|
194
|
+
const wanted = new Set(this.#stateMembers(shape));
|
|
195
|
+
for (const name of [...missing])
|
|
196
|
+
if (!wanted.has(name) || !this.stateOn)
|
|
197
|
+
missing.delete(name);
|
|
198
|
+
this.#pending = { shape, missing, values: previous?.values ?? new Map() };
|
|
199
|
+
if (missing.size === 0)
|
|
200
|
+
this.#apply();
|
|
201
|
+
}
|
|
202
|
+
/** A state frame for member `name` of the current hold. */
|
|
203
|
+
state(name, frame) {
|
|
204
|
+
if (this.ended)
|
|
205
|
+
return;
|
|
206
|
+
if (frame.as === "error") {
|
|
207
|
+
const error = fromWireError(frame.error);
|
|
208
|
+
// Before the first hydration nobody could read anything: the binding fails instead of waiting forever.
|
|
209
|
+
if (!this.hydrated && this.stateOn) {
|
|
210
|
+
this.gone(error);
|
|
211
|
+
this.close();
|
|
212
|
+
}
|
|
213
|
+
else
|
|
214
|
+
this.host.report(error);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const pending = this.#pending;
|
|
218
|
+
if (frame.as === "ops") {
|
|
219
|
+
const target = pending?.values.get(name) ?? this.members.get(name);
|
|
220
|
+
if (target === undefined)
|
|
221
|
+
return;
|
|
222
|
+
const ops = target.decoder.decode(frame.ops ?? []);
|
|
223
|
+
target.value = applyImmutable(target.value, ops);
|
|
224
|
+
if (target === this.members.get(name))
|
|
225
|
+
this.#deliver(name, { type: "ops", ops, value: target.value });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const fresh = { value: frame.value, decoder: decoder() };
|
|
229
|
+
if (pending !== undefined && (pending.missing.has(name) || pending.values.has(name))) {
|
|
230
|
+
pending.values.set(name, fresh);
|
|
231
|
+
pending.missing.delete(name);
|
|
232
|
+
if (pending.missing.size === 0)
|
|
233
|
+
this.#apply();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!this.inVariant(name))
|
|
237
|
+
return;
|
|
238
|
+
this.members.set(name, fresh);
|
|
239
|
+
this.#deliver(name, { type: "reset", value: fresh.value });
|
|
240
|
+
}
|
|
241
|
+
/** The provider ended the object (or refused the hold): every view is gone. */
|
|
242
|
+
gone(error) {
|
|
243
|
+
if (this.ended)
|
|
244
|
+
return;
|
|
245
|
+
this.status = "ended";
|
|
246
|
+
this.error = error;
|
|
247
|
+
this.hold = undefined;
|
|
248
|
+
this.#pending = undefined;
|
|
249
|
+
this.#live.reject(error);
|
|
250
|
+
this.#settleHydration(error);
|
|
251
|
+
for (const view of [...this.views])
|
|
252
|
+
view.end(error);
|
|
253
|
+
this.#listeners.clear();
|
|
254
|
+
this.host.forgetBinding(this);
|
|
255
|
+
}
|
|
256
|
+
/** The link went down but may come back (upstream reconnect): keep the values, wait for the re-hold. */
|
|
257
|
+
detached() {
|
|
258
|
+
if (this.ended)
|
|
259
|
+
return;
|
|
260
|
+
this.hold = undefined;
|
|
261
|
+
this.#pending = undefined;
|
|
262
|
+
if (this.status === "live") {
|
|
263
|
+
this.status = "loading";
|
|
264
|
+
this.#live = deferred();
|
|
265
|
+
}
|
|
266
|
+
for (const view of this.views)
|
|
267
|
+
view.update();
|
|
268
|
+
}
|
|
269
|
+
/** No views and no linger: releases the hold and forgets the binding. */
|
|
270
|
+
close() {
|
|
271
|
+
if (this.lingerTimer !== undefined)
|
|
272
|
+
clearTimeout(this.lingerTimer);
|
|
273
|
+
this.lingerTimer = undefined;
|
|
274
|
+
if (this.hold !== undefined)
|
|
275
|
+
this.hold.channel.release(this.hold.id);
|
|
276
|
+
this.hold = undefined;
|
|
277
|
+
if (this.status !== "ended") {
|
|
278
|
+
this.status = "ended";
|
|
279
|
+
const error = new ChordError("released", `reference ${this.key} released`);
|
|
280
|
+
this.#live.reject(error);
|
|
281
|
+
this.#settleHydration(error);
|
|
282
|
+
for (const view of [...this.views])
|
|
283
|
+
view.end(error);
|
|
284
|
+
}
|
|
285
|
+
this.#pending = undefined;
|
|
286
|
+
this.#listeners.clear();
|
|
287
|
+
this.host.forgetBinding(this);
|
|
288
|
+
}
|
|
289
|
+
/** Calls `name` over the current hold, waiting for it while the binding (re)holds. */
|
|
290
|
+
async call(name, args, ctx) {
|
|
291
|
+
for (;;) {
|
|
292
|
+
if (this.ended)
|
|
293
|
+
throw this.terminalError();
|
|
294
|
+
if (this.status !== "live" || this.hold === undefined)
|
|
295
|
+
await raceSignal(this.#live.promise, ctx.signal);
|
|
296
|
+
const hold = this.hold;
|
|
297
|
+
if (hold === undefined || this.status !== "live")
|
|
298
|
+
continue;
|
|
299
|
+
const variant = this.shape?.variant;
|
|
300
|
+
const description = variant === undefined ? undefined : this.contract.description.variants[variant]?.[name];
|
|
301
|
+
if (description?.kind !== "method") {
|
|
302
|
+
throw new ChordError("not_in_variant", `${this.key}: ${name} is not a method of variant ${variant}`);
|
|
303
|
+
}
|
|
304
|
+
// Send only what the consumer's own schema knows (the additive rule's premise; see ObjectImpl.call).
|
|
305
|
+
const sent = description.args === undefined ? undefined : Value.Clean(description.args, Value.Clone(args));
|
|
306
|
+
return hold.channel.call(hold.id, name, sent, { rootId: ctx.rootId, codeVersion: ctx.codeVersion }, ctx.signal);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
#stateMembers(shape) {
|
|
310
|
+
const members = shape.variant === undefined ? undefined : this.contract.description.variants[shape.variant];
|
|
311
|
+
if (members === undefined)
|
|
312
|
+
return [];
|
|
313
|
+
return shape.members.filter((name) => members[name]?.kind === "state");
|
|
314
|
+
}
|
|
315
|
+
/** Applies the pending shape with its snapshots: members outside the variant go, new values are announced. */
|
|
316
|
+
#apply() {
|
|
317
|
+
const pending = this.#pending;
|
|
318
|
+
if (pending === undefined)
|
|
319
|
+
return;
|
|
320
|
+
this.#pending = undefined;
|
|
321
|
+
this.shape = pending.shape;
|
|
322
|
+
const wanted = new Set(this.#stateMembers(pending.shape));
|
|
323
|
+
for (const name of [...this.members.keys()])
|
|
324
|
+
if (!wanted.has(name))
|
|
325
|
+
this.members.delete(name);
|
|
326
|
+
for (const [name, state] of pending.values)
|
|
327
|
+
if (wanted.has(name))
|
|
328
|
+
this.members.set(name, state);
|
|
329
|
+
this.hydrated = this.stateOn && [...wanted].every((name) => this.members.has(name));
|
|
330
|
+
if (this.hydrated)
|
|
331
|
+
this.#settleHydration();
|
|
332
|
+
this.status = "live";
|
|
333
|
+
this.#applied = true;
|
|
334
|
+
this.#live.resolve();
|
|
335
|
+
for (const view of this.views)
|
|
336
|
+
view.update();
|
|
337
|
+
for (const [name, state] of pending.values) {
|
|
338
|
+
if (wanted.has(name))
|
|
339
|
+
this.#deliver(name, { type: "reset", value: state.value });
|
|
340
|
+
}
|
|
341
|
+
for (const view of [...this.views])
|
|
342
|
+
view.shapeChanged();
|
|
343
|
+
}
|
|
344
|
+
#deliver(name, event) {
|
|
345
|
+
const readables = this.#listeners.get(name);
|
|
346
|
+
if (readables === undefined)
|
|
347
|
+
return;
|
|
348
|
+
for (const readable of [...readables])
|
|
349
|
+
readable.deliver(event);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
7
352
|
/**
|
|
8
|
-
* A state member of a remote
|
|
9
|
-
*
|
|
353
|
+
* A state member of a remote reference: the binding's value. Reading throws `not_ready` before the first hydration and
|
|
354
|
+
* `not_in_variant` outside the variant. A listener added before the hydration gets its snapshot then.
|
|
10
355
|
*/
|
|
11
|
-
class
|
|
12
|
-
|
|
356
|
+
class ViewReadable {
|
|
357
|
+
view;
|
|
13
358
|
#name;
|
|
14
359
|
#values = new Set();
|
|
15
360
|
#events = new Set();
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.#handle = handle;
|
|
361
|
+
/** Event listeners that have not had their snapshot yet. */
|
|
362
|
+
#unseen = new Set();
|
|
363
|
+
constructor(view, name) {
|
|
364
|
+
this.view = view;
|
|
21
365
|
this.#name = name;
|
|
22
366
|
}
|
|
23
367
|
get value() {
|
|
24
|
-
|
|
368
|
+
if (this.view.ended)
|
|
369
|
+
throw this.view.terminalError();
|
|
370
|
+
return this.view.binding.read(this.#name);
|
|
371
|
+
}
|
|
372
|
+
/** Not hydrated before the binding's first snapshots (a local object forwarding this member waits for them). */
|
|
373
|
+
get [HYDRATION]() {
|
|
374
|
+
const binding = this.view.binding;
|
|
375
|
+
if (binding.hydrated)
|
|
376
|
+
return undefined;
|
|
377
|
+
return { hydrated: false, whenHydrated: () => binding.whenHydrated() };
|
|
25
378
|
}
|
|
26
379
|
subscribe(listener) {
|
|
27
380
|
this.#values.add(listener);
|
|
28
|
-
this.#
|
|
29
|
-
this
|
|
381
|
+
this.#attach();
|
|
382
|
+
if (this.#readable())
|
|
383
|
+
this.#safe(() => listener(this.view.binding.members.get(this.#name)?.value));
|
|
30
384
|
return () => this.#remove(this.#values, listener);
|
|
31
385
|
}
|
|
32
386
|
events(listener) {
|
|
33
387
|
this.#events.add(listener);
|
|
34
|
-
this.#
|
|
35
|
-
this
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
/** Subscribes upstream while someone listens and the handle is bound; drops a subscription on an old link. */
|
|
39
|
-
bind() {
|
|
40
|
-
const listening = this.#values.size + this.#events.size > 0;
|
|
41
|
-
// Nobody listens: the value would go stale, so forget it. The next subscriber sees `undefined`, then the snapshot.
|
|
42
|
-
if (!listening)
|
|
43
|
-
this.#value = undefined;
|
|
44
|
-
const binding = listening ? this.#handle.binding : undefined;
|
|
45
|
-
if (binding !== undefined && this.#sub?.channel === binding.channel)
|
|
46
|
-
return;
|
|
47
|
-
if (this.#sub !== undefined) {
|
|
48
|
-
if (this.#sub.channel !== binding?.channel || binding === undefined)
|
|
49
|
-
this.#sub.channel.unsub(this.#sub.id);
|
|
50
|
-
this.#sub = undefined;
|
|
388
|
+
this.#attach();
|
|
389
|
+
if (this.#readable()) {
|
|
390
|
+
const value = this.view.binding.members.get(this.#name)?.value;
|
|
391
|
+
this.#safe(() => listener({ type: "snap", value }));
|
|
51
392
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
393
|
+
else
|
|
394
|
+
this.#unseen.add(listener);
|
|
395
|
+
return () => {
|
|
396
|
+
this.#unseen.delete(listener);
|
|
397
|
+
this.#remove(this.#events, listener);
|
|
398
|
+
};
|
|
58
399
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
400
|
+
deliver(event) {
|
|
401
|
+
for (const listener of [...this.#events]) {
|
|
402
|
+
if (!this.#unseen.delete(listener))
|
|
403
|
+
this.#safe(() => listener(event));
|
|
404
|
+
else if (event.type !== "ops")
|
|
405
|
+
this.#safe(() => listener({ type: "snap", value: event.value }));
|
|
406
|
+
}
|
|
407
|
+
for (const listener of [...this.#values])
|
|
408
|
+
this.#safe(() => listener(event.value));
|
|
62
409
|
}
|
|
63
410
|
stop() {
|
|
64
411
|
this.#values.clear();
|
|
65
412
|
this.#events.clear();
|
|
66
|
-
this.
|
|
413
|
+
this.#unseen.clear();
|
|
414
|
+
this.view.binding.unlisten(this.#name, this);
|
|
67
415
|
}
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (frame.ops === undefined && !("snap" in frame) && !("reset" in frame))
|
|
75
|
-
return;
|
|
76
|
-
if (frame.ops !== undefined) {
|
|
77
|
-
const ops = this.#decoder.decode(frame.ops);
|
|
78
|
-
this.#value = applyImmutable(this.#value, ops);
|
|
79
|
-
event = { type: "ops", ops, value: this.#value };
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
// Listeners already saw a snapshot (`undefined` or an older value), so every upstream snapshot is a reset.
|
|
83
|
-
this.#decoder = decoder();
|
|
84
|
-
this.#value = "snap" in frame ? frame.snap : frame.reset;
|
|
85
|
-
event = { type: "reset", value: this.#value };
|
|
86
|
-
}
|
|
87
|
-
for (const listener of [...this.#events])
|
|
88
|
-
this.#safe(() => listener(event));
|
|
89
|
-
for (const listener of [...this.#values])
|
|
90
|
-
this.#safe(() => listener(this.#value));
|
|
416
|
+
#readable() {
|
|
417
|
+
return !this.view.ended && this.view.binding.readable(this.#name);
|
|
418
|
+
}
|
|
419
|
+
#attach() {
|
|
420
|
+
if (!this.view.ended)
|
|
421
|
+
this.view.binding.listen(this.#name, this);
|
|
91
422
|
}
|
|
92
423
|
#remove(set, listener) {
|
|
93
424
|
set.delete(listener);
|
|
94
|
-
this.
|
|
425
|
+
if (this.#values.size + this.#events.size === 0)
|
|
426
|
+
this.view.binding.unlisten(this.#name, this);
|
|
95
427
|
}
|
|
96
428
|
#safe(fn) {
|
|
97
429
|
try {
|
|
98
430
|
fn();
|
|
99
431
|
}
|
|
100
432
|
catch (error) {
|
|
101
|
-
this
|
|
433
|
+
this.view.binding.host.report(error);
|
|
102
434
|
}
|
|
103
435
|
}
|
|
104
436
|
}
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
/** Forwarded principal (omitted for the host's own principal). */
|
|
113
|
-
principal;
|
|
437
|
+
/**
|
|
438
|
+
* A reference to an object on another host: a view onto the host's shared binding for its key. Each view has its own
|
|
439
|
+
* owner, status, observers and `release`; ending by the provider and re-holding belong to the binding.
|
|
440
|
+
*/
|
|
441
|
+
export class RemoteRef {
|
|
442
|
+
object = undefined;
|
|
443
|
+
binding;
|
|
114
444
|
owner;
|
|
115
|
-
|
|
445
|
+
/** False: a method-only view (`state: false`), ready once live. */
|
|
446
|
+
wantsState;
|
|
116
447
|
proxy;
|
|
117
448
|
ready;
|
|
118
449
|
status = "loading";
|
|
119
450
|
error;
|
|
120
|
-
shape;
|
|
121
|
-
binding;
|
|
122
451
|
shapeListeners = new Set();
|
|
452
|
+
#statusListeners = new Set();
|
|
123
453
|
#readables = new Map();
|
|
124
454
|
#callers = new Map();
|
|
125
455
|
#settleReady;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
this.
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
this.
|
|
132
|
-
this
|
|
133
|
-
this.owner = fields.owner;
|
|
134
|
-
this.address = fields.address;
|
|
135
|
-
this.ready = new Promise((resolve, reject) => {
|
|
136
|
-
this.#settleReady = { resolve, reject };
|
|
137
|
-
});
|
|
138
|
-
this.ready.catch(noop);
|
|
139
|
-
this.#live = this.#newLive();
|
|
456
|
+
constructor(binding, owner, wantsState) {
|
|
457
|
+
this.binding = binding;
|
|
458
|
+
this.owner = owner;
|
|
459
|
+
this.wantsState = wantsState;
|
|
460
|
+
const ready = deferred();
|
|
461
|
+
this.ready = ready.promise;
|
|
462
|
+
this.#settleReady = ready;
|
|
140
463
|
this.proxy = new Proxy(this, {
|
|
141
464
|
get: (target, property) => {
|
|
142
|
-
if (
|
|
465
|
+
if (REF_BASE.has(property)) {
|
|
143
466
|
const value = Reflect.get(target, property, target);
|
|
144
467
|
return typeof value === "function" ? value.bind(target) : value;
|
|
145
468
|
}
|
|
@@ -148,10 +471,22 @@ export class RemoteHandle {
|
|
|
148
471
|
return target.member(property);
|
|
149
472
|
},
|
|
150
473
|
});
|
|
151
|
-
|
|
474
|
+
refsByProxy.set(this.proxy, this);
|
|
475
|
+
}
|
|
476
|
+
get contract() {
|
|
477
|
+
return this.binding.contract;
|
|
478
|
+
}
|
|
479
|
+
get address() {
|
|
480
|
+
return this.binding.key;
|
|
481
|
+
}
|
|
482
|
+
get ended() {
|
|
483
|
+
return this.status === "released" || this.status === "gone";
|
|
152
484
|
}
|
|
153
|
-
get
|
|
154
|
-
return this.shape?.
|
|
485
|
+
get variant() {
|
|
486
|
+
return this.binding.shape?.variant;
|
|
487
|
+
}
|
|
488
|
+
get shape() {
|
|
489
|
+
return this.binding.shape;
|
|
155
490
|
}
|
|
156
491
|
onShape(listener) {
|
|
157
492
|
this.shapeListeners.add(listener);
|
|
@@ -161,78 +496,65 @@ export class RemoteHandle {
|
|
|
161
496
|
this.#statusListeners.add(listener);
|
|
162
497
|
return () => this.#statusListeners.delete(listener);
|
|
163
498
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.host.report(error));
|
|
499
|
+
terminalError() {
|
|
500
|
+
return this.status === "gone" ? this.error : new ChordError("released", `reference ${this.address} released`);
|
|
167
501
|
}
|
|
168
502
|
release() {
|
|
169
|
-
if (this.
|
|
503
|
+
if (this.ended)
|
|
170
504
|
return;
|
|
171
505
|
this.status = "released";
|
|
172
|
-
this.#
|
|
173
|
-
|
|
174
|
-
this.#settleReady
|
|
175
|
-
this.#live.reject(error);
|
|
506
|
+
this.#notify();
|
|
507
|
+
this.#settleReady?.reject(new ChordError("released", `reference ${this.address} released`));
|
|
508
|
+
this.#settleReady = undefined;
|
|
176
509
|
for (const readable of this.#readables.values())
|
|
177
510
|
readable.stop();
|
|
178
|
-
|
|
179
|
-
this.binding.channel.release(this.binding.id);
|
|
180
|
-
this.binding = undefined;
|
|
181
|
-
this.host.forgetRemote(this);
|
|
182
|
-
}
|
|
183
|
-
// ─── driven by the link ───
|
|
184
|
-
bound(channel, id) {
|
|
185
|
-
this.binding = { channel, id };
|
|
511
|
+
this.binding.removeView(this);
|
|
186
512
|
}
|
|
187
|
-
|
|
188
|
-
|
|
513
|
+
// ─── driven by the binding ───
|
|
514
|
+
/** Follows the binding: live once it has a shape (and, for a state view, its state) over a current hold. */
|
|
515
|
+
update() {
|
|
516
|
+
if (this.ended)
|
|
189
517
|
return;
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
518
|
+
const binding = this.binding;
|
|
519
|
+
const live = binding.status === "live" && binding.hold !== undefined && (!this.wantsState || binding.hydrated);
|
|
520
|
+
const next = live ? "live" : "loading";
|
|
521
|
+
if (next === this.status)
|
|
522
|
+
return;
|
|
523
|
+
this.status = next;
|
|
524
|
+
this.#notify();
|
|
525
|
+
if (live) {
|
|
196
526
|
this.#settleReady?.resolve();
|
|
197
527
|
this.#settleReady = undefined;
|
|
198
|
-
this.#live.resolve();
|
|
199
528
|
}
|
|
529
|
+
}
|
|
530
|
+
end(error) {
|
|
531
|
+
if (this.ended)
|
|
532
|
+
return;
|
|
533
|
+
const released = error instanceof ChordError && error.code === "released";
|
|
534
|
+
this.status = released ? "released" : "gone";
|
|
535
|
+
if (!released)
|
|
536
|
+
this.error = error;
|
|
537
|
+
this.#notify();
|
|
538
|
+
this.#settleReady?.reject(error);
|
|
539
|
+
this.#settleReady = undefined;
|
|
200
540
|
for (const readable of this.#readables.values())
|
|
201
|
-
readable.
|
|
541
|
+
readable.stop();
|
|
542
|
+
}
|
|
543
|
+
shapeChanged() {
|
|
544
|
+
const shape = this.binding.shape;
|
|
545
|
+
if (shape === undefined || this.ended)
|
|
546
|
+
return;
|
|
202
547
|
for (const listener of [...this.shapeListeners]) {
|
|
203
548
|
try {
|
|
204
549
|
listener(shape);
|
|
205
550
|
}
|
|
206
551
|
catch (error) {
|
|
207
|
-
this.host.report(error);
|
|
552
|
+
this.binding.host.report(error);
|
|
208
553
|
}
|
|
209
554
|
}
|
|
210
555
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return;
|
|
214
|
-
this.status = "gone";
|
|
215
|
-
this.error = error;
|
|
216
|
-
this.#statusChanged();
|
|
217
|
-
this.binding = undefined;
|
|
218
|
-
this.#settleReady?.reject(error);
|
|
219
|
-
this.#live.reject(error);
|
|
220
|
-
for (const readable of this.#readables.values())
|
|
221
|
-
readable.stop();
|
|
222
|
-
this.host.forgetRemote(this);
|
|
223
|
-
}
|
|
224
|
-
/** The link went down but may come back (upstream reconnect): wait, then re-hold. */
|
|
225
|
-
detached() {
|
|
226
|
-
if (this.status === "released" || this.status === "gone")
|
|
227
|
-
return;
|
|
228
|
-
this.binding = undefined;
|
|
229
|
-
for (const readable of this.#readables.values())
|
|
230
|
-
readable.detach();
|
|
231
|
-
if (this.status === "live") {
|
|
232
|
-
this.status = "loading";
|
|
233
|
-
this.#live = this.#newLive();
|
|
234
|
-
this.#statusChanged();
|
|
235
|
-
}
|
|
556
|
+
#notify() {
|
|
557
|
+
notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.binding.host.report(error));
|
|
236
558
|
}
|
|
237
559
|
// ─── members ───
|
|
238
560
|
member(name) {
|
|
@@ -244,7 +566,7 @@ export class RemoteHandle {
|
|
|
244
566
|
if (description?.kind === "state") {
|
|
245
567
|
let readable = this.#readables.get(name);
|
|
246
568
|
if (readable === undefined) {
|
|
247
|
-
readable = new
|
|
569
|
+
readable = new ViewReadable(this, name);
|
|
248
570
|
this.#readables.set(name, readable);
|
|
249
571
|
}
|
|
250
572
|
return readable;
|
|
@@ -254,19 +576,26 @@ export class RemoteHandle {
|
|
|
254
576
|
#caller(name) {
|
|
255
577
|
let caller = this.#callers.get(name);
|
|
256
578
|
if (caller === undefined) {
|
|
257
|
-
|
|
258
|
-
|
|
579
|
+
const noArgs = takesNoArgs(this.contract, name);
|
|
580
|
+
caller = (...given) => {
|
|
581
|
+
// A method without arguments is called as `ref.name(ctx)`.
|
|
582
|
+
const [args, ctx] = (noArgs ? [undefined, given[0]] : given);
|
|
583
|
+
const host = this.binding.host;
|
|
584
|
+
if (host.inFactory > 0)
|
|
259
585
|
throw new ChordError("not_ready", `${this.address}.${name} called inside a factory`);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
586
|
+
// The same checks as a local reference's caller (RefImpl.caller and ObjectImpl.call): a ctx from another host
|
|
587
|
+
// or one not made by Chord would carry a root this host does not own over its link.
|
|
588
|
+
if (ctx !== undefined && !(ctx instanceof CtxImpl)) {
|
|
589
|
+
return Promise.reject(invalid("foreign_ctx", "ctx must come from a Chord host"));
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
ctx?.check(host);
|
|
267
593
|
}
|
|
268
|
-
|
|
269
|
-
return
|
|
594
|
+
catch (error) {
|
|
595
|
+
return Promise.reject(error);
|
|
596
|
+
}
|
|
597
|
+
if (ctx === undefined || ctx.holder) {
|
|
598
|
+
return host.root((root) => this.#call(name, args, root), { signal: ctx?.signal });
|
|
270
599
|
}
|
|
271
600
|
return this.#call(name, args, ctx);
|
|
272
601
|
};
|
|
@@ -274,35 +603,10 @@ export class RemoteHandle {
|
|
|
274
603
|
}
|
|
275
604
|
return caller;
|
|
276
605
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
if (this.status === "loading")
|
|
283
|
-
await raceSignal(this.#live.promise, ctx.signal);
|
|
284
|
-
const binding = this.binding;
|
|
285
|
-
if (binding === undefined)
|
|
286
|
-
continue;
|
|
287
|
-
const kind = this.shape?.kind;
|
|
288
|
-
if (kind === undefined || this.contract.description.variants[kind]?.[name]?.kind !== "method") {
|
|
289
|
-
throw new ChordError("not_in_variant", `${this.address}: ${name} is not a method of variant ${kind}`);
|
|
290
|
-
}
|
|
291
|
-
// Send only what the consumer's own schema knows (the additive rule's premise; see RecordImpl.call).
|
|
292
|
-
const description = this.contract.description.variants[kind]?.[name];
|
|
293
|
-
const sent = description?.kind === "method" ? Value.Clean(description.args, Value.Clone(args)) : args;
|
|
294
|
-
return binding.channel.call(binding.id, name, sent, { rootId: ctx.rootId, epoch: ctx.epoch }, ctx.signal);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
#newLive() {
|
|
298
|
-
let resolve = noop;
|
|
299
|
-
let reject = noop;
|
|
300
|
-
const promise = new Promise((done, fail) => {
|
|
301
|
-
resolve = done;
|
|
302
|
-
reject = fail;
|
|
303
|
-
});
|
|
304
|
-
promise.catch(noop);
|
|
305
|
-
return { promise, resolve, reject };
|
|
606
|
+
#call(name, args, ctx) {
|
|
607
|
+
if (this.ended)
|
|
608
|
+
return Promise.reject(this.terminalError());
|
|
609
|
+
return this.binding.call(name, args, ctx);
|
|
306
610
|
}
|
|
307
611
|
}
|
|
308
612
|
//# sourceMappingURL=remote.js.map
|