@camera.ui/transport 0.0.21 → 0.0.23

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.
@@ -0,0 +1,19 @@
1
+ export interface JournalEntry {
2
+ readonly seq: number;
3
+ readonly t: number;
4
+ readonly scope: string;
5
+ readonly msg: string;
6
+ readonly detail?: string;
7
+ }
8
+ export interface JournalOptions {
9
+ readonly capacity?: number;
10
+ readonly now?: () => number;
11
+ }
12
+ export interface ConnectionJournal {
13
+ record(scope: string, msg: string, detail?: unknown): void;
14
+ list(): JournalEntry[];
15
+ clear(): void;
16
+ subscribe(listener: (entry: JournalEntry) => void): () => void;
17
+ exportText(): string;
18
+ }
19
+ export declare function createConnectionJournal(options?: JournalOptions): ConnectionJournal;
package/dist/race.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface RaceFirstOptions {
9
9
  readonly timeoutByMode?: TimeoutByModeFn;
10
10
  readonly shortCircuit?: (err: unknown) => boolean;
11
11
  readonly parentSignal?: AbortSignal;
12
+ readonly prefer?: (endpoint: Endpoint) => boolean;
13
+ readonly preferGraceMs?: number;
12
14
  }
13
15
  export interface RaceFirstResult<T> {
14
16
  readonly endpoint: Endpoint;
@@ -0,0 +1,30 @@
1
+ import { Kernel } from './core/kernel.js';
2
+ import { TransportId } from './core/types.js';
3
+ import { Transport } from './transports/contract.js';
4
+ export type ConnectionSignal = {
5
+ readonly kind: 'connecting';
6
+ } | {
7
+ readonly kind: 'online';
8
+ } | {
9
+ readonly kind: 'degraded';
10
+ readonly channels: readonly TransportId[];
11
+ } | {
12
+ readonly kind: 'offline';
13
+ readonly retryAt?: number;
14
+ } | {
15
+ readonly kind: 'needs-auth';
16
+ };
17
+ export interface ConnectionSignalOptions {
18
+ readonly kernel: Kernel;
19
+ readonly transports: readonly Transport[];
20
+ readonly debounceMs?: number;
21
+ }
22
+ export interface ConnectionSignalHandle {
23
+ readonly current: ConnectionSignal;
24
+ raw(): ConnectionSignal;
25
+ recompute(): void;
26
+ subscribe(listener: (signal: ConnectionSignal) => void): () => void;
27
+ dispose(): void;
28
+ }
29
+ export declare function signalEquals(a: ConnectionSignal, b: ConnectionSignal): boolean;
30
+ export declare function createConnectionSignal(options: ConnectionSignalOptions): ConnectionSignalHandle;
@@ -7,6 +7,8 @@ export interface FakeTransportOptions {
7
7
  export declare class FakeTransport implements Transport {
8
8
  readonly spec: TransportSpec;
9
9
  readonly applyCalls: (ConnectionTarget | null)[];
10
+ failNextApplies: number;
11
+ ensureAliveCalls: number;
10
12
  private readonly emitter;
11
13
  private readonly applyDelayMs;
12
14
  private currentTarget;
@@ -15,6 +17,7 @@ export declare class FakeTransport implements Transport {
15
17
  constructor(options: FakeTransportOptions);
16
18
  apply(target: ConnectionTarget | null): Promise<void>;
17
19
  health(): TransportStatus;
20
+ ensureAlive(): Promise<TransportStatus>;
18
21
  on<E extends TransportEvent>(event: E, handler: TransportEventHandler<E>): Unsubscribe;
19
22
  dispose(): Promise<void>;
20
23
  emitDown(reason: string): void;
package/dist/testing.js CHANGED
@@ -1,8 +1,10 @@
1
- import { t as TransportEmitter } from "./contract-d-0gfY8v.js";
1
+ import { t as TransportEmitter } from "./contract-dt1m8U-o.js";
2
2
  //#region src/testing/fakeTransport.ts
3
3
  var FakeTransport = class {
4
4
  spec;
5
5
  applyCalls = [];
6
+ failNextApplies = 0;
7
+ ensureAliveCalls = 0;
6
8
  emitter = new TransportEmitter();
7
9
  applyDelayMs;
8
10
  currentTarget = null;
@@ -16,6 +18,14 @@ var FakeTransport = class {
16
18
  if (this.disposed) throw new Error("disposed");
17
19
  this.applyCalls.push(target);
18
20
  if (this.applyDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, this.applyDelayMs));
21
+ if (this.failNextApplies > 0) {
22
+ this.failNextApplies--;
23
+ this.currentStatus = {
24
+ up: false,
25
+ lastError: "apply failed"
26
+ };
27
+ throw new Error("apply failed");
28
+ }
19
29
  this.currentTarget = target;
20
30
  this.currentStatus = target ? { up: true } : { up: false };
21
31
  if (target) this.emitter.emit("up", void 0);
@@ -24,6 +34,10 @@ var FakeTransport = class {
24
34
  health() {
25
35
  return this.currentStatus;
26
36
  }
37
+ async ensureAlive() {
38
+ this.ensureAliveCalls++;
39
+ return this.currentStatus;
40
+ }
27
41
  on(event, handler) {
28
42
  return this.emitter.on(event, handler);
29
43
  }
@@ -0,0 +1,8 @@
1
+ export type CloseClass = 'auth-expired' | 'forbidden' | 'other';
2
+ export interface CloseLike {
3
+ readonly code?: number;
4
+ readonly wsCloseCode?: number;
5
+ readonly message?: string;
6
+ readonly reason?: string;
7
+ }
8
+ export declare function classifyClose(input: CloseLike | string): CloseClass;
@@ -12,6 +12,7 @@ export interface Transport {
12
12
  readonly spec: TransportSpec;
13
13
  apply(target: ConnectionTarget | null): Promise<void>;
14
14
  health(): TransportStatus;
15
+ ensureAlive?(): Promise<TransportStatus>;
15
16
  on<E extends TransportEvent>(event: E, handler: TransportEventHandler<E>): Unsubscribe;
16
17
  dispose(): Promise<void>;
17
18
  }
@@ -20,7 +21,6 @@ export interface PerResourceTransport<R = unknown, S = unknown> extends Transpor
20
21
  }
21
22
  export declare function isSameTarget(a: ConnectionTarget | null, b: ConnectionTarget | null): boolean;
22
23
  export declare function isEndpointChange(a: ConnectionTarget | null, b: ConnectionTarget | null): boolean;
23
- export declare function isTokenOnlyChange(a: ConnectionTarget | null, b: ConnectionTarget | null): boolean;
24
24
  export declare class TransportEmitter {
25
25
  private readonly listeners;
26
26
  on<E extends TransportEvent>(event: E, handler: TransportEventHandler<E>): Unsubscribe;
@@ -1,4 +1,4 @@
1
- import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-d-0gfY8v.js";
1
+ import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-dt1m8U-o.js";
2
2
  import axios from "axios";
3
3
  //#region src/transports/http.ts
4
4
  var HTTP_SPEC = {
@@ -1,6 +1,6 @@
1
1
  import { Logger } from '@camera.ui/logger';
2
2
  import { RPCClient } from '@camera.ui/rpc';
3
- import { TransportSpec } from '../core/types.js';
3
+ import { TransportSpec, TransportStatus } from '../core/types.js';
4
4
  import { Transport, Unsubscribe } from './contract.js';
5
5
  export interface NatsTransportOptions {
6
6
  readonly spec?: Partial<TransportSpec>;
@@ -24,5 +24,6 @@ export interface NatsTransport extends Transport {
24
24
  subscribeClient(listener: NatsClientListener): Unsubscribe;
25
25
  probeAlive(timeoutMs?: number): Promise<void>;
26
26
  forceReconnect(): Promise<void>;
27
+ ensureAlive(): Promise<TransportStatus>;
27
28
  }
28
29
  export declare function createNatsTransport(options?: NatsTransportOptions): NatsTransport;
@@ -1,11 +1,11 @@
1
- import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-d-0gfY8v.js";
1
+ import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-dt1m8U-o.js";
2
+ import { t as classifyClose } from "../closeCodes-ClWJVOpq.js";
2
3
  import { createRPCClient } from "@camera.ui/rpc";
3
4
  //#region src/transports/nats.ts
4
5
  var NATS_SPEC = {
5
6
  id: "nats",
6
7
  kind: "persistent",
7
- phaseGating: true,
8
- graceMs: 4e3
8
+ phaseGating: true
9
9
  };
10
10
  function newConnId() {
11
11
  const c = globalThis.crypto;
@@ -112,11 +112,23 @@ function createNatsTransport(options = {}) {
112
112
  }
113
113
  function handleErrorEvent(event) {
114
114
  const message = stringifyError(event.data);
115
- const lower = message.toLowerCase();
116
- if (lower.includes("auth") || lower.includes("401") || lower.includes("forbidden") || lower.includes("403")) {
115
+ if (message.toLowerCase().includes("permissions violation")) {
116
+ logger?.warn(`permission violation (connection stays up): ${message}`);
117
+ return;
118
+ }
119
+ const cls = classifyClose(typeof event.data === "object" && event.data !== null ? {
120
+ ...event.data,
121
+ message
122
+ } : message);
123
+ if (cls === "auth-expired") {
117
124
  emitter.emit("auth-error", { message });
118
125
  return;
119
126
  }
127
+ if (cls === "forbidden") {
128
+ logger?.warn(`forbidden by server: ${message}`);
129
+ markDown(`forbidden: ${message}`);
130
+ return;
131
+ }
120
132
  markDown(message);
121
133
  }
122
134
  async function rebuildClient(target, epoch) {
@@ -163,6 +175,7 @@ function createNatsTransport(options = {}) {
163
175
  if (disposed || epoch !== applyEpoch) return;
164
176
  const msg = err instanceof Error ? err.message : String(err);
165
177
  logger?.debug(`rebuildClient connect FAILED after ${Date.now() - t0}ms: ${msg}`);
178
+ if (classifyClose(err instanceof Error ? err : msg) === "auth-expired") emitter.emit("auth-error", { message: msg });
166
179
  markDown(msg);
167
180
  throw err;
168
181
  } finally {
@@ -268,6 +281,32 @@ function createNatsTransport(options = {}) {
268
281
  await proxy.forceReconnect();
269
282
  logger?.debug(`forceReconnect: returned after ${Date.now() - t0}ms (up=${status.up})`);
270
283
  }
284
+ let ensureInflight = null;
285
+ function ensureAlive() {
286
+ if (ensureInflight) return ensureInflight;
287
+ const run = async () => {
288
+ if (disposed || !currentTarget) return health();
289
+ if (!proxy) {
290
+ const epoch = ++applyEpoch;
291
+ try {
292
+ await rebuildClient(currentTarget, epoch);
293
+ } catch {}
294
+ return health();
295
+ }
296
+ if (health().up) try {
297
+ await probeAlive(3e3);
298
+ return health();
299
+ } catch {}
300
+ try {
301
+ await forceReconnect();
302
+ } catch {}
303
+ return health();
304
+ };
305
+ ensureInflight = run().finally(() => {
306
+ ensureInflight = null;
307
+ });
308
+ return ensureInflight;
309
+ }
271
310
  return {
272
311
  spec,
273
312
  apply,
@@ -277,7 +316,8 @@ function createNatsTransport(options = {}) {
277
316
  getClient,
278
317
  subscribeClient,
279
318
  probeAlive,
280
- forceReconnect
319
+ forceReconnect,
320
+ ensureAlive
281
321
  };
282
322
  }
283
323
  function stringifyError(value) {
@@ -1,6 +1,6 @@
1
1
  import { Manager, Socket } from 'socket.io-client';
2
2
  import { Logger } from '@camera.ui/logger';
3
- import { TransportSpec } from '../core/types.js';
3
+ import { TransportSpec, TransportStatus } from '../core/types.js';
4
4
  import { Transport } from './contract.js';
5
5
  export type { Socket };
6
6
  export interface SocketioTransportOptions {
@@ -11,6 +11,7 @@ export interface SocketioTransportOptions {
11
11
  readonly reconnectionDelay?: number;
12
12
  readonly reconnectionDelayMax?: number;
13
13
  readonly timeout?: number;
14
+ readonly staleAfterMs?: number;
14
15
  readonly logger?: Logger;
15
16
  }
16
17
  export interface SocketioTransport extends Transport {
@@ -18,5 +19,6 @@ export interface SocketioTransport extends Transport {
18
19
  socket(namespace?: string): Socket | null;
19
20
  ensureSocket(namespace: string): Socket | null;
20
21
  reviveDeadSockets(): void;
22
+ ensureAlive(): Promise<TransportStatus>;
21
23
  }
22
24
  export declare function createSocketioTransport(options?: SocketioTransportOptions): SocketioTransport;
@@ -1,11 +1,11 @@
1
- import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-d-0gfY8v.js";
1
+ import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-dt1m8U-o.js";
2
+ import { t as classifyClose } from "../closeCodes-ClWJVOpq.js";
2
3
  import { Manager, io } from "socket.io-client";
3
4
  //#region src/transports/socketio.ts
4
5
  var SOCKETIO_SPEC = {
5
6
  id: "socketio",
6
7
  kind: "persistent",
7
- phaseGating: true,
8
- graceMs: 4e3
8
+ phaseGating: true
9
9
  };
10
10
  function createSocketioTransport(options = {}) {
11
11
  const spec = {
@@ -25,6 +25,14 @@ function createSocketioTransport(options = {}) {
25
25
  let currentTarget = null;
26
26
  let status = { up: false };
27
27
  let disposed = false;
28
+ const staleAfterMs = options.staleAfterMs ?? 6e4;
29
+ let lastActivityAt = 0;
30
+ function touchActivity() {
31
+ lastActivityAt = Date.now();
32
+ }
33
+ function isStaleNow() {
34
+ return lastActivityAt > 0 && Date.now() - lastActivityAt > staleAfterMs;
35
+ }
28
36
  function buildAuth(target) {
29
37
  const auth = { token: `Bearer ${target.tokens.access}` };
30
38
  if (target.tokens.proxySession) auth.session = target.tokens.proxySession;
@@ -51,25 +59,7 @@ function createSocketioTransport(options = {}) {
51
59
  emitter.emit("down", { reason });
52
60
  }
53
61
  function isAuthError(msg) {
54
- const m = msg.toLowerCase();
55
- return m.includes("auth") || m.includes("unauthorized");
56
- }
57
- function bindCommonAuthEvents(socket) {
58
- let rotatedOut = false;
59
- socket.on("unauthenticated", () => {
60
- const fresh = currentTarget ? buildAuth(currentTarget) : null;
61
- const current = socket.auth?.token;
62
- if (fresh && current !== fresh.token) {
63
- socket.auth = fresh;
64
- rotatedOut = true;
65
- } else emitter.emit("auth-error", { message: "unauthenticated" });
66
- });
67
- socket.on("disconnect", () => {
68
- if (rotatedOut) {
69
- rotatedOut = false;
70
- socket.connect();
71
- }
72
- });
62
+ return classifyClose(msg) === "auth-expired";
73
63
  }
74
64
  function bindMainSocketEvents(socket) {
75
65
  socket.on("connect", () => markUp());
@@ -83,14 +73,12 @@ function createSocketioTransport(options = {}) {
83
73
  }
84
74
  markDown(msg);
85
75
  });
86
- bindCommonAuthEvents(socket);
87
76
  }
88
77
  function bindSecondarySocketEvents(socket) {
89
78
  socket.on("connect_error", (err) => {
90
79
  const msg = err?.message ?? "connect_error";
91
80
  if (isAuthError(msg)) emitter.emit("auth-error", { message: msg });
92
81
  });
93
- bindCommonAuthEvents(socket);
94
82
  }
95
83
  function socketOrigin(target) {
96
84
  return new URL(target.endpoint.url).origin;
@@ -125,7 +113,12 @@ function createSocketioTransport(options = {}) {
125
113
  rejectUnauthorized: false,
126
114
  transports: ["websocket"]
127
115
  });
128
- bindMainSocketEvents(openSocket(mainNs, target));
116
+ const main = openSocket(mainNs, target);
117
+ bindMainSocketEvents(main);
118
+ lastActivityAt = Date.now();
119
+ main.io.on("open", touchActivity);
120
+ main.io.on("ping", touchActivity);
121
+ main.io.on("packet", touchActivity);
129
122
  }
130
123
  function closeAllSockets() {
131
124
  for (const sock of sockets.values()) {
@@ -152,6 +145,7 @@ function createSocketioTransport(options = {}) {
152
145
  currentTarget = target;
153
146
  if (!target) {
154
147
  closeAllSockets();
148
+ lastActivityAt = 0;
155
149
  markDown("detached");
156
150
  return;
157
151
  }
@@ -162,6 +156,10 @@ function createSocketioTransport(options = {}) {
162
156
  rebindAuth(target);
163
157
  }
164
158
  function health() {
159
+ if (status.up && isStaleNow()) return {
160
+ up: false,
161
+ lastError: "staleConnection"
162
+ };
165
163
  return status;
166
164
  }
167
165
  function on(event, handler) {
@@ -188,6 +186,12 @@ function createSocketioTransport(options = {}) {
188
186
  }
189
187
  function reviveDeadSockets() {
190
188
  if (!currentTarget) return;
189
+ if (status.up && isStaleNow()) {
190
+ logger?.debug("reviveDeadSockets: engine stale — forcing reconnect");
191
+ lastActivityAt = Date.now();
192
+ sockets.get(mainNs)?.io.engine?.close();
193
+ return;
194
+ }
191
195
  const auth = buildAuth(currentTarget);
192
196
  let revived = 0;
193
197
  for (const sock of sockets.values()) if (!sock.connected) {
@@ -197,6 +201,11 @@ function createSocketioTransport(options = {}) {
197
201
  }
198
202
  logger?.debug(`reviveDeadSockets: ${revived}/${sockets.size} reconnecting`);
199
203
  }
204
+ async function ensureAlive() {
205
+ if (disposed || !currentTarget) return health();
206
+ reviveDeadSockets();
207
+ return health();
208
+ }
200
209
  return {
201
210
  get spec() {
202
211
  return spec;
@@ -210,7 +219,8 @@ function createSocketioTransport(options = {}) {
210
219
  dispose,
211
220
  socket,
212
221
  ensureSocket,
213
- reviveDeadSockets
222
+ reviveDeadSockets,
223
+ ensureAlive
214
224
  };
215
225
  }
216
226
  //#endregion
@@ -1,4 +1,5 @@
1
- import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-d-0gfY8v.js";
1
+ import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "../contract-dt1m8U-o.js";
2
+ import { t as classifyClose } from "../closeCodes-ClWJVOpq.js";
2
3
  //#region src/transports/ws.ts
3
4
  var WS_SPEC = {
4
5
  id: "ws",
@@ -39,10 +40,10 @@ function createWsTransport(options = {}) {
39
40
  return `${wsProtocol}//${base.hostname}:${port}${prefix}${handleSpec.path}?${params.toString()}`;
40
41
  }
41
42
  function isAuthCloseEvent(event) {
42
- if (event.code === 1008 || event.code === 4401) return true;
43
- if (!event.reason) return false;
44
- const lower = event.reason.toLowerCase();
45
- return lower.includes("unauthorized") || lower.includes("forbidden") || lower.includes("401") || lower.includes("403");
43
+ return classifyClose({
44
+ code: event.code,
45
+ reason: event.reason
46
+ }) === "auth-expired";
46
47
  }
47
48
  function bindWs(handle, ws) {
48
49
  ws.onopen = () => {
package/dist/worker.js CHANGED
@@ -28,7 +28,6 @@ function createWorkerKernelMirror(options) {
28
28
  options.source.addEventListener("message", handler);
29
29
  function targetOf(p) {
30
30
  if (p.kind === "online") return p.target;
31
- if (p.kind === "reconnecting") return p.lastTarget;
32
31
  return null;
33
32
  }
34
33
  function phaseEquals(a, b) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camera.ui/transport",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
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.9",
59
+ "@camera.ui/rpc": ">=1.0.10",
60
60
  "axios": ">=1.18.1",
61
61
  "socket.io-client": ">=4.8.3"
62
62
  },
@@ -64,7 +64,7 @@
64
64
  "@eslint/js": "9.39.4",
65
65
  "@stylistic/eslint-plugin": "^5.10.0",
66
66
  "@typescript-eslint/parser": "^8.65.0",
67
- "@vue/language-core": "^3.3.7",
67
+ "@vue/language-core": "^3.3.8",
68
68
  "eslint": "9.39.2",
69
69
  "globals": "^17.7.0",
70
70
  "jiti": "^2.7.0",
@@ -1,5 +0,0 @@
1
- import { Endpoint, EndpointMode } from './types.js';
2
- export declare function sortByPriority(endpoints: readonly Endpoint[]): Endpoint[];
3
- export declare function isSameEndpoint(a: Endpoint, b: Endpoint): boolean;
4
- export declare function endpointKey(ep: Endpoint): string;
5
- export type { EndpointMode };
@@ -1,10 +0,0 @@
1
- import { Kernel } from '../core/kernel.js';
2
- export type Detach = () => void;
3
- export interface ReconnectWatchdogOptions {
4
- readonly kernel: Kernel;
5
- readonly escalateAfterMs?: number;
6
- readonly setTimer?: (cb: () => void, ms: number) => unknown;
7
- readonly clearTimer?: (handle: unknown) => void;
8
- readonly onEscalate?: (attempt: number) => void;
9
- }
10
- export declare function attachReconnectWatchdog(options: ReconnectWatchdogOptions): Detach;
@@ -1,16 +0,0 @@
1
- import { Kernel } from '../core/kernel.js';
2
- import { TransportId } from '../core/types.js';
3
- import { Transport } from '../transports/contract.js';
4
- export type Detach = () => void;
5
- export type WatchdogClearReason = 'up' | 'detach' | 'phase-change';
6
- export interface TransportWatchdogOptions {
7
- readonly kernel: Kernel;
8
- readonly transports: readonly Transport[];
9
- readonly defaultGraceMs?: number;
10
- readonly setTimer?: (cb: () => void, ms: number) => unknown;
11
- readonly clearTimer?: (handle: unknown) => void;
12
- readonly onGraceStarted?: (id: TransportId, graceMs: number) => void;
13
- readonly onGraceCleared?: (id: TransportId, reason: WatchdogClearReason) => void;
14
- readonly onConfirmed?: (id: TransportId) => void;
15
- }
16
- export declare function attachTransportWatchdog(options: TransportWatchdogOptions): Detach;