@ccmsg/cli 0.1.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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
package/src/mesh/mesh.ts
ADDED
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import {
|
|
3
|
+
type CallerIdentity,
|
|
4
|
+
type Endpoint,
|
|
5
|
+
type InstanceId,
|
|
6
|
+
type InstanceInfo,
|
|
7
|
+
PROTOCOL_VERSION,
|
|
8
|
+
type Sid,
|
|
9
|
+
type Timestamp,
|
|
10
|
+
} from "@ccmsg/protocol";
|
|
11
|
+
import { type Conn, type ConnRegistry, dialWs } from "../transport/index.ts";
|
|
12
|
+
import {
|
|
13
|
+
type DispatchResult,
|
|
14
|
+
failure,
|
|
15
|
+
OpError,
|
|
16
|
+
reply,
|
|
17
|
+
type Requester,
|
|
18
|
+
type SettledIdentity,
|
|
19
|
+
} from "../dispatch/index.ts";
|
|
20
|
+
import type { TopicValue } from "../topics/index.ts";
|
|
21
|
+
import { isClusterTopic, Relay } from "./relay.ts";
|
|
22
|
+
import {
|
|
23
|
+
ALLOWED_ALGS,
|
|
24
|
+
EphemeralKey,
|
|
25
|
+
type MeshJwk,
|
|
26
|
+
MESH_VER,
|
|
27
|
+
parseProof,
|
|
28
|
+
PROOF_LIFETIME_MS,
|
|
29
|
+
ProofError,
|
|
30
|
+
randomId,
|
|
31
|
+
verifyProof,
|
|
32
|
+
} from "./keys.ts";
|
|
33
|
+
import { PeerProbe, type PeerReport } from "./probe.ts";
|
|
34
|
+
import {
|
|
35
|
+
isProbePath,
|
|
36
|
+
jwkEndpoint,
|
|
37
|
+
type JwkRequest,
|
|
38
|
+
type JwkResponse,
|
|
39
|
+
kidOfPath,
|
|
40
|
+
MESH_PROTOCOL,
|
|
41
|
+
meshFrameOf,
|
|
42
|
+
type ProbeBody,
|
|
43
|
+
wsEndpoint,
|
|
44
|
+
} from "./wire.ts";
|
|
45
|
+
|
|
46
|
+
/** What a greeting claims, as the contract spells it. Taken as unknown fields
|
|
47
|
+
* because nothing here trusts it until the proof lands (mesh-peer-auth §5.3). */
|
|
48
|
+
export interface MeshClaim {
|
|
49
|
+
readonly ver: number;
|
|
50
|
+
readonly iss: Endpoint;
|
|
51
|
+
readonly aud: Endpoint;
|
|
52
|
+
/** Which instance answers at `iss`. Worth nothing until the proof lands,
|
|
53
|
+
* after which the whole greeting is trusted and this is what binds the
|
|
54
|
+
* endpoint to an id (DR-0001 §2.1). */
|
|
55
|
+
readonly id: InstanceId;
|
|
56
|
+
readonly kid: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The close code a glare loser is closed with.
|
|
60
|
+
*
|
|
61
|
+
* In the range WebSocket leaves to applications. It exists so the far end can
|
|
62
|
+
* tell this closure from a fault: losing a glare is its normal course, and
|
|
63
|
+
* reconnecting on it would reopen exactly the connection both sides just
|
|
64
|
+
* agreed to drop (§8.1). */
|
|
65
|
+
export const GLARE_CLOSE = 4000;
|
|
66
|
+
|
|
67
|
+
/** How often a link is asked whether it is still there, and how long silence
|
|
68
|
+
* may last before it is treated as gone.
|
|
69
|
+
*
|
|
70
|
+
* Provisional values: mesh-peer-auth §8.3 requires a heartbeat and states no
|
|
71
|
+
* period, and no primary source gives one, so these are chosen rather than
|
|
72
|
+
* derived. The reasoning behind the choice is that the heartbeat exists to
|
|
73
|
+
* catch a silent drop by a middlebox, whose idle timeouts are conventionally
|
|
74
|
+
* around a minute, and that three missed beats is the usual margin before a
|
|
75
|
+
* link is called dead. Replace them with measured values when a deployment
|
|
76
|
+
* gives any.
|
|
77
|
+
*
|
|
78
|
+
* This is the periodic timer this layer owns, and its whole justification (M3).
|
|
79
|
+
* There is no other: reconnection is scheduled per failure rather than polled,
|
|
80
|
+
* and reachability is read from the links rather than swept. */
|
|
81
|
+
export const HEARTBEAT_MS = 20_000;
|
|
82
|
+
export const HEARTBEAT_TIMEOUT_MS = 3 * HEARTBEAT_MS;
|
|
83
|
+
|
|
84
|
+
/** The reconnection backoff (§8.2).
|
|
85
|
+
*
|
|
86
|
+
* Loose on purpose: a peer that comes back dials us, so the moment it recovers
|
|
87
|
+
* is signalled by its own start rather than found by our retries. What this
|
|
88
|
+
* schedule is for is the case where only our side of the link failed. */
|
|
89
|
+
export const RECONNECT_MIN_MS = 5_000;
|
|
90
|
+
export const RECONNECT_MAX_MS = 60_000;
|
|
91
|
+
|
|
92
|
+
/** How many key requests are answered per second, over all callers.
|
|
93
|
+
*
|
|
94
|
+
* The key endpoint is reached before anything is proven (§6), so it is the one
|
|
95
|
+
* surface an unauthenticated caller can make this instance do work on. The cap
|
|
96
|
+
* is well above what a mesh of any size needs — one request per connection
|
|
97
|
+
* established — and well below what would cost anything. */
|
|
98
|
+
const JWK_RATE_LIMIT = 20;
|
|
99
|
+
const JWK_RATE_WINDOW_MS = 1_000;
|
|
100
|
+
|
|
101
|
+
/** How long a forwarded op may take before its caller is told the instance
|
|
102
|
+
* could not be reached (§7.3).
|
|
103
|
+
*
|
|
104
|
+
* Chosen rather than derived: no primary source states a deadline. The
|
|
105
|
+
* reasoning is that the two outcomes this sits between are both worse than a
|
|
106
|
+
* plain answer — a caller left waiting on an instance that will never reply,
|
|
107
|
+
* and a caller told "unreachable" about an instance that was merely busy — and
|
|
108
|
+
* that the heartbeat already declares a silent link dead well after this, so
|
|
109
|
+
* the deadline is about the op rather than about the link. */
|
|
110
|
+
export const FORWARD_TIMEOUT_MS = 10_000;
|
|
111
|
+
|
|
112
|
+
/** What the instance gives the mesh once it exists.
|
|
113
|
+
*
|
|
114
|
+
* The mesh is built before the instance, because the listener has to be up for
|
|
115
|
+
* self-identification to reach it (§8.3), so the two things a link needs from
|
|
116
|
+
* the instance arrive afterwards rather than through the constructor. */
|
|
117
|
+
export interface MeshHost {
|
|
118
|
+
/** The one door a frame goes through (§3.2). A request a peer carried here
|
|
119
|
+
* is answered by the same dispatch every other request is. */
|
|
120
|
+
handle(frame: unknown, conn: Requester): Promise<DispatchResult>;
|
|
121
|
+
/** Hand a relayed frame to this instance's own subscribers (§7.4). */
|
|
122
|
+
publish(topic: string, data: unknown, instance: InstanceId): void;
|
|
123
|
+
/** Which instances can be reached has changed, which is part of what this
|
|
124
|
+
* instance states on `peers` (§7.5). */
|
|
125
|
+
changed(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface MeshDeps {
|
|
129
|
+
/** This instance's id, which is what it is called on the wire. */
|
|
130
|
+
readonly id: InstanceId;
|
|
131
|
+
/** Where peers reach this instance, from config (DR-0001 §2.7). */
|
|
132
|
+
readonly self: Endpoint;
|
|
133
|
+
readonly peers: readonly Endpoint[];
|
|
134
|
+
readonly conns: ConnRegistry;
|
|
135
|
+
readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
136
|
+
/** Something changed about which peers are reachable. */
|
|
137
|
+
readonly onChanged?: () => void;
|
|
138
|
+
readonly heartbeatMs?: number;
|
|
139
|
+
readonly heartbeatTimeoutMs?: number;
|
|
140
|
+
readonly reconnectMinMs?: number;
|
|
141
|
+
readonly forwardTimeoutMs?: number;
|
|
142
|
+
/** The clock the retention window of §7.5 is read against. */
|
|
143
|
+
readonly now?: () => Timestamp;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One established mesh link. */
|
|
147
|
+
interface Link {
|
|
148
|
+
readonly conn: Requester;
|
|
149
|
+
/** One actor per caller this link has spoken for, keyed by the identity
|
|
150
|
+
* itself. Cached rather than made per request because a subscription is held
|
|
151
|
+
* by a connection and released when it closes (§6.3): the topic mechanism
|
|
152
|
+
* has to see the same object each time one caller subscribes. */
|
|
153
|
+
readonly actors: Map<string, PeerActor>;
|
|
154
|
+
/** Which end opened the socket, which is what the glare rule compares (§8.1). */
|
|
155
|
+
readonly dialledByUs: boolean;
|
|
156
|
+
readonly heartbeat: ReturnType<typeof setInterval>;
|
|
157
|
+
lastHeard: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One request this instance forwarded and is waiting on (§7.3). */
|
|
161
|
+
interface Forwarded {
|
|
162
|
+
readonly peer: Endpoint;
|
|
163
|
+
/** The id the caller used, restored on the reply so the caller's connection
|
|
164
|
+
* settles the request it actually made. */
|
|
165
|
+
readonly requestId: string;
|
|
166
|
+
readonly settle: (result: DispatchResult) => void;
|
|
167
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One caller a proven peer forwarded here, as the connection its request
|
|
171
|
+
* arrived on.
|
|
172
|
+
*
|
|
173
|
+
* The envelope's `caller` is taken as said. It is the one thing the
|
|
174
|
+
* destination believes on the forwarder's word, and it can, because the link
|
|
175
|
+
* is authenticated: mesh-peer-auth proved the far end is an instance on the
|
|
176
|
+
* peer list, and a peer list is one deployment (§8.2). Everything else is
|
|
177
|
+
* decided here — the role check reads this identity against this instance's
|
|
178
|
+
* own attribute table, and so do the capability and locality checks, which is
|
|
179
|
+
* what §7.3 means by putting a forwarded op through the steps again rather
|
|
180
|
+
* than taking the forwarder's outcome for it.
|
|
181
|
+
*
|
|
182
|
+
* `from_instance` is not part of that judgement: the field says where the
|
|
183
|
+
* reply goes, and the link says who sent it. */
|
|
184
|
+
class PeerActor implements Requester {
|
|
185
|
+
constructor(
|
|
186
|
+
private readonly conn: Requester,
|
|
187
|
+
readonly identity: SettledIdentity,
|
|
188
|
+
) {}
|
|
189
|
+
|
|
190
|
+
send(frame: object): void {
|
|
191
|
+
this.conn.send(frame);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Sent rather than queued. Deferring exists so a subscribe's snapshot
|
|
195
|
+
* follows its acknowledgement on the caller's connection; a peer settles its
|
|
196
|
+
* subscription on the reply's `request_id` and folds frames as they arrive,
|
|
197
|
+
* so there is no order here to keep. */
|
|
198
|
+
deferSend(frame: object): void {
|
|
199
|
+
this.conn.send(frame);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
onClose(listener: () => void): void {
|
|
203
|
+
this.conn.onClose(listener);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
close(code?: number, reason?: string): void {
|
|
207
|
+
this.conn.close(code, reason);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One handshake this instance is verifying, as the receiving end (§5).
|
|
212
|
+
*
|
|
213
|
+
* Everything the verification needs is here and nowhere else, so the whole of
|
|
214
|
+
* what a handshake leaves behind is one map entry that is deleted when it
|
|
215
|
+
* finishes — which is what §10.5 asks be true. */
|
|
216
|
+
interface Pending {
|
|
217
|
+
readonly claim: MeshClaim;
|
|
218
|
+
readonly challenge: string;
|
|
219
|
+
readonly deliver: (jws: string) => void;
|
|
220
|
+
readonly fail: (cause: Error) => void;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One key this instance minted for a connection it dialled (§7). */
|
|
224
|
+
interface Minted {
|
|
225
|
+
readonly key: EphemeralKey;
|
|
226
|
+
readonly aud: Endpoint;
|
|
227
|
+
conn?: Requester;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Which of two connections to one peer survives a glare (§8.1).
|
|
231
|
+
*
|
|
232
|
+
* The connection opened by the smaller `iss` is the one that stays. Neither is
|
|
233
|
+
* better than the other — both were verified before either was dropped — so
|
|
234
|
+
* there is nothing to prefer; what the rule is for is that the two ends reach
|
|
235
|
+
* the same conclusion, which they do because they compare the same two strings.
|
|
236
|
+
*
|
|
237
|
+
* Stated apart from the link it decides about, because that is what makes "both
|
|
238
|
+
* ends agree" a thing that can be checked rather than inferred. */
|
|
239
|
+
export function glareKeepsNew(self: Endpoint, peer: Endpoint, dialledByUs: boolean): boolean {
|
|
240
|
+
return dialledByUs ? self < peer : peer < self;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The mesh: the links to the other instances, and the handshake that decides
|
|
244
|
+
* what each of them is.
|
|
245
|
+
*
|
|
246
|
+
* Every instance dials every peer, so there is no side that owns a link and no
|
|
247
|
+
* peer that cannot be recovered from the other end (§8, and §12's reason for
|
|
248
|
+
* not assigning the duty to one side). */
|
|
249
|
+
export class Mesh {
|
|
250
|
+
readonly #links = new Map<Endpoint, Link>();
|
|
251
|
+
readonly #pending = new Map<Requester, Pending>();
|
|
252
|
+
readonly #minted = new Map<string, Minted>();
|
|
253
|
+
readonly #retries = new Map<Endpoint, ReturnType<typeof setTimeout>>();
|
|
254
|
+
readonly #backoff = new Map<Endpoint, number>();
|
|
255
|
+
readonly #probe = new PeerProbe();
|
|
256
|
+
/** The configured endpoints that turned out to be this instance, filled in by
|
|
257
|
+
* `verify`. `self` is always among them; an alias of it that a peer list also
|
|
258
|
+
* names joins it there, and none of them is dialled. */
|
|
259
|
+
readonly #ours = new Set<Endpoint>();
|
|
260
|
+
/** The authenticated endpoint-to-id mapping (DR-0001 §2.1), in both
|
|
261
|
+
* directions: a handshake writes it, `to_instance` reads it to find the link
|
|
262
|
+
* to dial down, and a disconnection leaves it standing so a peer that is out
|
|
263
|
+
* of reach is still an instance this one knows the name of.
|
|
264
|
+
*
|
|
265
|
+
* One id binds to one endpoint. A greeting naming an id already bound
|
|
266
|
+
* elsewhere is refused, the standing binding being the one the operator's
|
|
267
|
+
* endpoint list has already vouched for. */
|
|
268
|
+
readonly #idOf = new Map<Endpoint, InstanceId>();
|
|
269
|
+
readonly #endpointOf = new Map<InstanceId, Endpoint>();
|
|
270
|
+
#jwkWindow = 0;
|
|
271
|
+
#jwkServed = 0;
|
|
272
|
+
#stopping = false;
|
|
273
|
+
|
|
274
|
+
readonly #marked = new WeakSet<Requester>();
|
|
275
|
+
/** The link a proven connection belongs to, for the two questions asked per
|
|
276
|
+
* frame: who it may speak for, and whether it may settle a forwarded reply. */
|
|
277
|
+
readonly #linkOf = new Map<Requester, Link>();
|
|
278
|
+
readonly #forwarded = new Map<string, Forwarded>();
|
|
279
|
+
/** The relayed topics local subscribers are asking for right now. `peers` is
|
|
280
|
+
* always among them: it is the routing table of §7.3, and a question about
|
|
281
|
+
* where a session lives is answered whether or not anyone is subscribed
|
|
282
|
+
* (§6.3, "reading the current value is not what subscription drives"). */
|
|
283
|
+
readonly #demanded = new Set<string>(["peers"]);
|
|
284
|
+
#host: MeshHost | undefined;
|
|
285
|
+
|
|
286
|
+
/** What the peers said, kept across a disconnection (§7.5). */
|
|
287
|
+
readonly relay: Relay;
|
|
288
|
+
|
|
289
|
+
constructor(private readonly deps: MeshDeps) {
|
|
290
|
+
// The table opens with the one binding this instance did not have to learn:
|
|
291
|
+
// its own. That is what makes "an id already answering elsewhere" cover the
|
|
292
|
+
// case of a peer claiming to be us — which is what the instance at a moved
|
|
293
|
+
// instance's old URL looks like from the new one.
|
|
294
|
+
this.#bind(deps.self, deps.id);
|
|
295
|
+
this.relay = new Relay({
|
|
296
|
+
publish: (topic, data, instance) => {
|
|
297
|
+
this.#host?.publish(topic, data, instance);
|
|
298
|
+
},
|
|
299
|
+
...(deps.now === undefined ? {} : { now: deps.now }),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Give the mesh the instance it belongs to (§8.3). */
|
|
304
|
+
bind(host: MeshHost): void {
|
|
305
|
+
this.#host = host;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The registry the mesh's own connections are in. It is the instance's, and
|
|
309
|
+
* is shared because a mesh link is one of its connections (§3.1). */
|
|
310
|
+
get conns(): ConnRegistry {
|
|
311
|
+
return this.deps.conns;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Note a connection that was let past the entry token as a peer. */
|
|
315
|
+
accept(conn: Requester, info: { readonly mesh: boolean }): void {
|
|
316
|
+
if (info.mesh) this.#marked.add(conn);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Whether this connection came in as a peer and has not proven what it is.
|
|
320
|
+
*
|
|
321
|
+
* Such a connection presented nothing: it was admitted so that it could make
|
|
322
|
+
* the one claim that can be checked, and until it does it may do that and
|
|
323
|
+
* nothing else. */
|
|
324
|
+
unproven(conn: Requester): boolean {
|
|
325
|
+
return this.#marked.has(conn) && conn.identity.state !== "settled";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The peers this instance dials: the configured list without itself.
|
|
329
|
+
*
|
|
330
|
+
* The list is the same on every instance, which is what lets one file be
|
|
331
|
+
* distributed to all of them (§8.2) — and it may name this instance, so
|
|
332
|
+
* removing ourselves is the reader's job rather than the writer's. `verify`
|
|
333
|
+
* is what found which entries are ours, the configured `self` among them. */
|
|
334
|
+
get peers(): Endpoint[] {
|
|
335
|
+
return this.deps.peers.filter((peer) => !this.#ours.has(peer));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
get self(): Endpoint {
|
|
339
|
+
return this.deps.self;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
get id(): InstanceId {
|
|
343
|
+
return this.deps.id;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Whether a link to this peer is established and proven. */
|
|
347
|
+
reachable(peer: Endpoint): boolean {
|
|
348
|
+
return this.#links.has(peer);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** What `hello` reports: this instance, then every configured peer, with
|
|
352
|
+
* whether it can be reached right now (§7.5).
|
|
353
|
+
*
|
|
354
|
+
* A peer no handshake has settled yet is listed without an id. The operator
|
|
355
|
+
* configured that endpoint, so it is an entry of the cluster whether or not
|
|
356
|
+
* anything has answered there — and leaving it out would hide exactly the
|
|
357
|
+
* peer whose link is down, which is the one a reader is looking for. */
|
|
358
|
+
instances(): InstanceInfo[] {
|
|
359
|
+
return [
|
|
360
|
+
{ id: this.deps.id, endpoint: this.deps.self, host: hostname(), reachable: true },
|
|
361
|
+
...this.peers.map((peer) => {
|
|
362
|
+
const id = this.#idOf.get(peer);
|
|
363
|
+
return {
|
|
364
|
+
...(id === undefined ? {} : { id }),
|
|
365
|
+
endpoint: peer,
|
|
366
|
+
host: new URL(peer).hostname,
|
|
367
|
+
reachable: this.reachable(peer),
|
|
368
|
+
};
|
|
369
|
+
}),
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Whether any peer is currently out of reach.
|
|
374
|
+
*
|
|
375
|
+
* What separates "no instance in the cluster knows this session" from "an
|
|
376
|
+
* instance that might know it cannot be asked" — the one distinction §4.2
|
|
377
|
+
* says rests on the mesh's connection state and on nothing else. */
|
|
378
|
+
anyUnreachable(): boolean {
|
|
379
|
+
return this.peers.some((peer) => !this.reachable(peer));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Which instance should answer for a session this instance does not hold.
|
|
383
|
+
*
|
|
384
|
+
* A session the cluster has named belongs to the instance its `peers` row
|
|
385
|
+
* states. One nobody has named while a peer is out of reach is answered with
|
|
386
|
+
* that peer: forwarding there fails and the caller is told
|
|
387
|
+
* `instance_unreachable`, which is what §4.2 asks for in place of deciding
|
|
388
|
+
* the session does not exist. */
|
|
389
|
+
ownerOf(sid: Sid): InstanceId | undefined {
|
|
390
|
+
const owner = this.relay.owner(sid);
|
|
391
|
+
if (owner !== undefined) return owner;
|
|
392
|
+
// Any peer that is out of reach and whose name this instance knows. One it
|
|
393
|
+
// has never handshaken with cannot be named as the owner, and answering
|
|
394
|
+
// with no owner is what forwarding does when there is nowhere to forward.
|
|
395
|
+
for (const peer of this.peers) {
|
|
396
|
+
if (this.reachable(peer)) continue;
|
|
397
|
+
const id = this.#idOf.get(peer);
|
|
398
|
+
if (id !== undefined) return id;
|
|
399
|
+
}
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// --- op forwarding (§7.3) ---
|
|
404
|
+
|
|
405
|
+
/** Carry one op to the instance that owns its subject, and bring the answer
|
|
406
|
+
* back.
|
|
407
|
+
*
|
|
408
|
+
* The request keeps its shape and gains the envelope's three fields. Its
|
|
409
|
+
* `request_id` is reissued because uniqueness has to hold among one
|
|
410
|
+
* connection's in-flight requests (contract, `RequestEnvelope`) and this
|
|
411
|
+
* connection is the link, not the caller's; the caller's id is put back on
|
|
412
|
+
* the reply.
|
|
413
|
+
*
|
|
414
|
+
* `caller` is who the destination will run it as. It is stated by this
|
|
415
|
+
* instance from the connection the request came in on, never carried over
|
|
416
|
+
* from what the request said about itself — a client that wrote a `caller`
|
|
417
|
+
* of its own would otherwise choose the identity it is forwarded as. */
|
|
418
|
+
async forward(
|
|
419
|
+
to: InstanceId,
|
|
420
|
+
frame: Record<string, unknown>,
|
|
421
|
+
caller: CallerIdentity | undefined,
|
|
422
|
+
): Promise<DispatchResult> {
|
|
423
|
+
const self = this.deps.id;
|
|
424
|
+
const requestId = frame["request_id"] as string;
|
|
425
|
+
// The id names the instance; which link carries it is the binding a
|
|
426
|
+
// handshake left behind (DR-0001 §2.1).
|
|
427
|
+
const endpoint = this.#endpointOf.get(to);
|
|
428
|
+
const link = endpoint === undefined ? undefined : this.#links.get(endpoint);
|
|
429
|
+
if (link === undefined) {
|
|
430
|
+
return failure(requestId, "instance_unreachable", `${to} cannot be reached`);
|
|
431
|
+
}
|
|
432
|
+
const peer = endpoint as Endpoint;
|
|
433
|
+
const hops = Array.isArray(frame["hops"]) ? (frame["hops"] as InstanceId[]) : [];
|
|
434
|
+
const op = String(frame["op"]);
|
|
435
|
+
const carried = `mesh-fwd-${randomId()}`;
|
|
436
|
+
const settled = Promise.withResolvers<DispatchResult>();
|
|
437
|
+
const timer = setTimeout(() => {
|
|
438
|
+
this.#forwarded.delete(carried);
|
|
439
|
+
settled.resolve(
|
|
440
|
+
failure(requestId, "instance_unreachable", `${to} did not answer ${op} in time`),
|
|
441
|
+
);
|
|
442
|
+
}, this.deps.forwardTimeoutMs ?? FORWARD_TIMEOUT_MS);
|
|
443
|
+
timer.unref?.();
|
|
444
|
+
this.#forwarded.set(carried, {
|
|
445
|
+
peer,
|
|
446
|
+
requestId,
|
|
447
|
+
timer,
|
|
448
|
+
settle: settled.resolve,
|
|
449
|
+
});
|
|
450
|
+
link.conn.send({
|
|
451
|
+
...frame,
|
|
452
|
+
request_id: carried,
|
|
453
|
+
to_instance: to,
|
|
454
|
+
from_instance: self,
|
|
455
|
+
hops: [...hops, self],
|
|
456
|
+
...(caller === undefined ? {} : { caller }),
|
|
457
|
+
});
|
|
458
|
+
return await settled.promise;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Whether this connection is an established link to a peer. */
|
|
462
|
+
isLink(conn: Requester): boolean {
|
|
463
|
+
return this.#linkOf.has(conn);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The connection a forwarded request is dispatched as (§7.3).
|
|
467
|
+
*
|
|
468
|
+
* The caller the envelope names, on the link it arrived over. A request that
|
|
469
|
+
* names none is dispatched as the link itself, whose role is `instance` —
|
|
470
|
+
* which the attribute table already answers, since no instance-local op is
|
|
471
|
+
* open to an instance. */
|
|
472
|
+
caller(conn: Requester, caller: CallerIdentity | undefined): Requester {
|
|
473
|
+
const link = this.#linkOf.get(conn);
|
|
474
|
+
if (link === undefined || caller === undefined) return conn;
|
|
475
|
+
const key = `${caller.role}/${caller.sid ?? ""}`;
|
|
476
|
+
const held = link.actors.get(key);
|
|
477
|
+
if (held !== undefined) return held;
|
|
478
|
+
const actor = new PeerActor(conn, {
|
|
479
|
+
state: "settled",
|
|
480
|
+
role: caller.role,
|
|
481
|
+
...(caller.sid === undefined ? {} : { sid: caller.sid }),
|
|
482
|
+
});
|
|
483
|
+
link.actors.set(key, actor);
|
|
484
|
+
return actor;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- event relay (§7.4) ---
|
|
488
|
+
|
|
489
|
+
/** The current value of a relayed topic, one entry per instance that has
|
|
490
|
+
* stated one. Handed to a fresh local subscriber beside this instance's own
|
|
491
|
+
* snapshot, so it opens on the cluster rather than on us. */
|
|
492
|
+
snapshot(topic: string): readonly TopicValue[] {
|
|
493
|
+
return this.relay.snapshot(topic);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** A local subscriber appeared on a cluster topic, or the last one left.
|
|
497
|
+
*
|
|
498
|
+
* The subscription travels: what a subscriber asks of this instance, this
|
|
499
|
+
* instance asks of every peer, and the frames come back unchanged (§7.4).
|
|
500
|
+
* `peers` is never given up, because it is also the routing table. */
|
|
501
|
+
demand(topic: string, wanted: boolean): void {
|
|
502
|
+
if (!isClusterTopic(topic)) return;
|
|
503
|
+
if (wanted) {
|
|
504
|
+
if (this.#demanded.has(topic)) return;
|
|
505
|
+
this.#demanded.add(topic);
|
|
506
|
+
for (const link of this.#links.values()) this.#ask(link.conn, topic, true);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (topic === "peers" || !this.#demanded.delete(topic)) return;
|
|
510
|
+
for (const link of this.#links.values()) this.#ask(link.conn, topic, false);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Ask a peer for a topic, or give it up.
|
|
514
|
+
*
|
|
515
|
+
* `afterAck` is for the one moment this cannot be sent straight away: on the
|
|
516
|
+
* link we accepted, the handshake is finished by the reply to the greeting,
|
|
517
|
+
* and a peer that hears anything before that reply reads it as speaking out
|
|
518
|
+
* of turn and drops the connection (mesh-peer-auth §5.8). Deferring is what
|
|
519
|
+
* the connection already offers for "once the reply in flight has gone", so
|
|
520
|
+
* the ordering is the connection's rather than a delay chosen here. */
|
|
521
|
+
#ask(conn: Requester, topic: string, wanted: boolean, afterAck = false): void {
|
|
522
|
+
const frame = {
|
|
523
|
+
op: wanted ? "topic_subscribe" : "topic_unsubscribe",
|
|
524
|
+
request_id: `mesh-sub-${randomId()}`,
|
|
525
|
+
topic,
|
|
526
|
+
// The instance asks on behalf of whoever subscribed to it, and what they
|
|
527
|
+
// have in common is that they are this deployment's people rather than
|
|
528
|
+
// any one session: a cluster topic is the same value for all of them
|
|
529
|
+
// (§6.2), so there is nothing narrower to name.
|
|
530
|
+
caller: { role: "user" } satisfies CallerIdentity,
|
|
531
|
+
};
|
|
532
|
+
if (afterAck) conn.deferSend(frame);
|
|
533
|
+
else conn.send(frame);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Check the endpoint list against reality before anything is dialled (§7.1).
|
|
537
|
+
*
|
|
538
|
+
* Run once the listener is up, because the probe this instance sends to its
|
|
539
|
+
* own `self` has to arrive somewhere — and that probe is the whole test: a
|
|
540
|
+
* `self` that answers as somebody else ends the start, while a peer that is
|
|
541
|
+
* merely asleep is recorded and dialled later. */
|
|
542
|
+
async verify(): Promise<PeerReport> {
|
|
543
|
+
const report = await this.#probe.verify(this.deps.self, this.deps.peers);
|
|
544
|
+
for (const ours of report.ours) this.#ours.add(ours);
|
|
545
|
+
return report;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Start dialling. Each peer is attempted independently, and a peer that is
|
|
549
|
+
* not there is retried rather than waited for. */
|
|
550
|
+
connect(): void {
|
|
551
|
+
for (const peer of this.peers) void this.#dial(peer);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// --- the receiving end (mesh-peer-auth §5, steps 4 and 10) ---
|
|
555
|
+
|
|
556
|
+
/** Verify a greeting, and answer only if the connection is proven to be the
|
|
557
|
+
* peer it names.
|
|
558
|
+
*
|
|
559
|
+
* The whole judgement is here, inside the op that dispatch already validated
|
|
560
|
+
* and allowed: nothing settles an identity on another path, and a handshake
|
|
561
|
+
* that fails any step throws, which is what leaves the connection anonymous
|
|
562
|
+
* (§3.2 step 7). */
|
|
563
|
+
async greet(conn: Requester, claim: MeshClaim): Promise<void> {
|
|
564
|
+
const self = this.deps.self;
|
|
565
|
+
// 1-3 of §5.7, asked before the key is fetched: the cheap comparisons come
|
|
566
|
+
// first because the fetch reaches out to another host.
|
|
567
|
+
if (claim.ver !== MESH_VER) {
|
|
568
|
+
throw new OpError("invalid_args", `this instance speaks mesh handshake ${MESH_VER}`);
|
|
569
|
+
}
|
|
570
|
+
if (!this.deps.peers.includes(claim.iss)) {
|
|
571
|
+
throw new OpError("forbidden", `${claim.iss} is not a peer of this instance`);
|
|
572
|
+
}
|
|
573
|
+
if (claim.aud !== self) {
|
|
574
|
+
throw new OpError("forbidden", `this instance is ${self}, not ${claim.aud}`);
|
|
575
|
+
}
|
|
576
|
+
// The id is checked before the proof is fetched for the same reason: a
|
|
577
|
+
// greeting that cannot be accepted whatever it proves is refused now.
|
|
578
|
+
this.#checkBinding(claim.iss, claim.id);
|
|
579
|
+
const challenge = randomId();
|
|
580
|
+
const proof = Promise.withResolvers<string>();
|
|
581
|
+
this.#pending.set(conn, {
|
|
582
|
+
claim,
|
|
583
|
+
challenge,
|
|
584
|
+
deliver: proof.resolve,
|
|
585
|
+
fail: proof.reject,
|
|
586
|
+
});
|
|
587
|
+
// The connection can go before the key has even been fetched, which is what
|
|
588
|
+
// a peer refused for speaking out of turn does to its own handshake. The
|
|
589
|
+
// rejection is claimed here so that it is never one nobody is waiting for;
|
|
590
|
+
// what acts on it is still the await below.
|
|
591
|
+
proof.promise.catch(() => undefined);
|
|
592
|
+
conn.onClose(() => {
|
|
593
|
+
proof.reject(new Error("the connection went before its proof arrived"));
|
|
594
|
+
});
|
|
595
|
+
try {
|
|
596
|
+
// The key comes over a connection of its own, opened to the endpoint the
|
|
597
|
+
// greeting names. Asking for it on this connection would let whoever
|
|
598
|
+
// opened it answer with their own key and pass their own signature (§6).
|
|
599
|
+
const jwk = await this.#fetchKey(claim, challenge);
|
|
600
|
+
const jws = await withTimeout(
|
|
601
|
+
proof.promise,
|
|
602
|
+
PROOF_LIFETIME_MS,
|
|
603
|
+
"no proof arrived before this handshake expired",
|
|
604
|
+
);
|
|
605
|
+
this.#verify(jws, claim, challenge, jwk);
|
|
606
|
+
} catch (cause) {
|
|
607
|
+
throw cause instanceof OpError
|
|
608
|
+
? cause
|
|
609
|
+
: new OpError(
|
|
610
|
+
"forbidden",
|
|
611
|
+
`this connection was not proven to be ${claim.iss}: ${String(cause)}`,
|
|
612
|
+
);
|
|
613
|
+
} finally {
|
|
614
|
+
// The challenge is spent whatever happened, so there is no record of it
|
|
615
|
+
// anywhere once the handshake ends (§5.5, §10.5).
|
|
616
|
+
this.#pending.delete(conn);
|
|
617
|
+
}
|
|
618
|
+
// The claim is checked again now that it is trusted: the fetch and the wait
|
|
619
|
+
// took time, and another connection may have taken the id in between.
|
|
620
|
+
this.#checkBinding(claim.iss, claim.id);
|
|
621
|
+
this.#bind(claim.iss, claim.id);
|
|
622
|
+
this.#hold(claim.iss, conn, false);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Refuse a greeting that names an id already answering somewhere else.
|
|
626
|
+
*
|
|
627
|
+
* One id belongs to one endpoint, and the endpoint list is the only thing
|
|
628
|
+
* vouching for either — so the binding that stands is kept and the newcomer
|
|
629
|
+
* is the one turned away (DR-0001 §2.1). This is what an instance that has
|
|
630
|
+
* moved runs into while the one at its old URL is still up: the remedy is to
|
|
631
|
+
* take the old endpoint out of every peer list, not to let the newer link
|
|
632
|
+
* win here. */
|
|
633
|
+
#checkBinding(endpoint: Endpoint, id: InstanceId): void {
|
|
634
|
+
const bound = this.#endpointOf.get(id);
|
|
635
|
+
if (bound !== undefined && bound !== endpoint) {
|
|
636
|
+
throw new OpError("forbidden", `${id} is already the instance at ${bound}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
#bind(endpoint: Endpoint, id: InstanceId): void {
|
|
641
|
+
const previous = this.#idOf.get(endpoint);
|
|
642
|
+
if (previous !== undefined && previous !== id) this.#endpointOf.delete(previous);
|
|
643
|
+
this.#idOf.set(endpoint, id);
|
|
644
|
+
this.#endpointOf.set(id, endpoint);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** A frame that is not an op. True when the mesh took it.
|
|
648
|
+
*
|
|
649
|
+
* The proof arrives here because it belongs on the connection being
|
|
650
|
+
* authenticated (§5), which is the one connection the op vocabulary has no
|
|
651
|
+
* name for: mesh carries no ops of its own (contract, `Plane`). */
|
|
652
|
+
frame(conn: Requester, frame: unknown): boolean {
|
|
653
|
+
const mesh = meshFrameOf(frame);
|
|
654
|
+
if (mesh === undefined) return this.#peerFrame(conn, frame);
|
|
655
|
+
if (mesh.mesh === "ping") {
|
|
656
|
+
conn.send({ mesh: "pong" });
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
if (mesh.mesh === "pong") {
|
|
660
|
+
this.#heard(conn);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
const pending = this.#pending.get(conn);
|
|
664
|
+
// A proof with no handshake waiting for it: either none was started, or the
|
|
665
|
+
// challenge it answers has already been spent. Neither is retried (§5.5).
|
|
666
|
+
if (pending === undefined) {
|
|
667
|
+
conn.close();
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
pending.deliver(mesh.jws);
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** What a proven link wrote that is not a request: a topic frame to relay
|
|
675
|
+
* (§7.4), or the reply to something this instance forwarded (§7.3).
|
|
676
|
+
*
|
|
677
|
+
* Only a link is read this way. A client connection could otherwise guess a
|
|
678
|
+
* forwarded id and settle a request it has nothing to do with, and could
|
|
679
|
+
* push a topic frame this instance would pass on as a peer's. */
|
|
680
|
+
#peerFrame(conn: Requester, frame: unknown): boolean {
|
|
681
|
+
if (!this.#linkOf.has(conn)) return false;
|
|
682
|
+
if (typeof frame !== "object" || frame === null) return false;
|
|
683
|
+
const fields = frame as Record<string, unknown>;
|
|
684
|
+
|
|
685
|
+
const requestId = fields["request_id"];
|
|
686
|
+
// A reply, told from a request by `ok`: the two share the correlation id,
|
|
687
|
+
// and a peer forwarding an op to us uses ids of the same shape we use for
|
|
688
|
+
// our own.
|
|
689
|
+
if (typeof requestId === "string" && typeof fields["ok"] === "boolean") {
|
|
690
|
+
const waiting = this.#forwarded.get(requestId);
|
|
691
|
+
if (waiting !== undefined) {
|
|
692
|
+
this.#forwarded.delete(requestId);
|
|
693
|
+
clearTimeout(waiting.timer);
|
|
694
|
+
waiting.settle(answerOf(fields, waiting.requestId));
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
// The peer's answer to a subscription this instance asked for. It
|
|
698
|
+
// acknowledges and says nothing further; the value arrives as frames.
|
|
699
|
+
if (requestId.startsWith("mesh-sub-")) return true;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (fields["ev"] !== "topic") return false;
|
|
703
|
+
const topic = fields["topic"];
|
|
704
|
+
const instance = fields["instance"];
|
|
705
|
+
if (typeof topic !== "string" || typeof instance !== "string") return true;
|
|
706
|
+
// Our own value, come back around a triangle. Relaying it again would put
|
|
707
|
+
// this instance's value on the wire as something it received.
|
|
708
|
+
if (instance === this.deps.id) return true;
|
|
709
|
+
this.relay.accept(instance as InstanceId, topic, fields["data"]);
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Whether this connection is mid-handshake, which is what makes an ordinary
|
|
714
|
+
* request on it a protocol violation rather than an early call (§5.8). */
|
|
715
|
+
handshaking(conn: Requester): boolean {
|
|
716
|
+
return this.#pending.has(conn);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// --- the HTTP surface: the key of §6 and the probe of self-identification ---
|
|
720
|
+
|
|
721
|
+
/** Answer the two requests that are served before anything is proven, or
|
|
722
|
+
* nothing when the request is not one of them. */
|
|
723
|
+
async route(request: Request): Promise<Response | undefined> {
|
|
724
|
+
// Below `self` and nowhere else. The mesh's routes are the one part of the
|
|
725
|
+
// surface that stays tied to the configured endpoint, because that tie is
|
|
726
|
+
// what keeps two instances on one origin from answering for each other's
|
|
727
|
+
// keys (mesh-peer-auth §6.3). The person's entry is matched by the end of
|
|
728
|
+
// the path instead (DR-0001 §2.7).
|
|
729
|
+
const pathname = new URL(request.url).pathname;
|
|
730
|
+
const base = this.deps.self;
|
|
731
|
+
if (isProbePath(pathname, base)) return await this.#answerProbe(request);
|
|
732
|
+
const kid = kidOfPath(pathname, base);
|
|
733
|
+
if (kid !== undefined) return await this.#serveKey(kid, request);
|
|
734
|
+
return undefined;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async #answerProbe(request: Request): Promise<Response> {
|
|
738
|
+
let body: unknown;
|
|
739
|
+
try {
|
|
740
|
+
body = await request.json();
|
|
741
|
+
} catch {
|
|
742
|
+
return new Response("a probe is a JSON object", { status: 400 });
|
|
743
|
+
}
|
|
744
|
+
const probe = body as Partial<ProbeBody>;
|
|
745
|
+
// An unknown generation is ignored rather than refused: the comparison is
|
|
746
|
+
// the sender's, so a receiver that cannot read the probe costs the sender
|
|
747
|
+
// nothing it could not already have (§5.1).
|
|
748
|
+
if (probe.ver === MESH_VER && typeof probe.token === "string") {
|
|
749
|
+
this.#probe.accept(probe.token);
|
|
750
|
+
}
|
|
751
|
+
return Response.json({});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async #serveKey(kid: string, request: Request): Promise<Response> {
|
|
755
|
+
if (!this.#allowKeyRequest()) {
|
|
756
|
+
return new Response("too many key requests", { status: 429 });
|
|
757
|
+
}
|
|
758
|
+
let body: unknown;
|
|
759
|
+
try {
|
|
760
|
+
body = await request.json();
|
|
761
|
+
} catch {
|
|
762
|
+
return new Response("a key request carries its challenge", { status: 400 });
|
|
763
|
+
}
|
|
764
|
+
const asked = body as Partial<JwkRequest>;
|
|
765
|
+
if (asked.ver !== MESH_VER || typeof asked.challenge !== "string" || asked.challenge === "") {
|
|
766
|
+
return new Response("a key request carries a generation and a challenge", { status: 400 });
|
|
767
|
+
}
|
|
768
|
+
const minted = this.#minted.get(kid);
|
|
769
|
+
// Unknown to us, or known and no longer connected to the handshake it was
|
|
770
|
+
// made for. Either way there is no key to give (§6.1).
|
|
771
|
+
if (minted === undefined || minted.conn === undefined) {
|
|
772
|
+
return new Response("no such key", { status: 404 });
|
|
773
|
+
}
|
|
774
|
+
// The proof goes back on the connection being authenticated, not on this
|
|
775
|
+
// one. The two meet at the `kid`.
|
|
776
|
+
const claim = {
|
|
777
|
+
ver: MESH_VER,
|
|
778
|
+
iss: this.deps.self,
|
|
779
|
+
aud: minted.aud,
|
|
780
|
+
challenge: asked.challenge,
|
|
781
|
+
exp: Math.floor((Date.now() + PROOF_LIFETIME_MS) / 1000),
|
|
782
|
+
};
|
|
783
|
+
minted.conn.send({ mesh: "proof", jws: minted.key.proof(claim) });
|
|
784
|
+
const answer: JwkResponse["jwk"] = minted.key.jwk();
|
|
785
|
+
return new Response(JSON.stringify(answer), {
|
|
786
|
+
headers: { "content-type": "application/jwk+json" },
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** A fixed window rather than a queue: what this protects against is a caller
|
|
791
|
+
* making this instance sign and serve in a loop, and a count per second says
|
|
792
|
+
* that plainly. */
|
|
793
|
+
#allowKeyRequest(): boolean {
|
|
794
|
+
const now = Date.now();
|
|
795
|
+
if (now - this.#jwkWindow >= JWK_RATE_WINDOW_MS) {
|
|
796
|
+
this.#jwkWindow = now;
|
|
797
|
+
this.#jwkServed = 0;
|
|
798
|
+
}
|
|
799
|
+
this.#jwkServed += 1;
|
|
800
|
+
return this.#jwkServed <= JWK_RATE_LIMIT;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// --- the dialling end (§5, steps 1-3 and 13) ---
|
|
804
|
+
|
|
805
|
+
async #dial(peer: Endpoint): Promise<void> {
|
|
806
|
+
if (this.#stopping || this.#links.has(peer)) return;
|
|
807
|
+
const self = this.deps.self;
|
|
808
|
+
const key = new EphemeralKey();
|
|
809
|
+
const minted: Minted = { key, aud: peer };
|
|
810
|
+
this.#minted.set(key.kid, minted);
|
|
811
|
+
let conn: Conn;
|
|
812
|
+
try {
|
|
813
|
+
conn = await dialWs({
|
|
814
|
+
url: wsEndpoint(peer),
|
|
815
|
+
protocols: [MESH_PROTOCOL],
|
|
816
|
+
conns: this.deps.conns,
|
|
817
|
+
onFrame: (frame, on) => {
|
|
818
|
+
this.#dialledFrame(peer, on, frame, key.kid);
|
|
819
|
+
},
|
|
820
|
+
onClose: (code) => {
|
|
821
|
+
this.#minted.delete(key.kid);
|
|
822
|
+
this.#drop(peer, conn);
|
|
823
|
+
// The loser of a glare is closed on purpose by the other end, and
|
|
824
|
+
// redialling it would reopen what both sides just agreed to drop.
|
|
825
|
+
if (code !== GLARE_CLOSE) this.#retry(peer);
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
} catch {
|
|
829
|
+
this.#minted.delete(key.kid);
|
|
830
|
+
this.#retry(peer);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
minted.conn = conn;
|
|
834
|
+
conn.send({
|
|
835
|
+
op: "hello",
|
|
836
|
+
request_id: `mesh-hello-${key.kid}`,
|
|
837
|
+
role: "instance",
|
|
838
|
+
protocol_version: PROTOCOL_VERSION,
|
|
839
|
+
mesh: { ver: MESH_VER, iss: self, aud: peer, id: this.deps.id, kid: key.kid },
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** What the far end wrote on a connection we opened.
|
|
844
|
+
*
|
|
845
|
+
* The greeting's reply is the acknowledgement of §5.8: it is what says the
|
|
846
|
+
* peer finished verifying, which is both the moment this instance may speak
|
|
847
|
+
* and the moment its key has no further use. */
|
|
848
|
+
#dialledFrame(peer: Endpoint, conn: Requester, frame: unknown, kid: string): void {
|
|
849
|
+
if (this.frame(conn, frame)) return;
|
|
850
|
+
const fields = frame as Record<string, unknown>;
|
|
851
|
+
if (fields["request_id"] !== `mesh-hello-${kid}`) {
|
|
852
|
+
// A request the peer forwarded to us. A dialled connection is answered by
|
|
853
|
+
// whoever dialled it (transport, `DialOptions`), so the reply goes out
|
|
854
|
+
// here rather than through the driver — but what decides it is the same
|
|
855
|
+
// dispatch every other request goes through (§7.3).
|
|
856
|
+
if (typeof fields["op"] === "string") this.#answer(conn, fields);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
this.#minted.delete(kid);
|
|
860
|
+
if (fields["ok"] !== true) {
|
|
861
|
+
const error = fields["error"] as { msg?: string } | undefined;
|
|
862
|
+
this.deps.log?.("mesh peer refused this instance", { peer, msg: error?.msg });
|
|
863
|
+
conn.close();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
// Which instance answered there, taken from its greeting. What vouches for
|
|
867
|
+
// it on this side of the link is the endpoint itself — the URL is what was
|
|
868
|
+
// dialled and what its certificate was checked against — so the binding is
|
|
869
|
+
// made here rather than waiting for a claim the peer never sends us.
|
|
870
|
+
const id = fields["instance"];
|
|
871
|
+
if (typeof id !== "string") {
|
|
872
|
+
this.deps.log?.("mesh peer named no instance", { peer });
|
|
873
|
+
conn.close();
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const bound = this.#endpointOf.get(id);
|
|
877
|
+
if (bound !== undefined && bound !== peer) {
|
|
878
|
+
this.deps.log?.("mesh peer claimed an id held elsewhere", { peer, id, bound });
|
|
879
|
+
conn.close();
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
this.#bind(peer, id);
|
|
883
|
+
this.#hold(peer, conn, true);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Run one request a peer wrote on a connection we dialled, and write the
|
|
887
|
+
* answer back on it. */
|
|
888
|
+
#answer(conn: Requester, fields: Record<string, unknown>): void {
|
|
889
|
+
void this.#host?.handle(fields, conn).then(
|
|
890
|
+
(result) => {
|
|
891
|
+
if (result.kind === "none") return;
|
|
892
|
+
conn.send(
|
|
893
|
+
result.kind === "forward"
|
|
894
|
+
? failure(
|
|
895
|
+
fields["request_id"] as string,
|
|
896
|
+
"instance_unreachable",
|
|
897
|
+
`${result.to} cannot be reached`,
|
|
898
|
+
).response
|
|
899
|
+
: result.response,
|
|
900
|
+
);
|
|
901
|
+
},
|
|
902
|
+
(cause: unknown) => {
|
|
903
|
+
conn.send(
|
|
904
|
+
failure(
|
|
905
|
+
fields["request_id"] as string,
|
|
906
|
+
"internal_error",
|
|
907
|
+
`the forwarded request could not be answered: ${String(cause)}`,
|
|
908
|
+
).response,
|
|
909
|
+
);
|
|
910
|
+
},
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// --- links, glare and the heartbeat ---
|
|
915
|
+
|
|
916
|
+
/** Take a proven connection as the link to this peer, resolving a glare if
|
|
917
|
+
* one is already held.
|
|
918
|
+
*
|
|
919
|
+
* Both connections are verified before either is dropped, so whichever
|
|
920
|
+
* survives is one that was proven (§8.1). */
|
|
921
|
+
#hold(peer: Endpoint, conn: Requester, dialledByUs: boolean): void {
|
|
922
|
+
const self = this.deps.self;
|
|
923
|
+
const held = this.#links.get(peer);
|
|
924
|
+
if (held !== undefined) {
|
|
925
|
+
if (held.conn === conn) return;
|
|
926
|
+
const keepNew = glareKeepsNew(self, peer, dialledByUs);
|
|
927
|
+
if (!keepNew) {
|
|
928
|
+
conn.close(GLARE_CLOSE, "glare");
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
clearInterval(held.heartbeat);
|
|
932
|
+
this.#links.delete(peer);
|
|
933
|
+
this.#linkOf.delete(held.conn);
|
|
934
|
+
held.conn.close(GLARE_CLOSE, "glare");
|
|
935
|
+
}
|
|
936
|
+
const heartbeat = setInterval(() => {
|
|
937
|
+
this.#beat(peer);
|
|
938
|
+
}, this.deps.heartbeatMs ?? HEARTBEAT_MS);
|
|
939
|
+
heartbeat.unref?.();
|
|
940
|
+
const link: Link = {
|
|
941
|
+
conn,
|
|
942
|
+
actors: new Map<string, PeerActor>(),
|
|
943
|
+
dialledByUs,
|
|
944
|
+
heartbeat,
|
|
945
|
+
lastHeard: Date.now(),
|
|
946
|
+
};
|
|
947
|
+
this.#linkOf.set(conn, link);
|
|
948
|
+
this.#links.set(peer, link);
|
|
949
|
+
this.#backoff.delete(peer);
|
|
950
|
+
conn.onClose(() => {
|
|
951
|
+
this.#drop(peer, conn);
|
|
952
|
+
});
|
|
953
|
+
// What it said before is still held and stops being marked; what it says
|
|
954
|
+
// now replaces it, which is the whole of "restored by reconnection" (§7.5).
|
|
955
|
+
// Under the id, because that is what its frames name themselves with: the
|
|
956
|
+
// endpoint is where the link was dialled and says nothing about the value.
|
|
957
|
+
const id = this.#idOf.get(peer);
|
|
958
|
+
if (id !== undefined) this.relay.restored(id);
|
|
959
|
+
for (const topic of this.#demanded) this.#ask(conn, topic, true, !dialledByUs);
|
|
960
|
+
this.deps.log?.("mesh peer established", { peer, dialled_by_us: dialledByUs });
|
|
961
|
+
this.#changed();
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Say that the set of reachable instances moved. Two listeners: whatever
|
|
965
|
+
* the deps gave, and the instance, which restates `peers` — the topic the
|
|
966
|
+
* view rides on (§7.5). */
|
|
967
|
+
#changed(): void {
|
|
968
|
+
this.deps.onChanged?.();
|
|
969
|
+
this.#host?.changed();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
#beat(peer: Endpoint): void {
|
|
973
|
+
const link = this.#links.get(peer);
|
|
974
|
+
if (link === undefined) return;
|
|
975
|
+
const silence = Date.now() - link.lastHeard;
|
|
976
|
+
if (silence > (this.deps.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS)) {
|
|
977
|
+
// Nothing has come back for long enough that the link is gone whatever
|
|
978
|
+
// the socket believes — which is the whole reason for the heartbeat
|
|
979
|
+
// (§8.3): a middlebox drops a connection without telling either end.
|
|
980
|
+
this.deps.log?.("mesh peer went silent", { peer, silence_ms: silence });
|
|
981
|
+
link.conn.close();
|
|
982
|
+
this.#drop(peer, link.conn);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
link.conn.send({ mesh: "ping" });
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
#heard(conn: Requester): void {
|
|
989
|
+
for (const link of this.#links.values()) {
|
|
990
|
+
if (link.conn === conn) link.lastHeard = Date.now();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
#drop(peer: Endpoint, conn: Requester): void {
|
|
995
|
+
const link = this.#links.get(peer);
|
|
996
|
+
if (link === undefined || link.conn !== conn) return;
|
|
997
|
+
clearInterval(link.heartbeat);
|
|
998
|
+
this.#links.delete(peer);
|
|
999
|
+
this.#linkOf.delete(conn);
|
|
1000
|
+
// Its sessions become a kind of Disappeared and its values are marked
|
|
1001
|
+
// rather than dropped (§7.5), and anything on its way there is answered
|
|
1002
|
+
// now instead of waiting out a deadline it can no longer beat.
|
|
1003
|
+
const id = this.#idOf.get(peer);
|
|
1004
|
+
if (id !== undefined) this.relay.lost(id);
|
|
1005
|
+
this.#abandon(peer);
|
|
1006
|
+
this.deps.log?.("mesh peer lost", { peer });
|
|
1007
|
+
this.#changed();
|
|
1008
|
+
if (link.dialledByUs) this.#retry(peer);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Answer everything that was waiting on a peer that has gone. */
|
|
1012
|
+
#abandon(peer: Endpoint): void {
|
|
1013
|
+
for (const [carried, waiting] of this.#forwarded) {
|
|
1014
|
+
if (waiting.peer !== peer) continue;
|
|
1015
|
+
this.#forwarded.delete(carried);
|
|
1016
|
+
clearTimeout(waiting.timer);
|
|
1017
|
+
waiting.settle(
|
|
1018
|
+
failure(waiting.requestId, "instance_unreachable", `${peer} cannot be reached`),
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Try again, later each time up to the ceiling. */
|
|
1024
|
+
#retry(peer: Endpoint): void {
|
|
1025
|
+
if (this.#stopping || this.#retries.has(peer)) return;
|
|
1026
|
+
const min = this.deps.reconnectMinMs ?? RECONNECT_MIN_MS;
|
|
1027
|
+
const previous = this.#backoff.get(peer) ?? 0;
|
|
1028
|
+
const wait = previous === 0 ? min : Math.min(previous * 2, RECONNECT_MAX_MS);
|
|
1029
|
+
this.#backoff.set(peer, wait);
|
|
1030
|
+
const timer = setTimeout(() => {
|
|
1031
|
+
this.#retries.delete(peer);
|
|
1032
|
+
void this.#dial(peer);
|
|
1033
|
+
}, wait);
|
|
1034
|
+
timer.unref?.();
|
|
1035
|
+
this.#retries.set(peer, timer);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** Let every link and every timer go. Called from the stop order (§8.5). */
|
|
1039
|
+
stop(): void {
|
|
1040
|
+
this.#stopping = true;
|
|
1041
|
+
for (const timer of this.#retries.values()) clearTimeout(timer);
|
|
1042
|
+
this.#retries.clear();
|
|
1043
|
+
// The timers go, and so does every link — the ones this instance dialled
|
|
1044
|
+
// and the ones it accepted alike.
|
|
1045
|
+
//
|
|
1046
|
+
// The far end has no other way to learn this instance is going: it would
|
|
1047
|
+
// keep the link, keep answering `reachable`, and keep routing
|
|
1048
|
+
// `instance-local` ops here until its own heartbeat gave up minutes later,
|
|
1049
|
+
// where the disconnection of §7.5 is supposed to be immediate. Which side
|
|
1050
|
+
// dialled a link is decided by the glare rule from a comparison of
|
|
1051
|
+
// endpoint strings (§8.1), so which of a peer's links this instance
|
|
1052
|
+
// accepted is not something either end chose — leaving those open makes a
|
|
1053
|
+
// clean stop look like a silent one to whichever half of the cluster the
|
|
1054
|
+
// comparison put on this side.
|
|
1055
|
+
//
|
|
1056
|
+
// An accepted socket is transport's to release (§8.5 step 5), and left to
|
|
1057
|
+
// it the far end is told whenever the listener gets round to it: measured
|
|
1058
|
+
// against Bun 1.3.13, `stop` on a server that has itself closed a
|
|
1059
|
+
// WebSocket — which the mesh does, to drop the loser of a glare — never
|
|
1060
|
+
// settles, and the wait for it is capped rather than trusted. That cap
|
|
1061
|
+
// bounds this instance's own exit; it cannot bound when the peer hears.
|
|
1062
|
+
// Closing here is what makes the notice the mesh's own rather than a side
|
|
1063
|
+
// effect of a listener going down. The step-3 notice this precedes is
|
|
1064
|
+
// `restarting`, which is addressed to clients — a peer learns from the
|
|
1065
|
+
// link, and that is the whole of what §7.5 asks for.
|
|
1066
|
+
for (const link of this.#links.values()) {
|
|
1067
|
+
clearInterval(link.heartbeat);
|
|
1068
|
+
link.conn.close();
|
|
1069
|
+
}
|
|
1070
|
+
for (const peer of this.#links.keys()) this.#abandon(peer);
|
|
1071
|
+
this.#links.clear();
|
|
1072
|
+
this.#linkOf.clear();
|
|
1073
|
+
// Keys die with the connections they were made for, and none outlives this
|
|
1074
|
+
// (§7).
|
|
1075
|
+
this.#minted.clear();
|
|
1076
|
+
for (const pending of this.#pending.values()) {
|
|
1077
|
+
pending.fail(new Error("this instance is stopping"));
|
|
1078
|
+
}
|
|
1079
|
+
this.#pending.clear();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** What is held per handshake right now, so a test can state that nothing is
|
|
1083
|
+
* kept once one has finished (§10.5). */
|
|
1084
|
+
get held(): { keys: number; handshakes: number; links: number } {
|
|
1085
|
+
return {
|
|
1086
|
+
keys: this.#minted.size,
|
|
1087
|
+
handshakes: this.#pending.size,
|
|
1088
|
+
links: this.#links.size,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// --- verification ---
|
|
1093
|
+
|
|
1094
|
+
async #fetchKey(claim: MeshClaim, challenge: string): Promise<MeshJwk> {
|
|
1095
|
+
const body: JwkRequest = { ver: MESH_VER, challenge };
|
|
1096
|
+
const response = await fetch(jwkEndpoint(claim.iss, claim.kid), {
|
|
1097
|
+
method: "POST",
|
|
1098
|
+
// One request and close, which is what the second connection is
|
|
1099
|
+
// (§6.2): it exists to carry the key and the challenge, and keeping it
|
|
1100
|
+
// pooled afterwards would leave a connection nothing speaks on.
|
|
1101
|
+
headers: { "content-type": "application/json", connection: "close" },
|
|
1102
|
+
body: JSON.stringify(body),
|
|
1103
|
+
signal: AbortSignal.timeout(PROOF_LIFETIME_MS),
|
|
1104
|
+
});
|
|
1105
|
+
if (!response.ok) throw new Error(`${claim.iss} did not hand out the key ${claim.kid}`);
|
|
1106
|
+
const jwk = (await response.json()) as MeshJwk;
|
|
1107
|
+
// §5.7-8, the third of the three ids that have to agree: a key served under
|
|
1108
|
+
// one id and answering to another would break the correspondence the whole
|
|
1109
|
+
// exchange is keyed on.
|
|
1110
|
+
if (jwk.kid !== claim.kid) throw new Error("the key served is not the key asked for");
|
|
1111
|
+
return jwk;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** §5.7, steps 4 to 8. */
|
|
1115
|
+
#verify(jws: string, claim: MeshClaim, challenge: string, jwk: MeshJwk): void {
|
|
1116
|
+
let parsed;
|
|
1117
|
+
try {
|
|
1118
|
+
parsed = parseProof(jws);
|
|
1119
|
+
} catch (cause) {
|
|
1120
|
+
throw cause instanceof ProofError ? cause : new Error(String(cause));
|
|
1121
|
+
}
|
|
1122
|
+
if (!ALLOWED_ALGS.has(parsed.alg)) throw new Error(`${parsed.alg} is not an allowed algorithm`);
|
|
1123
|
+
if (parsed.kid !== claim.kid) throw new Error("the proof names another key than the greeting");
|
|
1124
|
+
const stated = parsed.claim;
|
|
1125
|
+
if (stated.ver !== claim.ver || stated.iss !== claim.iss || stated.aud !== claim.aud) {
|
|
1126
|
+
throw new Error("the proof states something other than the greeting did");
|
|
1127
|
+
}
|
|
1128
|
+
if (stated.challenge !== challenge) throw new Error("the proof answers another challenge");
|
|
1129
|
+
if (typeof stated.exp !== "number" || stated.exp * 1000 <= Date.now()) {
|
|
1130
|
+
throw new Error("the proof has expired");
|
|
1131
|
+
}
|
|
1132
|
+
if (!verifyProof(jws, jwk)) throw new Error("the signature is not the key's");
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** A peer's reply, as the answer to the request the caller made.
|
|
1137
|
+
*
|
|
1138
|
+
* The body is passed through untouched — the destination decided it, and this
|
|
1139
|
+
* instance re-deciding any of it would put the same judgement in two places.
|
|
1140
|
+
* Only the correlation id changes, back to the one the caller used. */
|
|
1141
|
+
function answerOf(fields: Record<string, unknown>, requestId: string): DispatchResult {
|
|
1142
|
+
const { ok: _ok, request_id: _id, ...body } = fields;
|
|
1143
|
+
if (fields["ok"] === true) return reply(requestId, body);
|
|
1144
|
+
const error = fields["error"] as { code?: string; msg?: string } | undefined;
|
|
1145
|
+
return failure(
|
|
1146
|
+
requestId,
|
|
1147
|
+
(error?.code ?? "internal_error") as Parameters<typeof failure>[1],
|
|
1148
|
+
error?.msg ?? "the instance that answered said nothing about why",
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
|
|
1153
|
+
return new Promise<T>((resolve, reject) => {
|
|
1154
|
+
const timer = setTimeout(() => {
|
|
1155
|
+
reject(new Error(msg));
|
|
1156
|
+
}, ms);
|
|
1157
|
+
timer.unref?.();
|
|
1158
|
+
promise.then(
|
|
1159
|
+
(value) => {
|
|
1160
|
+
clearTimeout(timer);
|
|
1161
|
+
resolve(value);
|
|
1162
|
+
},
|
|
1163
|
+
(cause: unknown) => {
|
|
1164
|
+
clearTimeout(timer);
|
|
1165
|
+
reject(cause instanceof Error ? cause : new Error(String(cause)));
|
|
1166
|
+
},
|
|
1167
|
+
);
|
|
1168
|
+
});
|
|
1169
|
+
}
|