@camera.ui/transport 0.0.13 → 0.0.16

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.
@@ -38,6 +38,7 @@ export type ConnectionPhase = {
38
38
  readonly kind: 'discovering';
39
39
  readonly instanceId: string;
40
40
  readonly attempt: number;
41
+ readonly transports?: ReadonlyMap<TransportId, TransportStatus>;
41
42
  } | {
42
43
  readonly kind: 'online';
43
44
  readonly instanceId: string;
@@ -7,6 +7,7 @@ export interface CrossTabOptions {
7
7
  readonly kernel: Kernel;
8
8
  readonly key?: string;
9
9
  readonly source?: CrossTabSource;
10
+ readonly absorb?: (target: ConnectionTarget | null) => void;
10
11
  readonly onTokensReceived?: (tokens: ConnectionTarget['tokens']) => void;
11
12
  readonly onResetReceived?: () => void;
12
13
  readonly onError?: (op: 'parse', err: unknown) => void;
@@ -16,6 +16,7 @@ export interface Persistence {
16
16
  readonly detach: Detach;
17
17
  peek(): ConnectionTarget | null;
18
18
  seed(target: ConnectionTarget): Promise<void>;
19
+ absorb(target: ConnectionTarget | null): void;
19
20
  }
20
21
  export interface PersistenceOptions {
21
22
  readonly kernel: Kernel;
package/dist/index.js CHANGED
@@ -22,7 +22,8 @@ function reducer(phase, action, ctx) {
22
22
  if (phase.kind === "reconnecting") return {
23
23
  kind: "discovering",
24
24
  instanceId: phase.instanceId,
25
- attempt: 1
25
+ attempt: 1,
26
+ transports: phase.transports
26
27
  };
27
28
  if (phase.kind === "needs-auth") return {
28
29
  kind: "discovering",
@@ -32,7 +33,8 @@ function reducer(phase, action, ctx) {
32
33
  if (phase.kind === "online") return {
33
34
  kind: "discovering",
34
35
  instanceId: phase.instanceId,
35
- attempt: 1
36
+ attempt: 1,
37
+ transports: phase.transports
36
38
  };
37
39
  return phase;
38
40
  case "PROBE_SUCCEEDED":
@@ -43,7 +45,7 @@ function reducer(phase, action, ctx) {
43
45
  endpoint: action.endpoint,
44
46
  tokens: action.tokens
45
47
  },
46
- transports: EMPTY_TRANSPORTS
48
+ transports: phase.transports ?? EMPTY_TRANSPORTS
47
49
  };
48
50
  if (phase.kind === "reconnecting") return {
49
51
  kind: "online",
@@ -89,6 +91,10 @@ function reducer(phase, action, ctx) {
89
91
  transports: next
90
92
  };
91
93
  }
94
+ if (phase.kind === "discovering") return {
95
+ ...phase,
96
+ transports: setStatus(phase.transports ?? EMPTY_TRANSPORTS, action.id, { up: true })
97
+ };
92
98
  return phase;
93
99
  case "TRANSPORT_DOWN":
94
100
  if (phase.kind === "online" || phase.kind === "reconnecting") return {
@@ -99,6 +105,14 @@ function reducer(phase, action, ctx) {
99
105
  downSince: ctx.now()
100
106
  })
101
107
  };
108
+ if (phase.kind === "discovering") return {
109
+ ...phase,
110
+ transports: setStatus(phase.transports ?? EMPTY_TRANSPORTS, action.id, {
111
+ up: false,
112
+ lastError: action.reason,
113
+ downSince: ctx.now()
114
+ })
115
+ };
102
116
  return phase;
103
117
  case "TRANSPORT_DOWN_CONFIRMED":
104
118
  if (phase.kind !== "online") return phase;
@@ -321,6 +335,7 @@ function attachCrossTab(options) {
321
335
  const e = event;
322
336
  if (e.key !== key) return;
323
337
  if (e.newValue === null) {
338
+ options.absorb?.(null);
324
339
  const k = options.kernel.phase.kind;
325
340
  if (k !== "online" && k !== "reconnecting") return;
326
341
  options.kernel.dispatch({ type: "RESET" });
@@ -336,7 +351,16 @@ function attachCrossTab(options) {
336
351
  }
337
352
  if (!parsed?.tokens?.access) return;
338
353
  const k = options.kernel.phase.kind;
339
- if (k !== "online" && k !== "reconnecting") return;
354
+ if (k !== "online" && k !== "reconnecting") {
355
+ if (parsed.endpoint?.url) {
356
+ options.absorb?.({
357
+ endpoint: parsed.endpoint,
358
+ tokens: parsed.tokens
359
+ });
360
+ options.onTokensReceived?.(parsed.tokens);
361
+ }
362
+ return;
363
+ }
340
364
  options.kernel.dispatch({
341
365
  type: "TOKENS_REFRESHED",
342
366
  tokens: parsed.tokens
@@ -450,6 +474,10 @@ function attachPersistence(options) {
450
474
  seed: async (target) => {
451
475
  if (detached) return;
452
476
  await persist(target);
477
+ },
478
+ absorb: (target) => {
479
+ if (detached) return;
480
+ cached = target;
453
481
  }
454
482
  };
455
483
  }
@@ -695,7 +723,7 @@ function attachProbeLoop(options) {
695
723
  const underlying = err instanceof RaceFirstError ? err.cause : err;
696
724
  if (isProbeFailure(underlying) && underlying.kind === "aborted") {
697
725
  setTimeout(() => {
698
- if (!detached) runRound();
726
+ if (!detached && options.kernel.phase.kind === "discovering") runRound();
699
727
  }, 200);
700
728
  return;
701
729
  }
@@ -762,6 +790,7 @@ function attachReconnectWatchdog(options) {
762
790
  var DEFAULT_GRACE_MS$1 = 5e3;
763
791
  var DEFAULT_MAX_TRANSIENT_RETRIES = 3;
764
792
  var DEFAULT_TRANSIENT_RETRY_DELAY_MS = 2e3;
793
+ var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
765
794
  function attachTokenLifecycle(options) {
766
795
  const graceMs = options.graceMs ?? DEFAULT_GRACE_MS$1;
767
796
  const isTransient = options.isTransientError ?? (() => false);
@@ -786,7 +815,7 @@ function attachTokenLifecycle(options) {
786
815
  cancelTimer();
787
816
  const exp = target.tokens.accessExpiresAt;
788
817
  if (!exp) return;
789
- const delayMs = Math.max(0, exp - now() - graceMs);
818
+ const delayMs = Math.min(Math.max(0, exp - now() - graceMs), MAX_TIMER_DELAY_MS);
790
819
  options.onScheduled?.(delayMs, exp);
791
820
  timer = setTimer(() => {
792
821
  timer = void 0;
@@ -824,7 +853,13 @@ function attachTokenLifecycle(options) {
824
853
  skipped: true
825
854
  };
826
855
  }
827
- const tokens = await options.refresh(target, reason);
856
+ const livePhase = options.kernel.phase;
857
+ const base = (livePhase.kind === "online" ? livePhase.target : livePhase.kind === "reconnecting" ? livePhase.lastTarget : null) ?? target;
858
+ const refreshTarget = fresh ? {
859
+ ...base,
860
+ tokens: fresh
861
+ } : base;
862
+ const tokens = await options.refresh(refreshTarget, reason);
828
863
  if (detached) return {
829
864
  tokens,
830
865
  skipped: false
@@ -840,8 +875,11 @@ function attachTokenLifecycle(options) {
840
875
  });
841
876
  if (detached) return;
842
877
  transientRetries = 0;
843
- if (result.skipped) options.onRefreshSkipped?.(reason, result.tokens);
844
- else options.onRefreshSuccess?.(reason, result.tokens);
878
+ if (result.skipped) {
879
+ options.onRefreshSkipped?.(reason, result.tokens);
880
+ const p = options.kernel.phase;
881
+ if (p.kind === "online" && timer === void 0) schedule(p.target);
882
+ } else options.onRefreshSuccess?.(reason, result.tokens);
845
883
  } catch (err) {
846
884
  if (detached) return;
847
885
  const transient = isTransient(err);
@@ -41,6 +41,8 @@ function createNatsTransport(options = {}) {
41
41
  let statusAbort = null;
42
42
  let disposed = false;
43
43
  let connId = null;
44
+ let applyEpoch = 0;
45
+ let pendingConnectAbort = null;
44
46
  function buildServers(target) {
45
47
  const url = new URL(target.endpoint.url);
46
48
  const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
@@ -111,8 +113,9 @@ function createNatsTransport(options = {}) {
111
113
  }
112
114
  markDown(message);
113
115
  }
114
- async function rebuildClient(target) {
116
+ async function rebuildClient(target, epoch) {
115
117
  stopStatusMonitor();
118
+ pendingConnectAbort?.abort();
116
119
  if (proxy) {
117
120
  try {
118
121
  proxy.abortClose();
@@ -140,14 +143,25 @@ function createNatsTransport(options = {}) {
140
143
  maxPingOut,
141
144
  ignoreAuthErrorAbort: true
142
145
  });
146
+ const connectAbort = new AbortController();
147
+ pendingConnectAbort = connectAbort;
143
148
  try {
144
- await next.connect();
149
+ await next.connect({ signal: connectAbort.signal });
145
150
  } catch (err) {
146
151
  try {
147
152
  next.abortClose();
148
153
  } catch {}
154
+ if (disposed || epoch !== applyEpoch) return;
149
155
  markDown(err instanceof Error ? err.message : String(err));
150
156
  throw err;
157
+ } finally {
158
+ if (pendingConnectAbort === connectAbort) pendingConnectAbort = null;
159
+ }
160
+ if (disposed || epoch !== applyEpoch) {
161
+ try {
162
+ next.abortClose();
163
+ } catch {}
164
+ return;
151
165
  }
152
166
  proxy = next;
153
167
  markUp();
@@ -157,10 +171,13 @@ function createNatsTransport(options = {}) {
157
171
  async function apply(target) {
158
172
  if (disposed) throw new Error("nats-transport disposed");
159
173
  if (isSameTarget(currentTarget, target)) return;
174
+ const epoch = ++applyEpoch;
160
175
  const endpointChanged = isEndpointChange(currentTarget, target);
161
176
  currentTarget = target;
162
177
  if (!target) {
163
178
  stopStatusMonitor();
179
+ pendingConnectAbort?.abort();
180
+ pendingConnectAbort = null;
164
181
  if (proxy) {
165
182
  try {
166
183
  proxy.abortClose();
@@ -172,7 +189,12 @@ function createNatsTransport(options = {}) {
172
189
  return;
173
190
  }
174
191
  if (endpointChanged || !proxy) {
175
- await rebuildClient(target);
192
+ try {
193
+ await rebuildClient(target, epoch);
194
+ } catch (err) {
195
+ if (epoch === applyEpoch && currentTarget === target) currentTarget = null;
196
+ throw err;
197
+ }
176
198
  return;
177
199
  }
178
200
  const newServers = buildServers(target);
@@ -190,7 +212,10 @@ function createNatsTransport(options = {}) {
190
212
  }
191
213
  async function dispose() {
192
214
  disposed = true;
215
+ applyEpoch++;
193
216
  stopStatusMonitor();
217
+ pendingConnectAbort?.abort();
218
+ pendingConnectAbort = null;
194
219
  if (proxy) {
195
220
  try {
196
221
  proxy.abortClose();
@@ -203,11 +228,11 @@ function createNatsTransport(options = {}) {
203
228
  emitter.clear();
204
229
  }
205
230
  function getClient() {
206
- return proxy;
231
+ return status.up ? proxy : null;
207
232
  }
208
233
  function subscribeClient(listener) {
209
234
  clientListeners.add(listener);
210
- listener(proxy);
235
+ listener(status.up ? proxy : null);
211
236
  return () => clientListeners.delete(listener);
212
237
  }
213
238
  async function probeAlive(timeoutMs = 5e3) {
@@ -18,6 +18,7 @@ function createWsTransport(options = {}) {
18
18
  const WsCtor = options.webSocketCtor ?? (typeof WebSocket !== "undefined" ? WebSocket : void 0);
19
19
  const emitter = new TransportEmitter();
20
20
  const handles = /* @__PURE__ */ new Set();
21
+ const closeDelivered = /* @__PURE__ */ new WeakSet();
21
22
  let currentTarget = null;
22
23
  let status = { up: false };
23
24
  let disposed = false;
@@ -51,10 +52,14 @@ function createWsTransport(options = {}) {
51
52
  for (const fn of [...handle.listeners.open]) fn();
52
53
  };
53
54
  ws.onclose = (event) => {
54
- if (handle.ws === ws) {
55
+ const isCurrent = handle.ws === ws;
56
+ if (isCurrent) {
55
57
  handle.ws = null;
56
58
  handle.url = null;
57
59
  }
60
+ if (closeDelivered.has(ws)) return;
61
+ closeDelivered.add(ws);
62
+ if (!isCurrent) return;
58
63
  if (isAuthCloseEvent(event)) emitter.emit("auth-error", { message: event.reason || `ws close code ${event.code}` });
59
64
  const info = {
60
65
  code: event.code,
@@ -86,6 +91,8 @@ function createWsTransport(options = {}) {
86
91
  handle.ws = null;
87
92
  handle.url = null;
88
93
  }
94
+ if (closeDelivered.has(ws)) return;
95
+ closeDelivered.add(ws);
89
96
  const info = {
90
97
  code,
91
98
  reason,
package/dist/worker.js CHANGED
@@ -20,7 +20,7 @@ function createWorkerKernelMirror(options) {
20
20
  lastGeneration = msg.generation;
21
21
  const prev = current;
22
22
  current = msg.phase;
23
- if (prev === current) return;
23
+ if (phaseEquals(prev, current)) return;
24
24
  for (const l of [...listeners]) try {
25
25
  l(current, prev);
26
26
  } catch {}
@@ -31,6 +31,13 @@ function createWorkerKernelMirror(options) {
31
31
  if (p.kind === "reconnecting") return p.lastTarget;
32
32
  return null;
33
33
  }
34
+ function phaseEquals(a, b) {
35
+ if (a.kind !== b.kind) return false;
36
+ const ta = targetOf(a);
37
+ const tb = targetOf(b);
38
+ if (ta === null || tb === null) return ta === tb;
39
+ return ta.endpoint.url === tb.endpoint.url && ta.endpoint.mode === tb.endpoint.mode && ta.tokens.access === tb.tokens.access && ta.tokens.proxySession === tb.tokens.proxySession && ta.tokens.refresh === tb.tokens.refresh;
40
+ }
34
41
  return {
35
42
  get phase() {
36
43
  return current;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camera.ui/transport",
3
- "version": "0.0.13",
3
+ "version": "0.0.16",
4
4
  "description": "camera.ui transport layer — framework-agnostic connection kernel, reducer state, pluggable transports (HTTP/WS/Socket.IO/NATS), lifecycle effects and worker bridge",
5
5
  "author": "seydx (https://github.com/cameraui/clients)",
6
6
  "type": "module",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@camera.ui/logger": ">=0.0.3",
59
- "@camera.ui/rpc": ">=1.0.6",
59
+ "@camera.ui/rpc": ">=1.0.8",
60
60
  "axios": ">=1.18.1",
61
61
  "socket.io-client": ">=4.8.3"
62
62
  },
@@ -73,8 +73,8 @@
73
73
  "typescript": "5.9.3",
74
74
  "typescript-eslint": "^8.62.1",
75
75
  "unplugin-dts": "^1.0.3",
76
- "updates": "^17.18.1",
77
- "vite": "^8.1.2",
76
+ "updates": "^17.18.2",
77
+ "vite": "^8.1.3",
78
78
  "vitest": "^4.1.9"
79
79
  },
80
80
  "overrides": {