@camera.ui/transport 0.0.30 → 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.
@@ -1,5 +1,5 @@
1
+ import { n as refusedByServer, t as classifyClose } from "../closeCodes-CltSRjdJ.js";
1
2
  import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-dt1m8U-o.js";
2
- import { t as classifyClose } from "../closeCodes-ClWJVOpq.js";
3
3
  import { createRPCClient } from "@camera.ui/rpc";
4
4
  //#region src/transports/nats.ts
5
5
  var NATS_SPEC = {
@@ -8,6 +8,8 @@ var NATS_SPEC = {
8
8
  phaseGating: true
9
9
  };
10
10
  var RESOLVE_RETRY_MIN_MS = 3e3;
11
+ var DIAL_GRACE_MS = 2e3;
12
+ var CLIENT_DEFAULT_PING_INTERVAL_MS = 12e4;
11
13
  function newConnId() {
12
14
  const c = globalThis.crypto;
13
15
  if (c?.randomUUID) return c.randomUUID();
@@ -35,6 +37,8 @@ function createNatsTransport(options = {}) {
35
37
  const pingInterval = options.pingInterval ?? 25e3;
36
38
  const pingTimeout = options.pingTimeout ?? 2e4;
37
39
  const maxPingOut = options.maxPingOut ?? 1;
40
+ const heartbeatInterval = pingInterval || CLIENT_DEFAULT_PING_INTERVAL_MS;
41
+ const heartbeatWindow = heartbeatInterval + (pingTimeout > 0 ? pingTimeout : heartbeatInterval);
38
42
  const logger = options.logger;
39
43
  const resolveServers = options.resolveServers;
40
44
  const resolveRefreshMs = options.resolveRefreshMs ?? 0;
@@ -48,16 +52,19 @@ function createNatsTransport(options = {}) {
48
52
  let disposed = false;
49
53
  let connId = null;
50
54
  let applyEpoch = 0;
51
- let pendingConnectAbort = null;
55
+ let dialAbort = null;
56
+ let dialAttempt = null;
57
+ let parked = false;
52
58
  let poolRefreshAt = 0;
53
59
  let poolRefreshing = false;
60
+ let dialAccess;
61
+ let probedUntil = 0;
54
62
  function buildServers(target) {
55
63
  const url = new URL(target.endpoint.url);
56
64
  const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
57
65
  const port = url.port || (url.protocol === "https:" ? "443" : "80");
58
66
  const prefix = url.pathname.replace(/\/$/, "");
59
67
  const params = new URLSearchParams({ token: target.tokens.access });
60
- if (target.tokens.proxySession) params.set("session", target.tokens.proxySession);
61
68
  if (connId) params.set("connId", connId);
62
69
  return [`${wsProtocol}//${url.hostname}:${port}${prefix}${proxyPath}?${params.toString()}`];
63
70
  }
@@ -171,13 +178,14 @@ function createNatsTransport(options = {}) {
171
178
  })().catch(() => {});
172
179
  }
173
180
  function handleErrorEvent(event) {
174
- const message = stringifyError(event.data);
181
+ const payload = event.error ?? event.data;
182
+ const message = stringifyError(payload ?? "server error");
175
183
  if (message.toLowerCase().includes("permissions violation")) {
176
184
  logger?.warn(`permission violation (connection stays up): ${message}`);
177
185
  return;
178
186
  }
179
- const cls = classifyClose(typeof event.data === "object" && event.data !== null ? {
180
- ...event.data,
187
+ const cls = classifyClose(typeof payload === "object" && payload !== null ? {
188
+ ...payload,
181
189
  message
182
190
  } : message);
183
191
  if (cls === "auth-expired") {
@@ -185,102 +193,172 @@ function createNatsTransport(options = {}) {
185
193
  resign(message);
186
194
  return;
187
195
  }
188
- emitter.emit("auth-error", { message });
196
+ emitter.emit("auth-error", {
197
+ message,
198
+ access: dialAccess
199
+ });
189
200
  return;
190
201
  }
191
202
  if (cls === "forbidden") {
203
+ if (refusedByServer(payload)) {
204
+ park(message);
205
+ return;
206
+ }
192
207
  logger?.warn(`forbidden by server: ${message}`);
193
- markDown(`forbidden: ${message}`);
208
+ markDown(forbiddenError(message));
194
209
  return;
195
210
  }
196
211
  markDown(message);
197
212
  }
213
+ function park(message) {
214
+ parked = true;
215
+ const dropped = proxy;
216
+ if (dropped) {
217
+ stopStatusMonitor();
218
+ stopRefresh();
219
+ proxy = null;
220
+ try {
221
+ dropped.abortClose();
222
+ } catch {}
223
+ }
224
+ logger?.warn(`forbidden by server: ${message}`);
225
+ markDown(forbiddenError(message));
226
+ if (dropped) notifyClient();
227
+ }
198
228
  async function rebuildClient(target, epoch) {
199
229
  logger?.debug(`rebuildClient start (epoch=${epoch})`);
200
- const t0 = Date.now();
201
230
  stopStatusMonitor();
202
231
  stopRefresh();
203
- pendingConnectAbort?.abort();
232
+ dialAbort?.abort();
233
+ parked = false;
204
234
  if (proxy) {
205
235
  try {
206
236
  proxy.abortClose();
207
237
  } catch {}
208
238
  proxy = null;
239
+ markDown("endpoint-changed");
209
240
  notifyClient();
210
241
  }
211
- connId = newConnId();
212
- let servers;
242
+ const abort = new AbortController();
243
+ dialAbort = abort;
213
244
  try {
214
- servers = await serversFor(target);
215
- } catch (err) {
216
- if (disposed || epoch !== applyEpoch) return;
217
- const msg = stringifyError(err);
218
- logger?.debug(`rebuildClient resolve FAILED: ${msg}`);
219
- markDown(msg);
220
- throw err;
245
+ for (let retry = 0;; retry++) {
246
+ if (retry > 0) {
247
+ const wait = retryDelay(retry - 1);
248
+ logger?.debug(`rebuildClient retry in ${wait}ms`);
249
+ await sleep(wait, abort.signal);
250
+ if (disposed || epoch !== applyEpoch) return;
251
+ }
252
+ if (await dialOnce(target, epoch, abort.signal)) return;
253
+ }
254
+ } finally {
255
+ if (dialAbort === abort) dialAbort = null;
221
256
  }
222
- if (disposed || epoch !== applyEpoch) return;
223
- const next = createRPCClient({
224
- servers,
225
- name: clientName,
226
- connId,
227
- auth: {
228
- user: natsUser,
229
- password: natsPassword
230
- },
231
- reconnect: true,
232
- maxReconnectAttempts: -1,
233
- reconnectTimeWait,
234
- reconnectionDelayMax,
235
- reconnectionRandomizationFactor,
236
- timeout,
237
- connectTimeout,
238
- pingInterval,
239
- pingTimeout,
240
- maxPingOut,
241
- ignoreAuthErrorAbort: true
242
- });
243
- const connectAbort = new AbortController();
244
- pendingConnectAbort = connectAbort;
257
+ }
258
+ async function dialOnce(target, epoch, signal) {
259
+ const t0 = Date.now();
260
+ let settle;
261
+ const attempt = {
262
+ startedAt: t0,
263
+ settled: new Promise((resolve) => settle = resolve)
264
+ };
265
+ dialAttempt = attempt;
245
266
  try {
246
- await next.connect({ signal: connectAbort.signal });
247
- } catch (err) {
267
+ connId = newConnId();
268
+ let servers;
248
269
  try {
249
- next.abortClose();
250
- } catch {}
251
- if (disposed || epoch !== applyEpoch) return;
252
- const msg = err instanceof Error ? err.message : String(err);
253
- logger?.debug(`rebuildClient connect FAILED after ${Date.now() - t0}ms: ${msg}`);
254
- if (!resolveServers && classifyClose(err instanceof Error ? err : msg) === "auth-expired") emitter.emit("auth-error", { message: msg });
255
- markDown(msg);
256
- throw err;
257
- } finally {
258
- if (pendingConnectAbort === connectAbort) pendingConnectAbort = null;
259
- }
260
- if (disposed || epoch !== applyEpoch) {
270
+ servers = await serversFor(target);
271
+ } catch (err) {
272
+ if (disposed || epoch !== applyEpoch) return true;
273
+ const msg = stringifyError(err);
274
+ logger?.debug(`rebuildClient resolve FAILED: ${msg}`);
275
+ markDown(msg);
276
+ return false;
277
+ }
278
+ if (disposed || epoch !== applyEpoch) return true;
279
+ dialAccess = target.tokens.access;
280
+ const next = createRPCClient({
281
+ servers,
282
+ name: clientName,
283
+ connId,
284
+ auth: {
285
+ user: natsUser,
286
+ password: natsPassword
287
+ },
288
+ reconnect: true,
289
+ maxReconnectAttempts: -1,
290
+ reconnectTimeWait,
291
+ reconnectionDelayMax,
292
+ reconnectionRandomizationFactor,
293
+ timeout,
294
+ connectTimeout,
295
+ pingInterval,
296
+ pingTimeout,
297
+ maxPingOut,
298
+ ignoreAuthErrorAbort: true,
299
+ waitOnFirstConnect: false
300
+ });
261
301
  try {
262
- next.abortClose();
263
- } catch {}
264
- return;
302
+ await next.connect({ signal });
303
+ } catch (err) {
304
+ try {
305
+ next.abortClose();
306
+ } catch {}
307
+ if (disposed || epoch !== applyEpoch) return true;
308
+ const msg = err instanceof Error ? err.message : String(err);
309
+ logger?.debug(`rebuildClient connect FAILED after ${Date.now() - t0}ms: ${msg}`);
310
+ const cls = classifyClose(err instanceof Error ? err : msg);
311
+ if (cls === "forbidden") {
312
+ park(msg);
313
+ throw err;
314
+ }
315
+ if (cls === "auth-expired" && !resolveServers) {
316
+ parked = true;
317
+ emitter.emit("auth-error", {
318
+ message: msg,
319
+ access: target.tokens.access
320
+ });
321
+ markDown(msg);
322
+ throw err;
323
+ }
324
+ markDown(msg);
325
+ return false;
326
+ }
327
+ if (disposed || epoch !== applyEpoch) {
328
+ try {
329
+ next.abortClose();
330
+ } catch {}
331
+ return true;
332
+ }
333
+ proxy = next;
334
+ logger?.debug(`rebuildClient connected in ${Date.now() - t0}ms`);
335
+ markUp();
336
+ notifyClient();
337
+ startStatusMonitor(next);
338
+ startRefresh(next);
339
+ return true;
340
+ } finally {
341
+ settle();
342
+ if (dialAttempt === attempt) dialAttempt = null;
265
343
  }
266
- proxy = next;
267
- logger?.debug(`rebuildClient connected in ${Date.now() - t0}ms`);
268
- markUp();
269
- notifyClient();
270
- startStatusMonitor(next);
271
- startRefresh(next);
344
+ }
345
+ function retryDelay(retry) {
346
+ const base = reconnectionDelayMax > 0 ? Math.min(reconnectTimeWait * 2 ** retry, reconnectionDelayMax) : reconnectTimeWait;
347
+ const spread = base * reconnectionRandomizationFactor;
348
+ return Math.round(base - spread + Math.random() * 2 * spread);
272
349
  }
273
350
  async function apply(target) {
274
351
  if (disposed) throw new Error("nats-transport disposed");
275
- if (isSameTarget(currentTarget, target)) return;
352
+ if (isSameTarget(currentTarget, target) && (!target || proxy || dialAbort || parked)) return;
276
353
  const epoch = ++applyEpoch;
277
354
  const endpointChanged = isEndpointChange(currentTarget, target);
278
355
  currentTarget = target;
279
356
  if (!target) {
280
357
  stopStatusMonitor();
281
358
  stopRefresh();
282
- pendingConnectAbort?.abort();
283
- pendingConnectAbort = null;
359
+ dialAbort?.abort();
360
+ dialAbort = null;
361
+ parked = false;
284
362
  if (proxy) {
285
363
  try {
286
364
  proxy.abortClose();
@@ -292,20 +370,19 @@ function createNatsTransport(options = {}) {
292
370
  return;
293
371
  }
294
372
  if (endpointChanged || !proxy) {
295
- try {
296
- await rebuildClient(target, epoch);
297
- } catch (err) {
298
- if (epoch === applyEpoch && currentTarget === target) currentTarget = null;
299
- throw err;
300
- }
373
+ await rebuildClient(target, epoch);
301
374
  return;
302
375
  }
303
376
  const p = proxy;
304
377
  const newServers = await serversFor(target);
305
- if (proxy === p && epoch === applyEpoch) p.setServers(newServers);
378
+ if (epoch !== applyEpoch) return;
379
+ if (proxy === p) {
380
+ p.setServers(newServers);
381
+ dialAccess = target.tokens.access;
382
+ } else if (parked) await rebuildClient(target, epoch);
306
383
  }
307
384
  function health() {
308
- if (status.up && proxy?.isStale) return {
385
+ if (status.up && proxy?.isStale && Date.now() > probedUntil) return {
309
386
  up: false,
310
387
  lastError: "staleConnection"
311
388
  };
@@ -319,8 +396,8 @@ function createNatsTransport(options = {}) {
319
396
  applyEpoch++;
320
397
  stopStatusMonitor();
321
398
  stopRefresh();
322
- pendingConnectAbort?.abort();
323
- pendingConnectAbort = null;
399
+ dialAbort?.abort();
400
+ dialAbort = null;
324
401
  if (proxy) {
325
402
  try {
326
403
  proxy.abortClose();
@@ -345,6 +422,7 @@ function createNatsTransport(options = {}) {
345
422
  const t0 = Date.now();
346
423
  try {
347
424
  await proxy.flush(timeoutMs);
425
+ probedUntil = Date.now() + heartbeatWindow;
348
426
  logger?.debug(`probeAlive OK in ${Date.now() - t0}ms`);
349
427
  } catch (err) {
350
428
  logger?.debug(`probeAlive FAILED after ${Date.now() - t0}ms: ${err instanceof Error ? err.message : String(err)}`);
@@ -365,17 +443,20 @@ function createNatsTransport(options = {}) {
365
443
  function ensureAlive() {
366
444
  if (ensureInflight) return ensureInflight;
367
445
  const run = async () => {
368
- if (disposed || !currentTarget) return health();
446
+ if (disposed || !currentTarget || parked) return health();
369
447
  if (!proxy) {
370
- const epoch = ++applyEpoch;
371
- try {
372
- await rebuildClient(currentTarget, epoch);
373
- } catch {}
448
+ const attempt = dialAttempt;
449
+ if (attempt && Date.now() - attempt.startedAt < DIAL_GRACE_MS) {
450
+ await attempt.settled;
451
+ return health();
452
+ }
453
+ rebuildClient(currentTarget, ++applyEpoch).catch(() => {});
454
+ await dialAttempt?.settled;
374
455
  return health();
375
456
  }
376
- if (health().up) try {
457
+ if (status.up) try {
377
458
  await probeAlive(3e3);
378
- return health();
459
+ return status;
379
460
  } catch {}
380
461
  try {
381
462
  await forceReconnect();
@@ -400,11 +481,29 @@ function createNatsTransport(options = {}) {
400
481
  ensureAlive
401
482
  };
402
483
  }
484
+ function sleep(ms, signal) {
485
+ return new Promise((resolve) => {
486
+ if (signal.aborted) {
487
+ resolve();
488
+ return;
489
+ }
490
+ const timer = setTimeout(done, ms);
491
+ signal.addEventListener("abort", done, { once: true });
492
+ function done() {
493
+ clearTimeout(timer);
494
+ signal.removeEventListener("abort", done);
495
+ resolve();
496
+ }
497
+ });
498
+ }
499
+ function forbiddenError(message) {
500
+ return /^forbidden\b/.test(message) ? message : `forbidden: ${message}`;
501
+ }
403
502
  function stringifyError(value) {
404
503
  if (value instanceof Error) return value.message;
405
504
  if (typeof value === "string") return value;
406
505
  try {
407
- return JSON.stringify(value);
506
+ return JSON.stringify(value) ?? String(value);
408
507
  } catch {
409
508
  return String(value);
410
509
  }
@@ -1,7 +1,7 @@
1
1
  import { Manager, Socket } from 'socket.io-client';
2
2
  import { Logger } from '@camera.ui/logger';
3
3
  import { ConnectionTarget, TransportSpec, TransportStatus } from '../core/types.js';
4
- import { Transport } from './contract.js';
4
+ import { Transport, Unsubscribe } from './contract.js';
5
5
  export type { Socket };
6
6
  export interface ResolveSocketQueryContext {
7
7
  readonly target: ConnectionTarget;
@@ -20,12 +20,15 @@ export interface SocketioTransportOptions {
20
20
  readonly reconnectionDelayMax?: number;
21
21
  readonly timeout?: number;
22
22
  readonly staleAfterMs?: number;
23
+ readonly releaseGraceMs?: number;
23
24
  readonly logger?: Logger;
24
25
  }
25
26
  export interface SocketioTransport extends Transport {
26
27
  readonly manager: Manager | null;
27
28
  socket(namespace?: string): Socket | null;
28
29
  ensureSocket(namespace: string): Socket | null;
30
+ acquire(namespace: string): Unsubscribe;
31
+ onRebuild(handler: () => void): Unsubscribe;
29
32
  reviveDeadSockets(): void;
30
33
  ensureAlive(): Promise<TransportStatus>;
31
34
  }