@deepseek-ai/dsh-client-connection 0.2.1-alpha.1 → 0.2.1-alpha.3
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/README.i18n.yaml +8 -8
- package/README.md +14 -7
- package/README.zh.md +14 -7
- package/lib/{client.js → client.browser.js} +22 -2
- package/lib/client.node.js +624 -0
- package/lib/index.js +296 -74
- package/lib/types/api-request-trust.d.ts +16 -9
- package/lib/types/browser-auth.d.ts +25 -2
- package/lib/types/client/index.d.ts +19 -3
- package/lib/types/client/socket.d.ts +50 -0
- package/lib/types/connect-protocol.d.ts +6 -0
- package/lib/types/http-bridge.d.ts +3 -1
- package/lib/types/index.d.ts +9 -9
- package/lib/types/peer.d.ts +36 -0
- package/lib/types/rpc-host.d.ts +29 -7
- package/lib/types/rpc.d.ts +47 -11
- package/locale/en.json +5 -0
- package/locale/zh.json +6 -0
- package/package.json +22 -17
- package/lib/types/operator-peer.d.ts +0 -23
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
//#region lib/types/recovery-config.js
|
|
3
|
+
/** Shared validation for Host-configured and browser-local connection recovery. */
|
|
4
|
+
const MAX_TIMER_MS = 2147483647;
|
|
5
|
+
/** Schema shared by the Host plugin and the Client's recovery input parser. */
|
|
6
|
+
const ConnectionRecoveryConfigSchema = z.object({
|
|
7
|
+
backoffBaseMs: z.natural().min(1).max(MAX_TIMER_MS).default(500),
|
|
8
|
+
backoffFactor: z.number().min(1).max(Number.MAX_VALUE).default(2),
|
|
9
|
+
backoffMaxMs: z.natural().min(1).max(MAX_TIMER_MS).default(1e4),
|
|
10
|
+
generationReadyWarnMs: z.natural().min(1).max(MAX_TIMER_MS).default(3e3),
|
|
11
|
+
generationReadyTimeoutMs: z.natural().min(1).max(MAX_TIMER_MS).default(15e3)
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* Validate recovery input and supply every timing default before starting work.
|
|
15
|
+
* @param config - Host configuration, page bootstrap data, or direct loop options.
|
|
16
|
+
* @returns validated, complete recovery timing.
|
|
17
|
+
*/
|
|
18
|
+
function resolveConnectionConfig(config = {}) {
|
|
19
|
+
const resolved = ConnectionRecoveryConfigSchema(config);
|
|
20
|
+
if (!Number.isFinite(resolved.backoffFactor)) throw new RangeError("connection recovery backoffFactor must be finite");
|
|
21
|
+
return resolved;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region lib/types/client/connection.js
|
|
25
|
+
/** Connection generation readiness, cancellation, and continuous recovery. */
|
|
26
|
+
const MANUAL_RECONNECT = /* @__PURE__ */ new Error("connection: manual reconnect requested");
|
|
27
|
+
const NETWORK_STATE_CHANGED = /* @__PURE__ */ new Error("connection: browser network state changed");
|
|
28
|
+
function sleep(ms, signal) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const t = setTimeout(done, ms);
|
|
31
|
+
signal.addEventListener("abort", done, { once: true });
|
|
32
|
+
function done() {
|
|
33
|
+
clearTimeout(t);
|
|
34
|
+
signal.removeEventListener("abort", done);
|
|
35
|
+
resolve();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function waitForAbort(signal) {
|
|
40
|
+
if (signal.aborted) return Promise.resolve();
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
signal.addEventListener("abort", () => {
|
|
43
|
+
resolve();
|
|
44
|
+
}, { once: true });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Opens the registered generation source, reconnecting with exponential backoff on loss.
|
|
49
|
+
* State (generation/attempt) is instance-private, never in the store.
|
|
50
|
+
* Sink exceptions do not kill the generation loop.
|
|
51
|
+
*/
|
|
52
|
+
var ConnectionController = class {
|
|
53
|
+
source;
|
|
54
|
+
sinks;
|
|
55
|
+
generation = 0;
|
|
56
|
+
attempt = 0;
|
|
57
|
+
current = null;
|
|
58
|
+
retryDelay = null;
|
|
59
|
+
running = false;
|
|
60
|
+
immediateRetry = false;
|
|
61
|
+
networkAvailable = true;
|
|
62
|
+
lastState;
|
|
63
|
+
config;
|
|
64
|
+
constructor(source, sinks = {}, config = {}) {
|
|
65
|
+
this.source = source;
|
|
66
|
+
this.sinks = sinks;
|
|
67
|
+
this.config = resolveConnectionConfig(config);
|
|
68
|
+
}
|
|
69
|
+
/** Idempotent: begin the connect/pump/reconnect loop. */
|
|
70
|
+
start() {
|
|
71
|
+
if (this.running) return;
|
|
72
|
+
this.running = true;
|
|
73
|
+
this.loop();
|
|
74
|
+
}
|
|
75
|
+
/** Stop the loop and abort the current generation source. */
|
|
76
|
+
stop() {
|
|
77
|
+
this.running = false;
|
|
78
|
+
this.current?.abort();
|
|
79
|
+
this.current = null;
|
|
80
|
+
this.retryDelay?.abort();
|
|
81
|
+
this.retryDelay = null;
|
|
82
|
+
}
|
|
83
|
+
/** Reset the retry sequence and replace the current generation or retry delay immediately. */
|
|
84
|
+
reconnect() {
|
|
85
|
+
if (!this.running) return;
|
|
86
|
+
this.attempt = 0;
|
|
87
|
+
this.immediateRetry = true;
|
|
88
|
+
this.emitState("connecting");
|
|
89
|
+
if (!this.isRunning()) return;
|
|
90
|
+
this.current?.abort(MANUAL_RECONNECT);
|
|
91
|
+
this.retryDelay?.abort(MANUAL_RECONNECT);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Suspend automatic retries while offline and restart backoff when the network returns.
|
|
95
|
+
* @param available - whether the browser reports network access.
|
|
96
|
+
*/
|
|
97
|
+
setNetworkAvailable(available) {
|
|
98
|
+
if (this.networkAvailable === available) return;
|
|
99
|
+
this.networkAvailable = available;
|
|
100
|
+
this.attempt = 0;
|
|
101
|
+
this.immediateRetry = false;
|
|
102
|
+
if (!this.running) return;
|
|
103
|
+
this.emitState(available ? "connecting" : "disconnected");
|
|
104
|
+
if (!this.isRunning()) return;
|
|
105
|
+
this.current?.abort(NETWORK_STATE_CHANGED);
|
|
106
|
+
this.retryDelay?.abort(NETWORK_STATE_CHANGED);
|
|
107
|
+
}
|
|
108
|
+
backoffCap(attempt) {
|
|
109
|
+
const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config;
|
|
110
|
+
return Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1));
|
|
111
|
+
}
|
|
112
|
+
backoffDelay(attempt) {
|
|
113
|
+
const cap = this.backoffCap(attempt);
|
|
114
|
+
return cap / 2 + Math.random() * (cap / 2);
|
|
115
|
+
}
|
|
116
|
+
/** Re-read retry inputs after a potentially reentrant state sink. */
|
|
117
|
+
isRetryInterrupted(immediate) {
|
|
118
|
+
return this.immediateRetry || !this.networkAvailable && !immediate;
|
|
119
|
+
}
|
|
120
|
+
/** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */
|
|
121
|
+
isRunning() {
|
|
122
|
+
return this.running;
|
|
123
|
+
}
|
|
124
|
+
/** Re-read both mutable liveness guards after a potentially reentrant sink. */
|
|
125
|
+
isGenerationActive(controller) {
|
|
126
|
+
return this.isRunning() && !controller.signal.aborted;
|
|
127
|
+
}
|
|
128
|
+
async loop() {
|
|
129
|
+
let retry = false;
|
|
130
|
+
while (this.running) {
|
|
131
|
+
if (!this.networkAvailable && !this.immediateRetry) {
|
|
132
|
+
const retryDelay = new AbortController();
|
|
133
|
+
this.retryDelay = retryDelay;
|
|
134
|
+
this.emitState("disconnected");
|
|
135
|
+
await waitForAbort(retryDelay.signal);
|
|
136
|
+
if (this.retryDelay === retryDelay) this.retryDelay = null;
|
|
137
|
+
if (!this.isRunning()) return;
|
|
138
|
+
retry = true;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
let manualAttempt = false;
|
|
142
|
+
if (retry) {
|
|
143
|
+
const immediate = this.immediateRetry;
|
|
144
|
+
this.immediateRetry = false;
|
|
145
|
+
if (immediate) this.attempt = 0;
|
|
146
|
+
manualAttempt = immediate;
|
|
147
|
+
const attempt = ++this.attempt;
|
|
148
|
+
this.emitState("connecting");
|
|
149
|
+
if (!this.isRunning()) return;
|
|
150
|
+
if (this.isRetryInterrupted(immediate)) continue;
|
|
151
|
+
if (!immediate) {
|
|
152
|
+
const retryDelay = new AbortController();
|
|
153
|
+
this.retryDelay = retryDelay;
|
|
154
|
+
await sleep(this.backoffDelay(attempt), retryDelay.signal);
|
|
155
|
+
if (this.retryDelay === retryDelay) this.retryDelay = null;
|
|
156
|
+
if (!this.isRunning()) return;
|
|
157
|
+
if (retryDelay.signal.aborted) continue;
|
|
158
|
+
}
|
|
159
|
+
console.warn(`[connection] connection lost, retry #${String(attempt)}`);
|
|
160
|
+
this.callSink(() => {
|
|
161
|
+
this.sinks.onReconnectRequested?.();
|
|
162
|
+
});
|
|
163
|
+
if (!this.isRunning()) return;
|
|
164
|
+
}
|
|
165
|
+
const gen = ++this.generation;
|
|
166
|
+
const ac = new AbortController();
|
|
167
|
+
this.current = ac;
|
|
168
|
+
let sourceReady = false;
|
|
169
|
+
let resolveReady;
|
|
170
|
+
let rejectReady;
|
|
171
|
+
let rejectSourceLost;
|
|
172
|
+
const ready = new Promise((resolve, reject) => {
|
|
173
|
+
resolveReady = resolve;
|
|
174
|
+
rejectReady = reject;
|
|
175
|
+
});
|
|
176
|
+
const sourceLost = new Promise((_resolve, reject) => {
|
|
177
|
+
rejectSourceLost = reject;
|
|
178
|
+
});
|
|
179
|
+
const reportReady = (host) => {
|
|
180
|
+
if (sourceReady || gen !== this.generation || !this.isGenerationActive(ac)) return;
|
|
181
|
+
sourceReady = true;
|
|
182
|
+
resolveReady(host);
|
|
183
|
+
};
|
|
184
|
+
const failed = new Promise((resolve) => {
|
|
185
|
+
const settle = () => {
|
|
186
|
+
if (gen === this.generation && !ac.signal.aborted) ac.abort();
|
|
187
|
+
resolve();
|
|
188
|
+
};
|
|
189
|
+
Promise.resolve().then(() => this.source(ac.signal, reportReady)).then(() => {
|
|
190
|
+
const error = /* @__PURE__ */ new Error("connection generation ended");
|
|
191
|
+
if (!sourceReady) rejectReady(error);
|
|
192
|
+
rejectSourceLost(error);
|
|
193
|
+
settle();
|
|
194
|
+
}, (error) => {
|
|
195
|
+
const failure = error instanceof Error ? error : new Error("connection generation failed", { cause: error });
|
|
196
|
+
if (!sourceReady) rejectReady(failure);
|
|
197
|
+
rejectSourceLost(failure);
|
|
198
|
+
settle();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
const host = await Promise.race([waitForReady(ready, this.config, ac.signal), sourceLost]);
|
|
203
|
+
if (ac.signal.aborted) throw new Error("generation aborted during readiness handshake");
|
|
204
|
+
this.attempt = 0;
|
|
205
|
+
this.emitState("connected");
|
|
206
|
+
if (this.isGenerationActive(ac)) this.callSink(() => {
|
|
207
|
+
this.sinks.onConnected?.(host);
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!ac.signal.aborted) ac.abort(error);
|
|
211
|
+
}
|
|
212
|
+
await failed;
|
|
213
|
+
if (!this.isRunning()) return;
|
|
214
|
+
if (manualAttempt) this.attempt = 0;
|
|
215
|
+
retry = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Deduplicated state emission (sink isolation applies). */
|
|
219
|
+
emitState(state) {
|
|
220
|
+
if (this.lastState === state) return;
|
|
221
|
+
this.lastState = state;
|
|
222
|
+
this.callSink(() => this.sinks.onStateChange?.(state));
|
|
223
|
+
}
|
|
224
|
+
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
|
|
225
|
+
callSink(fn) {
|
|
226
|
+
try {
|
|
227
|
+
fn();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error("[connection] connection sink threw:", error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
/** Report a slow handshake before the hard deadline ends its generation. */
|
|
234
|
+
function waitForReady(ready, config, signal) {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
let settled = false;
|
|
237
|
+
const warning = setTimeout(() => {
|
|
238
|
+
console.warn(`[connection] generation is still not ready after ${String(config.generationReadyWarnMs)}ms`);
|
|
239
|
+
}, config.generationReadyWarnMs);
|
|
240
|
+
const timeout = setTimeout(() => {
|
|
241
|
+
const error = /* @__PURE__ */ new Error(`connection generation was not ready within ${String(config.generationReadyTimeoutMs)}ms`);
|
|
242
|
+
console.warn(`[connection] ${error.message}; cancelling generation`);
|
|
243
|
+
finish({ error });
|
|
244
|
+
}, config.generationReadyTimeoutMs);
|
|
245
|
+
const aborted = () => {
|
|
246
|
+
finish({ error: new Error("connection generation aborted", { cause: signal.reason }) });
|
|
247
|
+
};
|
|
248
|
+
const finish = (outcome) => {
|
|
249
|
+
if (settled) return;
|
|
250
|
+
settled = true;
|
|
251
|
+
clearTimeout(warning);
|
|
252
|
+
clearTimeout(timeout);
|
|
253
|
+
signal.removeEventListener("abort", aborted);
|
|
254
|
+
if ("error" in outcome) reject(outcome.error);
|
|
255
|
+
else resolve(outcome.value);
|
|
256
|
+
};
|
|
257
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
258
|
+
ready.then((value) => {
|
|
259
|
+
finish({ value });
|
|
260
|
+
}, (error) => {
|
|
261
|
+
finish({ error });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region lib/types/rpc.js
|
|
267
|
+
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
|
268
|
+
/**
|
|
269
|
+
* Brand one validated string as a Connection correlation id.
|
|
270
|
+
* @param id - validated wire identity.
|
|
271
|
+
* @returns the same string with the correlation-id brand.
|
|
272
|
+
*/
|
|
273
|
+
function RpcId(id) {
|
|
274
|
+
return id;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Convert a rejected transport operation into a generic failure result.
|
|
278
|
+
* @param error - rejected transport value.
|
|
279
|
+
* @returns an `internal` failure preserving the available message.
|
|
280
|
+
*/
|
|
281
|
+
function transportError(error) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
error: {
|
|
285
|
+
code: "gateway/internal",
|
|
286
|
+
message: error instanceof Error ? error.message : String(error),
|
|
287
|
+
details: {}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region lib/types/client/random-uuid.js
|
|
293
|
+
/** Browser-safe UUID generation for client-side wire correlation. */
|
|
294
|
+
/**
|
|
295
|
+
* Generate an RFC 4122 version 4 UUID without requiring a secure context.
|
|
296
|
+
* @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins.
|
|
297
|
+
*/
|
|
298
|
+
function randomUuid() {
|
|
299
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
300
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
301
|
+
view.setUint8(6, view.getUint8(6) & 15 | 64);
|
|
302
|
+
view.setUint8(8, view.getUint8(8) & 63 | 128);
|
|
303
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
304
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region lib/types/client/rpc.js
|
|
308
|
+
/** Browser caller for generic Connection unary RPC channels. */
|
|
309
|
+
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/;
|
|
310
|
+
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
|
|
311
|
+
/**
|
|
312
|
+
* Create the browser-backed generic RPC caller.
|
|
313
|
+
* @param doFetch - transport override; defaults to the page's global fetch.
|
|
314
|
+
* @param openStream - optional worker-local Gateway stream carrier.
|
|
315
|
+
* @returns caller that owns request correlation and response-envelope validation.
|
|
316
|
+
*/
|
|
317
|
+
function createWebConnectionRpc(doFetch, openStream) {
|
|
318
|
+
const send = doFetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
319
|
+
return {
|
|
320
|
+
async call(channel, endpoint, payload, signal) {
|
|
321
|
+
assertTarget(channel, endpoint);
|
|
322
|
+
const rpcId = RpcId(randomUuid());
|
|
323
|
+
const message = {
|
|
324
|
+
type: "client-request",
|
|
325
|
+
rpcId,
|
|
326
|
+
method: endpoint,
|
|
327
|
+
payload
|
|
328
|
+
};
|
|
329
|
+
const response = await send(`${channel}/${endpoint}`.slice(1), {
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: { "content-type": "application/json" },
|
|
332
|
+
body: JSON.stringify(message),
|
|
333
|
+
...signal === void 0 ? {} : { signal }
|
|
334
|
+
});
|
|
335
|
+
if (!response.ok) throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`);
|
|
336
|
+
const full = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "multipart/form-data" ? await parseBinaryResponse(response) : parseConnectionResponse(await response.json());
|
|
337
|
+
signal?.throwIfAborted();
|
|
338
|
+
if (full.rpcId !== rpcId) throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`);
|
|
339
|
+
return full.result;
|
|
340
|
+
},
|
|
341
|
+
...openStream === void 0 ? {} : { open(channel, endpoint, payload, signal, uplink) {
|
|
342
|
+
assertTarget(channel, endpoint);
|
|
343
|
+
if (channel !== "/api") throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`);
|
|
344
|
+
return openStream(endpoint, payload, signal, uplink);
|
|
345
|
+
} }
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
async function parseBinaryResponse(response) {
|
|
349
|
+
const body = await response.formData();
|
|
350
|
+
const fields = /* @__PURE__ */ new Map();
|
|
351
|
+
for (const [name, value] of body) {
|
|
352
|
+
if (fields.has(name)) throw new TypeError("connection: invalid binary response fields");
|
|
353
|
+
fields.set(name, value);
|
|
354
|
+
}
|
|
355
|
+
const metadata = fields.get("metadata");
|
|
356
|
+
fields.delete("metadata");
|
|
357
|
+
if (typeof metadata !== "string") throw new TypeError("connection: invalid binary response fields");
|
|
358
|
+
const envelope = JSON.parse(metadata);
|
|
359
|
+
const full = parseConnectionResponse(envelope);
|
|
360
|
+
if (!full.result.ok || !isRecord(envelope) || !Array.isArray(envelope.attachments) || envelope.attachments.length === 0) throw new TypeError("connection: invalid binary response result");
|
|
361
|
+
const root = { value: full.result.value };
|
|
362
|
+
for (const attachment of envelope.attachments) {
|
|
363
|
+
if (!isRecord(attachment) || attachment.codec !== "bytes" || typeof attachment.part !== "string" || !Array.isArray(attachment.path)) throw new TypeError("connection: invalid binary response attachment");
|
|
364
|
+
const data = fields.get(attachment.part);
|
|
365
|
+
fields.delete(attachment.part);
|
|
366
|
+
if (!(data instanceof Blob)) throw new TypeError("connection: invalid binary response fields");
|
|
367
|
+
let parent = root;
|
|
368
|
+
let key = "value";
|
|
369
|
+
for (const segment of attachment.path) {
|
|
370
|
+
const value = Reflect.get(parent, key);
|
|
371
|
+
if (typeof value !== "object" || value === null) throw new TypeError("connection: invalid binary response path");
|
|
372
|
+
if (Array.isArray(value)) {
|
|
373
|
+
if (typeof segment !== "number" || !Number.isSafeInteger(segment) || segment < 0 || segment >= value.length) throw new TypeError("connection: invalid binary response path");
|
|
374
|
+
} else if (typeof segment !== "string") throw new TypeError("connection: invalid binary response path");
|
|
375
|
+
if (!Object.hasOwn(value, segment)) throw new TypeError("connection: invalid binary response path");
|
|
376
|
+
parent = value;
|
|
377
|
+
key = segment;
|
|
378
|
+
}
|
|
379
|
+
if (Reflect.get(parent, key) !== null) throw new TypeError("connection: invalid binary response placeholder");
|
|
380
|
+
Object.defineProperty(parent, key, {
|
|
381
|
+
value: new Uint8Array(await data.arrayBuffer()),
|
|
382
|
+
enumerable: true,
|
|
383
|
+
writable: true,
|
|
384
|
+
configurable: true
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (fields.size !== 0) throw new TypeError("connection: invalid binary response fields");
|
|
388
|
+
return {
|
|
389
|
+
rpcId: full.rpcId,
|
|
390
|
+
result: {
|
|
391
|
+
ok: true,
|
|
392
|
+
value: root.value
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function parseConnectionResponse(value) {
|
|
397
|
+
if (!isRecord(value) || value.type !== "server-response" || typeof value.rpcId !== "string") throw new TypeError("connection: invalid server-response envelope");
|
|
398
|
+
const result = value.result;
|
|
399
|
+
if (!isRecord(result)) throw new TypeError("connection: invalid server-response result");
|
|
400
|
+
if (result.ok === true) return {
|
|
401
|
+
rpcId: RpcId(value.rpcId),
|
|
402
|
+
result: {
|
|
403
|
+
ok: true,
|
|
404
|
+
value: result.value
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
if (result.ok !== false || !isRecord(result.error)) throw new TypeError("connection: invalid server-response result");
|
|
408
|
+
const error = result.error;
|
|
409
|
+
if (typeof error.code !== "string" || typeof error.message !== "string" || !isRecord(error.details)) throw new TypeError("connection: invalid server-response failure");
|
|
410
|
+
return {
|
|
411
|
+
rpcId: RpcId(value.rpcId),
|
|
412
|
+
result: {
|
|
413
|
+
ok: false,
|
|
414
|
+
error: {
|
|
415
|
+
code: error.code,
|
|
416
|
+
message: error.message,
|
|
417
|
+
details: error.details
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function isRecord(value) {
|
|
423
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
424
|
+
}
|
|
425
|
+
function assertTarget(channel, endpoint) {
|
|
426
|
+
const segments = endpoint.split("/");
|
|
427
|
+
if (!CHANNEL_PATTERN.test(channel) || segments.some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`);
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region lib/types/loopback-hostname.js
|
|
431
|
+
/**
|
|
432
|
+
* Browser-safe, zero-dependency loopback classification shared by the `/api`
|
|
433
|
+
* Host fence and the package's `ctx.connection` state. The predicate stays
|
|
434
|
+
* package-internal; client plugins consume the derived state through Cordis.
|
|
435
|
+
*/
|
|
436
|
+
/**
|
|
437
|
+
* Whether a normalized URL hostname names the local loopback authority.
|
|
438
|
+
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
|
|
439
|
+
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
|
|
440
|
+
*/
|
|
441
|
+
function isLoopbackHostname(hostname) {
|
|
442
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
443
|
+
const parts = hostname.split(".");
|
|
444
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
445
|
+
}
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region lib/types/client/socket.js
|
|
448
|
+
/** Physical socket selection for browser, desktop, and authenticated Node connections. */
|
|
449
|
+
/**
|
|
450
|
+
* Resolve a logical channel against the explicit Host or the page's document base.
|
|
451
|
+
* @param path - absolute logical channel path.
|
|
452
|
+
* @param factory - authenticated socket factory; omission uses the browser API.
|
|
453
|
+
* @param baseUrl - Host base URL, required outside a document.
|
|
454
|
+
* @returns a socket owned by the calling channel.
|
|
455
|
+
*/
|
|
456
|
+
function openConnectionWebSocket(path, factory = (url) => new WebSocket(url), baseUrl) {
|
|
457
|
+
const base = baseUrl ?? (typeof document === "undefined" ? void 0 : document.baseURI);
|
|
458
|
+
if (base === void 0) throw new Error("connection: WebSocket carrier requires streamBaseUrl outside a document");
|
|
459
|
+
const url = new URL(path.slice(1), base);
|
|
460
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
461
|
+
return factory(url.href);
|
|
462
|
+
}
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region lib/types/client/index.js
|
|
465
|
+
/** Required services (none — this is the wire root). */
|
|
466
|
+
const inject = [];
|
|
467
|
+
function watchBrowserNetwork(controller) {
|
|
468
|
+
const browser = globalThis.window;
|
|
469
|
+
const initiallyAvailable = browser?.navigator?.onLine;
|
|
470
|
+
if (browser === void 0 || initiallyAvailable === void 0) return () => {};
|
|
471
|
+
const online = () => {
|
|
472
|
+
controller.setNetworkAvailable(true);
|
|
473
|
+
};
|
|
474
|
+
const offline = () => {
|
|
475
|
+
controller.setNetworkAvailable(false);
|
|
476
|
+
};
|
|
477
|
+
controller.setNetworkAvailable(initiallyAvailable);
|
|
478
|
+
browser.addEventListener("online", online);
|
|
479
|
+
browser.addEventListener("offline", offline);
|
|
480
|
+
return () => {
|
|
481
|
+
browser.removeEventListener("online", online);
|
|
482
|
+
browser.removeEventListener("offline", offline);
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Install one Context-owned Connection service from explicit composition inputs.
|
|
487
|
+
* @param ctx - client Cordis context.
|
|
488
|
+
* @param options - interaction participation, physical carrier, reconnect timing, and page location.
|
|
489
|
+
*/
|
|
490
|
+
function installConnection(ctx, options = {}) {
|
|
491
|
+
const pageLocation = options.location;
|
|
492
|
+
const transport = options.transport;
|
|
493
|
+
const recovery = options.recovery ?? {};
|
|
494
|
+
const interactions = options.interactions ?? (pageLocation === void 0 ? "none" : "answer");
|
|
495
|
+
const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream);
|
|
496
|
+
let generationSource;
|
|
497
|
+
let owner;
|
|
498
|
+
let generationId = 0;
|
|
499
|
+
let generation;
|
|
500
|
+
let state;
|
|
501
|
+
const generationListeners = /* @__PURE__ */ new Set();
|
|
502
|
+
const stateListeners = /* @__PURE__ */ new Set();
|
|
503
|
+
const publishGeneration = (next) => {
|
|
504
|
+
if (Object.is(generation, next)) return;
|
|
505
|
+
generation = next;
|
|
506
|
+
for (const listener of [...generationListeners]) try {
|
|
507
|
+
listener();
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.error("[connection] generation listener threw:", error);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
const publishState = (next) => {
|
|
513
|
+
if (state === next) return;
|
|
514
|
+
state = next;
|
|
515
|
+
for (const listener of [...stateListeners]) try {
|
|
516
|
+
listener();
|
|
517
|
+
} catch (error) {
|
|
518
|
+
console.error("[connection] state listener threw:", error);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
const releaseOwner = (current) => {
|
|
522
|
+
if (owner !== current) return;
|
|
523
|
+
owner = void 0;
|
|
524
|
+
current.stopNetworkWatch();
|
|
525
|
+
current.controller.stop();
|
|
526
|
+
publishGeneration(void 0);
|
|
527
|
+
publishState(void 0);
|
|
528
|
+
};
|
|
529
|
+
const handle = {
|
|
530
|
+
interactions,
|
|
531
|
+
openWebSocket: (path) => openConnectionWebSocket(path, transport?.createWebSocket, transport?.streamBaseUrl),
|
|
532
|
+
isLoopback: transport?.ownsHost === true || pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),
|
|
533
|
+
generation: {
|
|
534
|
+
getSnapshot: () => generation,
|
|
535
|
+
subscribe: (listener) => {
|
|
536
|
+
generationListeners.add(listener);
|
|
537
|
+
return () => {
|
|
538
|
+
generationListeners.delete(listener);
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
state: {
|
|
543
|
+
getSnapshot: () => state,
|
|
544
|
+
subscribe: (listener) => {
|
|
545
|
+
stateListeners.add(listener);
|
|
546
|
+
return () => {
|
|
547
|
+
stateListeners.delete(listener);
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
rpc,
|
|
552
|
+
reconnect() {
|
|
553
|
+
owner?.controller.reconnect();
|
|
554
|
+
},
|
|
555
|
+
registerGenerationSource(source) {
|
|
556
|
+
if (generationSource !== void 0) throw new Error("connection: a generation source is already registered");
|
|
557
|
+
generationSource = source;
|
|
558
|
+
return () => {
|
|
559
|
+
if (generationSource !== source) return;
|
|
560
|
+
generationSource = void 0;
|
|
561
|
+
const current = owner;
|
|
562
|
+
if (current?.source === source) releaseOwner(current);
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
start(sinks, config) {
|
|
566
|
+
if (owner !== void 0) throw new Error("connection: the stream loop is already owned by another consumer");
|
|
567
|
+
const source = generationSource;
|
|
568
|
+
if (source === void 0) throw new Error("connection: no generation source is registered");
|
|
569
|
+
const token = {};
|
|
570
|
+
const ownsGeneration = () => owner?.token === token;
|
|
571
|
+
const controller = new ConnectionController(source, {
|
|
572
|
+
...sinks,
|
|
573
|
+
onConnected: (host) => {
|
|
574
|
+
const nextGeneration = {
|
|
575
|
+
id: ++generationId,
|
|
576
|
+
host
|
|
577
|
+
};
|
|
578
|
+
publishGeneration(nextGeneration);
|
|
579
|
+
if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return;
|
|
580
|
+
sinks.onConnected?.(host);
|
|
581
|
+
},
|
|
582
|
+
onStateChange: (state) => {
|
|
583
|
+
if (state !== "connected") publishGeneration(void 0);
|
|
584
|
+
if (!ownsGeneration()) return;
|
|
585
|
+
publishState(state);
|
|
586
|
+
sinks.onStateChange?.(state);
|
|
587
|
+
}
|
|
588
|
+
}, {
|
|
589
|
+
...recovery,
|
|
590
|
+
...config
|
|
591
|
+
});
|
|
592
|
+
const current = {
|
|
593
|
+
token,
|
|
594
|
+
source,
|
|
595
|
+
controller,
|
|
596
|
+
stopNetworkWatch: watchBrowserNetwork(controller)
|
|
597
|
+
};
|
|
598
|
+
owner = current;
|
|
599
|
+
controller.start();
|
|
600
|
+
return { stop: () => {
|
|
601
|
+
releaseOwner(current);
|
|
602
|
+
} };
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
ctx.provide("connection", handle);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Client plugin body: read the page composition and install its Connection service.
|
|
609
|
+
* @param ctx - client Cordis context.
|
|
610
|
+
*/
|
|
611
|
+
function apply(ctx) {
|
|
612
|
+
const globals = globalThis;
|
|
613
|
+
const pageLocation = typeof location === "undefined" ? void 0 : location;
|
|
614
|
+
const transport = globals.__DSH_TRANSPORT__;
|
|
615
|
+
installConnection(ctx, {
|
|
616
|
+
...transport === void 0 ? {} : { transport },
|
|
617
|
+
recovery: resolveConnectionConfig(globals.__DSH_CONNECTION_RECOVERY__),
|
|
618
|
+
...pageLocation === void 0 ? {} : { location: pageLocation }
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
//#endregion
|
|
622
|
+
export { RpcId, apply, inject, installConnection, transportError };
|
|
623
|
+
|
|
624
|
+
//# sourceMappingURL=client.node.js.map
|