@camera.ui/transport 0.0.25 → 0.0.27

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.
@@ -2,7 +2,7 @@ import { Kernel } from '../core/kernel.js';
2
2
  import { ConnectionTarget, Endpoint, Tokens } from '../core/types.js';
3
3
  import { TimeoutByModeFn } from '../race.js';
4
4
  import { ProbeContext } from './probeLoop.js';
5
- export type BackgroundProbeOutcome = 'swap' | 'same' | 'failed' | 'skipped';
5
+ export type BackgroundProbeOutcome = 'swap' | 'same' | 'kept' | 'failed' | 'skipped';
6
6
  export interface BackgroundProbeOptions {
7
7
  readonly kernel: Kernel;
8
8
  readonly discover: (signal: AbortSignal) => Promise<readonly Endpoint[]>;
@@ -11,6 +11,7 @@ export interface BackgroundProbeOptions {
11
11
  readonly timeoutByMode?: TimeoutByModeFn;
12
12
  readonly prefer?: (endpoint: Endpoint) => boolean;
13
13
  readonly preferGraceMs?: number;
14
+ readonly isCurrentHealthy?: () => boolean;
14
15
  readonly onResult?: (outcome: BackgroundProbeOutcome, detail?: string) => void;
15
16
  }
16
17
  export interface BackgroundProbe {
@@ -1,10 +1,17 @@
1
1
  import { Kernel } from '../core/kernel.js';
2
+ import { ConnectionTarget } from '../core/types.js';
2
3
  import { WorkerHost } from '../worker/protocol.js';
3
4
  export type Detach = () => void;
5
+ export interface BridgeResolveContext {
6
+ readonly target: ConnectionTarget;
7
+ readonly connId: string;
8
+ readonly defaultServers: readonly string[];
9
+ }
4
10
  export interface WorkerBridgeOptions {
5
11
  readonly kernel: Kernel;
6
- readonly hosts: () => Iterable<WorkerHost>;
7
12
  readonly listenForResyncRequests?: boolean;
13
+ readonly hosts: () => Iterable<WorkerHost>;
14
+ readonly resolveServers?: (ctx: BridgeResolveContext) => Promise<string[]>;
8
15
  readonly onBroadcast?: (generation: number, hostCount: number) => void;
9
16
  readonly onSyncHost?: (generation: number) => void;
10
17
  }
package/dist/index.d.ts CHANGED
@@ -28,10 +28,10 @@ export type { PresenceCallback, PresenceOptions, VisibilitySource } from './effe
28
28
  export type { ProbeContext, ProbeFailure, ProbeFailureKind, ProbeLoopOptions } from './effects/probeLoop.js';
29
29
  export type { Detach, RefreshReason, TokenLifecycle, TokenLifecycleOptions } from './effects/tokenLifecycle.js';
30
30
  export type { TransportSyncOptions } from './effects/transportSync.js';
31
- export type { WorkerBridge, WorkerBridgeOptions } from './effects/workerBridge.js';
31
+ export type { BridgeResolveContext, WorkerBridge, WorkerBridgeOptions } from './effects/workerBridge.js';
32
32
  export type { ConnectionJournal, JournalEntry, JournalOptions } from './journal.js';
33
33
  export type { RaceCandidate, RaceFirstOptions, RaceFirstResult, TimeoutByModeFn } from './race.js';
34
34
  export type { ConnectionSignal, ConnectionSignalHandle, ConnectionSignalOptions } from './signal.js';
35
35
  export type { CloseClass, CloseLike } from './transports/closeCodes.js';
36
36
  export type { PerResourceTransport, Transport, TransportEvent, TransportEventHandler, TransportEventPayload } from './transports/contract.js';
37
- export type { KernelSyncMessage, KernelSyncRequestMessage, MessageSource, WorkerHost, WorkerMessage } from './worker/protocol.js';
37
+ export type { KernelResolveReplyMessage, KernelResolveRequestMessage, KernelSyncMessage, KernelSyncRequestMessage, MessageSource, WorkerHost, WorkerMessage, } from './worker/protocol.js';
package/dist/index.js CHANGED
@@ -331,7 +331,12 @@ function createBackgroundProbe(options) {
331
331
  options.onResult?.("skipped", `phase=${phase.kind}`);
332
332
  return "skipped";
333
333
  }
334
- const same = phase.target.endpoint.url === endpoint.url && phase.target.endpoint.mode === endpoint.mode;
334
+ const current = phase.target.endpoint;
335
+ const same = current.url === endpoint.url && current.mode === endpoint.mode;
336
+ if (!same && options.prefer && options.prefer(current) && !options.prefer(endpoint) && options.isCurrentHealthy?.() === true) {
337
+ options.onResult?.("kept", current.url);
338
+ return "kept";
339
+ }
335
340
  options.kernel.dispatch({
336
341
  type: "ENDPOINT_SWAP",
337
342
  endpoint,
@@ -780,8 +785,10 @@ function attachProbeLoop(options) {
780
785
  return tokens;
781
786
  } catch (err) {
782
787
  let finalErr = err;
783
- if (signal.aborted && !ctrl.signal.aborted && !isProbeFailure(err)) if (signal.reason === "race-settled") finalErr = makeProbeFailure("transient", "superseded");
784
- else finalErr = makeProbeFailure("transient", `timeout (${(options.timeoutByMode ?? ((m) => DEFAULT_RACE_TIMEOUT_BY_MODE[m] ?? 5e3))(endpoint.mode)}ms)`);
788
+ if (signal.aborted && !ctrl.signal.aborted && !isProbeFailure(err)) {
789
+ if (signal.reason === "race-settled") finalErr = makeProbeFailure("transient", "superseded");
790
+ else finalErr = makeProbeFailure("transient", `timeout (${(options.timeoutByMode ?? ((m) => DEFAULT_RACE_TIMEOUT_BY_MODE[m] ?? 5e3))(endpoint.mode)}ms)`);
791
+ }
785
792
  options.onProbeError?.(endpoint, finalErr);
786
793
  throw finalErr;
787
794
  }
@@ -1119,7 +1126,8 @@ function attachWorkerBridge(options) {
1119
1126
  return {
1120
1127
  type: "kernel-sync",
1121
1128
  generation,
1122
- phase
1129
+ phase,
1130
+ resolver: options.resolveServers !== void 0
1123
1131
  };
1124
1132
  }
1125
1133
  function broadcast(phase) {
@@ -1153,13 +1161,46 @@ function attachWorkerBridge(options) {
1153
1161
  });
1154
1162
  }
1155
1163
  }
1164
+ async function answerResolve(host, req) {
1165
+ const reply = (msg) => {
1166
+ try {
1167
+ host.postMessage({
1168
+ type: "kernel-resolve-reply",
1169
+ id: req.id,
1170
+ ...msg
1171
+ });
1172
+ } catch (err) {
1173
+ log.warn("resolve reply postMessage failed", err);
1174
+ }
1175
+ };
1176
+ if (!options.resolveServers) {
1177
+ reply({ servers: [...req.defaultServers] });
1178
+ return;
1179
+ }
1180
+ const phase = options.kernel.phase;
1181
+ if (phase.kind !== "online") {
1182
+ reply({ error: `kernel ${phase.kind}` });
1183
+ return;
1184
+ }
1185
+ try {
1186
+ reply({ servers: await options.resolveServers({
1187
+ target: phase.target,
1188
+ connId: req.connId,
1189
+ defaultServers: req.defaultServers
1190
+ }) });
1191
+ } catch (err) {
1192
+ reply({ error: err instanceof Error ? err.message : String(err) });
1193
+ }
1194
+ }
1156
1195
  function maybeAttachHostListener(host) {
1157
- if (!options.listenForResyncRequests) return;
1196
+ if (!options.listenForResyncRequests && !options.resolveServers) return;
1158
1197
  if (!host.addEventListener) return;
1159
1198
  if (hostListenerCleanups.has(host)) return;
1160
1199
  const listener = (event) => {
1161
1200
  if (detached) return;
1162
- if (event.data?.type === "kernel-sync-request") syncOne(host);
1201
+ const msg = event.data;
1202
+ if (msg?.type === "kernel-sync-request" && options.listenForResyncRequests) syncOne(host);
1203
+ else if (msg?.type === "kernel-resolve-request") answerResolve(host, msg);
1163
1204
  };
1164
1205
  host.addEventListener("message", listener);
1165
1206
  hostListenerCleanups.set(host, () => {
@@ -1,12 +1,14 @@
1
1
  import { Logger } from '@camera.ui/logger';
2
- import { AxiosInstance, AxiosRequestConfig } from 'axios';
3
- import { TransportSpec } from '../core/types.js';
2
+ import { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
3
+ import { ConnectionTarget, TransportSpec } from '../core/types.js';
4
4
  import { Transport } from './contract.js';
5
+ export type HttpAuthorizer = (config: InternalAxiosRequestConfig, target: ConnectionTarget) => void | Promise<void>;
5
6
  export interface HttpTransportOptions {
6
7
  readonly apiPrefix?: string;
7
8
  readonly timeoutMs?: number;
8
9
  readonly targetWaitMs?: number;
9
10
  readonly spec?: Partial<TransportSpec>;
11
+ readonly authorize?: HttpAuthorizer;
10
12
  readonly logger?: Logger;
11
13
  }
12
14
  export interface HttpTransport extends Transport {
@@ -57,7 +57,8 @@ function createHttpTransport(options = {}) {
57
57
  const target = currentTarget;
58
58
  if (!target) throw new axios.Cancel("http-transport: no target");
59
59
  if (!config.baseURL) config.baseURL = `${target.endpoint.url}${apiPrefix}`;
60
- config.headers.set("Authorization", `Bearer ${target.tokens.access}`);
60
+ if (options.authorize) await options.authorize(config, target);
61
+ else config.headers.set("Authorization", `Bearer ${target.tokens.access}`);
61
62
  if (target.tokens.proxySession) config.headers.set("X-Proxy-Session", target.tokens.proxySession);
62
63
  return config;
63
64
  });
@@ -1,10 +1,17 @@
1
1
  import { Logger } from '@camera.ui/logger';
2
2
  import { RPCClient } from '@camera.ui/rpc';
3
- import { TransportSpec, TransportStatus } from '../core/types.js';
3
+ import { ConnectionTarget, TransportSpec, TransportStatus } from '../core/types.js';
4
4
  import { Transport, Unsubscribe } from './contract.js';
5
+ export interface ResolveServersContext {
6
+ readonly target: ConnectionTarget;
7
+ readonly connId: string;
8
+ readonly defaultServers: readonly string[];
9
+ }
10
+ export type ServersResolver = (ctx: ResolveServersContext) => Promise<string[]>;
5
11
  export interface NatsTransportOptions {
6
12
  readonly spec?: Partial<TransportSpec>;
7
13
  readonly proxyPath?: string;
14
+ readonly resolveRefreshMs?: number;
8
15
  readonly clientName?: string;
9
16
  readonly natsUser?: string;
10
17
  readonly natsPassword?: string;
@@ -17,6 +24,7 @@ export interface NatsTransportOptions {
17
24
  readonly pingTimeout?: number;
18
25
  readonly maxPingOut?: number;
19
26
  readonly logger?: Logger;
27
+ readonly resolveServers?: ServersResolver;
20
28
  }
21
29
  export type NatsClientListener = (client: RPCClient | null) => void;
22
30
  export interface NatsTransport extends Transport {
@@ -35,8 +35,11 @@ function createNatsTransport(options = {}) {
35
35
  const pingTimeout = options.pingTimeout ?? 2e4;
36
36
  const maxPingOut = options.maxPingOut ?? 1;
37
37
  const logger = options.logger;
38
+ const resolveServers = options.resolveServers;
39
+ const resolveRefreshMs = options.resolveRefreshMs ?? 0;
38
40
  const emitter = new TransportEmitter();
39
41
  const clientListeners = /* @__PURE__ */ new Set();
42
+ let refreshTimer = null;
40
43
  let proxy = null;
41
44
  let currentTarget = null;
42
45
  let status = { up: false };
@@ -55,6 +58,53 @@ function createNatsTransport(options = {}) {
55
58
  if (connId) params.set("connId", connId);
56
59
  return [`${wsProtocol}//${url.hostname}:${port}${prefix}${proxyPath}?${params.toString()}`];
57
60
  }
61
+ async function serversFor(target) {
62
+ const defaultServers = buildServers(target);
63
+ if (!resolveServers) return defaultServers;
64
+ return resolveServers({
65
+ target,
66
+ connId: connId ?? "",
67
+ defaultServers
68
+ });
69
+ }
70
+ async function refreshPool(p) {
71
+ const target = currentTarget;
72
+ if (!resolveServers || !target || disposed) return;
73
+ try {
74
+ const servers = await serversFor(target);
75
+ if (proxy === p && !disposed) p.setServers(servers);
76
+ } catch (err) {
77
+ logger?.warn(`server refresh failed: ${stringifyError(err)}`);
78
+ }
79
+ }
80
+ function stopRefresh() {
81
+ if (refreshTimer) {
82
+ clearInterval(refreshTimer);
83
+ refreshTimer = null;
84
+ }
85
+ }
86
+ function startRefresh(p) {
87
+ stopRefresh();
88
+ if (!resolveServers || resolveRefreshMs <= 0) return;
89
+ refreshTimer = setInterval(() => void refreshPool(p), resolveRefreshMs);
90
+ }
91
+ async function resign(reason) {
92
+ const target = currentTarget;
93
+ if (!proxy || !target || disposed) return;
94
+ logger?.debug(`resign (${reason})`);
95
+ const epoch = ++applyEpoch;
96
+ stopStatusMonitor();
97
+ stopRefresh();
98
+ try {
99
+ proxy.abortClose();
100
+ } catch {}
101
+ proxy = null;
102
+ notifyClient();
103
+ markDown(`signature-expired: ${reason}`);
104
+ try {
105
+ await rebuildClient(target, epoch);
106
+ } catch {}
107
+ }
58
108
  function notifyClient() {
59
109
  const next = status.up ? proxy : null;
60
110
  for (const fn of [...clientListeners]) try {
@@ -98,14 +148,13 @@ function createNatsTransport(options = {}) {
98
148
  case "disconnect":
99
149
  markDown("disconnect");
100
150
  notifyClient();
151
+ if (resolveServers) refreshPool(p);
101
152
  break;
102
153
  case "staleConnection":
103
154
  markDown("staleConnection");
104
155
  notifyClient();
105
156
  break;
106
- case "error":
107
- handleErrorEvent(event);
108
- break;
157
+ case "error": handleErrorEvent(event);
109
158
  }
110
159
  }
111
160
  })().catch(() => {});
@@ -121,6 +170,10 @@ function createNatsTransport(options = {}) {
121
170
  message
122
171
  } : message);
123
172
  if (cls === "auth-expired") {
173
+ if (resolveServers) {
174
+ resign(message);
175
+ return;
176
+ }
124
177
  emitter.emit("auth-error", { message });
125
178
  return;
126
179
  }
@@ -135,6 +188,7 @@ function createNatsTransport(options = {}) {
135
188
  logger?.debug(`rebuildClient start (epoch=${epoch})`);
136
189
  const t0 = Date.now();
137
190
  stopStatusMonitor();
191
+ stopRefresh();
138
192
  pendingConnectAbort?.abort();
139
193
  if (proxy) {
140
194
  try {
@@ -144,8 +198,19 @@ function createNatsTransport(options = {}) {
144
198
  notifyClient();
145
199
  }
146
200
  connId = newConnId();
201
+ let servers;
202
+ try {
203
+ servers = await serversFor(target);
204
+ } catch (err) {
205
+ if (disposed || epoch !== applyEpoch) return;
206
+ const msg = stringifyError(err);
207
+ logger?.debug(`rebuildClient resolve FAILED: ${msg}`);
208
+ markDown(msg);
209
+ throw err;
210
+ }
211
+ if (disposed || epoch !== applyEpoch) return;
147
212
  const next = createRPCClient({
148
- servers: buildServers(target),
213
+ servers,
149
214
  name: clientName,
150
215
  connId,
151
216
  auth: {
@@ -175,7 +240,7 @@ function createNatsTransport(options = {}) {
175
240
  if (disposed || epoch !== applyEpoch) return;
176
241
  const msg = err instanceof Error ? err.message : String(err);
177
242
  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 });
243
+ if (!resolveServers && classifyClose(err instanceof Error ? err : msg) === "auth-expired") emitter.emit("auth-error", { message: msg });
179
244
  markDown(msg);
180
245
  throw err;
181
246
  } finally {
@@ -192,6 +257,7 @@ function createNatsTransport(options = {}) {
192
257
  markUp();
193
258
  notifyClient();
194
259
  startStatusMonitor(next);
260
+ startRefresh(next);
195
261
  }
196
262
  async function apply(target) {
197
263
  if (disposed) throw new Error("nats-transport disposed");
@@ -201,6 +267,7 @@ function createNatsTransport(options = {}) {
201
267
  currentTarget = target;
202
268
  if (!target) {
203
269
  stopStatusMonitor();
270
+ stopRefresh();
204
271
  pendingConnectAbort?.abort();
205
272
  pendingConnectAbort = null;
206
273
  if (proxy) {
@@ -222,8 +289,9 @@ function createNatsTransport(options = {}) {
222
289
  }
223
290
  return;
224
291
  }
225
- const newServers = buildServers(target);
226
- proxy.setServers(newServers);
292
+ const p = proxy;
293
+ const newServers = await serversFor(target);
294
+ if (proxy === p && epoch === applyEpoch) p.setServers(newServers);
227
295
  }
228
296
  function health() {
229
297
  if (status.up && proxy?.isStale) return {
@@ -239,6 +307,7 @@ function createNatsTransport(options = {}) {
239
307
  disposed = true;
240
308
  applyEpoch++;
241
309
  stopStatusMonitor();
310
+ stopRefresh();
242
311
  pendingConnectAbort?.abort();
243
312
  pendingConnectAbort = null;
244
313
  if (proxy) {
@@ -1,11 +1,19 @@
1
1
  import { Manager, Socket } from 'socket.io-client';
2
2
  import { Logger } from '@camera.ui/logger';
3
- import { TransportSpec, TransportStatus } from '../core/types.js';
3
+ import { ConnectionTarget, TransportSpec, TransportStatus } from '../core/types.js';
4
4
  import { Transport } from './contract.js';
5
5
  export type { Socket };
6
+ export interface ResolveSocketQueryContext {
7
+ readonly target: ConnectionTarget;
8
+ readonly path: string;
9
+ readonly query: Record<string, string>;
10
+ }
11
+ export type SocketQueryResolver = (ctx: ResolveSocketQueryContext) => Promise<Record<string, string>>;
6
12
  export interface SocketioTransportOptions {
7
13
  readonly spec?: Partial<TransportSpec>;
8
14
  readonly path?: string;
15
+ readonly resolveQuery?: SocketQueryResolver;
16
+ readonly resolveRefreshMs?: number;
9
17
  readonly mainNamespace?: string;
10
18
  readonly reconnection?: boolean;
11
19
  readonly reconnectionDelay?: number;
@@ -19,10 +19,14 @@ function createSocketioTransport(options = {}) {
19
19
  const reconnectionDelayMax = options.reconnectionDelayMax ?? 5e3;
20
20
  const timeout = options.timeout ?? 2e4;
21
21
  const logger = options.logger;
22
+ const resolveQuery = options.resolveQuery;
23
+ const resolveRefreshMs = options.resolveRefreshMs ?? 0;
22
24
  const emitter = new TransportEmitter();
23
25
  const sockets = /* @__PURE__ */ new Map();
24
26
  let manager = null;
25
27
  let currentTarget = null;
28
+ let currentQuery = {};
29
+ let refreshTimer = null;
26
30
  let status = { up: false };
27
31
  let disposed = false;
28
32
  const staleAfterMs = options.staleAfterMs ?? 6e4;
@@ -43,6 +47,38 @@ function createSocketioTransport(options = {}) {
43
47
  if (target.tokens.proxySession) q.session = target.tokens.proxySession;
44
48
  return q;
45
49
  }
50
+ async function queryFor(target) {
51
+ const base = buildQuery(target);
52
+ if (!resolveQuery) return base;
53
+ return resolveQuery({
54
+ target,
55
+ path: socketPath(target),
56
+ query: base
57
+ });
58
+ }
59
+ async function refreshQuery() {
60
+ const target = currentTarget;
61
+ if (!resolveQuery || !target || disposed) return;
62
+ try {
63
+ const query = await queryFor(target);
64
+ if (currentTarget !== target || disposed) return;
65
+ currentQuery = query;
66
+ for (const sock of sockets.values()) sock.io.opts.query = query;
67
+ } catch (err) {
68
+ logger?.warn(`query refresh failed: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ }
71
+ function stopRefresh() {
72
+ if (refreshTimer) {
73
+ clearInterval(refreshTimer);
74
+ refreshTimer = null;
75
+ }
76
+ }
77
+ function startRefresh() {
78
+ stopRefresh();
79
+ if (!resolveQuery || resolveRefreshMs <= 0) return;
80
+ refreshTimer = setInterval(() => void refreshQuery(), resolveRefreshMs);
81
+ }
46
82
  function markUp() {
47
83
  if (status.up) return;
48
84
  status = { up: true };
@@ -63,7 +99,10 @@ function createSocketioTransport(options = {}) {
63
99
  }
64
100
  function bindMainSocketEvents(socket) {
65
101
  socket.on("connect", () => markUp());
66
- socket.on("disconnect", (reason) => markDown(reason));
102
+ socket.on("disconnect", (reason) => {
103
+ markDown(reason);
104
+ if (resolveQuery) refreshQuery();
105
+ });
67
106
  socket.on("connect_error", (err) => {
68
107
  const msg = err?.message ?? "connect_error";
69
108
  logger?.debug(`connect_error (main): ${msg}`);
@@ -87,10 +126,11 @@ function createSocketioTransport(options = {}) {
87
126
  return `${new URL(target.endpoint.url).pathname.replace(/\/$/, "")}${path}`;
88
127
  }
89
128
  function openSocket(namespace, target) {
90
- const sock = io(`${socketOrigin(target)}${namespace}`, {
129
+ const url = `${socketOrigin(target)}${namespace}`;
130
+ const sock = io(url, {
91
131
  path: socketPath(target),
92
132
  auth: buildAuth(target),
93
- query: buildQuery(target),
133
+ query: currentQuery,
94
134
  reconnection,
95
135
  reconnectionDelay,
96
136
  reconnectionDelayMax,
@@ -101,8 +141,18 @@ function createSocketioTransport(options = {}) {
101
141
  sockets.set(namespace, sock);
102
142
  return sock;
103
143
  }
104
- function rebuildManager(target) {
144
+ async function rebuildManager(target) {
145
+ stopRefresh();
146
+ let query;
147
+ try {
148
+ query = await queryFor(target);
149
+ } catch (err) {
150
+ markDown(err instanceof Error ? err.message : String(err));
151
+ throw err;
152
+ }
153
+ if (currentTarget !== target || disposed) return;
105
154
  closeAllSockets();
155
+ currentQuery = query;
106
156
  manager = new Manager(socketOrigin(target), {
107
157
  path: socketPath(target),
108
158
  autoConnect: false,
@@ -115,6 +165,7 @@ function createSocketioTransport(options = {}) {
115
165
  });
116
166
  const main = openSocket(mainNs, target);
117
167
  bindMainSocketEvents(main);
168
+ startRefresh();
118
169
  lastActivityAt = Date.now();
119
170
  main.io.on("open", touchActivity);
120
171
  main.io.on("ping", touchActivity);
@@ -145,12 +196,13 @@ function createSocketioTransport(options = {}) {
145
196
  currentTarget = target;
146
197
  if (!target) {
147
198
  closeAllSockets();
199
+ stopRefresh();
148
200
  lastActivityAt = 0;
149
201
  markDown("detached");
150
202
  return;
151
203
  }
152
204
  if (endpointChanged || !manager) {
153
- rebuildManager(target);
205
+ await rebuildManager(target);
154
206
  return;
155
207
  }
156
208
  rebindAuth(target);
@@ -168,6 +220,7 @@ function createSocketioTransport(options = {}) {
168
220
  async function dispose() {
169
221
  disposed = true;
170
222
  closeAllSockets();
223
+ stopRefresh();
171
224
  currentTarget = null;
172
225
  status = { up: false };
173
226
  emitter.clear();
@@ -176,7 +229,7 @@ function createSocketioTransport(options = {}) {
176
229
  return sockets.get(namespace) ?? null;
177
230
  }
178
231
  function ensureSocket(namespace) {
179
- if (!currentTarget) return null;
232
+ if (!currentTarget || !manager) return null;
180
233
  let sock = sockets.get(namespace);
181
234
  if (!sock) {
182
235
  sock = openSocket(namespace, currentTarget);
@@ -203,6 +256,7 @@ function createSocketioTransport(options = {}) {
203
256
  }
204
257
  async function ensureAlive() {
205
258
  if (disposed || !currentTarget) return health();
259
+ if (resolveQuery) await refreshQuery();
206
260
  reviveDeadSockets();
207
261
  return health();
208
262
  }
@@ -1,5 +1,5 @@
1
1
  import { Logger } from '@camera.ui/logger';
2
- import { TransportSpec } from '../core/types.js';
2
+ import { ConnectionTarget, TransportSpec } from '../core/types.js';
3
3
  import { PerResourceTransport, Unsubscribe } from './contract.js';
4
4
  export interface WsHandleSpec {
5
5
  readonly path: string;
@@ -23,12 +23,19 @@ export interface WsHandle {
23
23
  on<E extends WsEvent>(event: E, handler: WsEventHandler<E>): Unsubscribe;
24
24
  dispose(): void;
25
25
  }
26
+ export interface ResolveWsUrlContext {
27
+ readonly target: ConnectionTarget;
28
+ readonly spec: WsHandleSpec;
29
+ readonly defaultUrl: string;
30
+ }
31
+ export type WsUrlResolver = (ctx: ResolveWsUrlContext) => Promise<string>;
26
32
  export interface WsTransportOptions {
27
33
  readonly spec?: Partial<TransportSpec>;
28
34
  readonly webSocketCtor?: typeof WebSocket;
29
35
  readonly tokenParam?: string;
30
36
  readonly sessionParam?: string;
31
37
  readonly logger?: Logger;
38
+ readonly resolveUrl?: WsUrlResolver;
32
39
  }
33
40
  export interface WsTransport extends PerResourceTransport<WsHandle, WsHandleSpec> {
34
41
  readonly handleCount: number;
@@ -9,6 +9,7 @@ var WS_SPEC = {
9
9
  var WS_CLOSE_TARGET_CHANGED = 4e3;
10
10
  var WS_CLOSE_DETACHED = 4001;
11
11
  var WS_CLOSE_DISPOSED = 4002;
12
+ var WS_CLOSE_RESOLVE_FAILED = 4003;
12
13
  function createWsTransport(options = {}) {
13
14
  const spec = {
14
15
  ...WS_SPEC,
@@ -17,6 +18,7 @@ function createWsTransport(options = {}) {
17
18
  const tokenParam = options.tokenParam ?? "token";
18
19
  const sessionParam = options.sessionParam ?? "session";
19
20
  const WsCtor = options.webSocketCtor ?? (typeof WebSocket !== "undefined" ? WebSocket : void 0);
21
+ const resolveUrl = options.resolveUrl;
20
22
  const logger = options.logger;
21
23
  const emitter = new TransportEmitter();
22
24
  const handles = /* @__PURE__ */ new Set();
@@ -83,6 +85,7 @@ function createWsTransport(options = {}) {
83
85
  };
84
86
  }
85
87
  function closeWs(handle, code, reason) {
88
+ handle.openSeq++;
86
89
  const ws = handle.ws;
87
90
  if (!ws) return;
88
91
  const Ctor = ensureCtor();
@@ -105,16 +108,42 @@ function createWsTransport(options = {}) {
105
108
  };
106
109
  for (const fn of [...handle.listeners.close]) fn(info);
107
110
  }
108
- function openWs(handle) {
109
- if (handle.disposed) return;
110
- if (!currentTarget) return;
111
- const url = buildUrl(currentTarget, handle.spec);
111
+ function connectWs(handle, url) {
112
112
  const ws = new (ensureCtor())(url, handle.spec.protocols);
113
113
  if (handle.spec.binaryType) ws.binaryType = handle.spec.binaryType;
114
114
  handle.ws = ws;
115
115
  handle.url = url;
116
116
  bindWs(handle, ws);
117
117
  }
118
+ function openWs(handle) {
119
+ if (handle.disposed) return;
120
+ if (!currentTarget) return;
121
+ const target = currentTarget;
122
+ const defaultUrl = buildUrl(target, handle.spec);
123
+ if (!resolveUrl) {
124
+ connectWs(handle, defaultUrl);
125
+ return;
126
+ }
127
+ const seq = ++handle.openSeq;
128
+ resolveUrl({
129
+ target,
130
+ spec: handle.spec,
131
+ defaultUrl
132
+ }).then((url) => {
133
+ if (handle.disposed || seq !== handle.openSeq || currentTarget !== target) return;
134
+ connectWs(handle, url);
135
+ }, (err) => {
136
+ if (handle.disposed || seq !== handle.openSeq) return;
137
+ const reason = err instanceof Error ? err.message : String(err);
138
+ logger?.warn(`resolveUrl failed for ${handle.spec.path}: ${reason}`);
139
+ const info = {
140
+ code: WS_CLOSE_RESOLVE_FAILED,
141
+ reason: "resolve-failed",
142
+ wasClean: false
143
+ };
144
+ for (const fn of [...handle.listeners.close]) fn(info);
145
+ });
146
+ }
118
147
  function recycleAll(code, reason) {
119
148
  const snapshot = [...handles];
120
149
  logger?.debug(`recycleAll (${reason}) handles=${snapshot.length}`);
@@ -178,7 +207,8 @@ function createWsTransport(options = {}) {
178
207
  },
179
208
  ws: null,
180
209
  url: null,
181
- disposed: false
210
+ disposed: false,
211
+ openSeq: 0
182
212
  };
183
213
  function send(data) {
184
214
  if (handle.disposed) throw new Error("ws-handle disposed");
@@ -1,3 +1,3 @@
1
1
  export { createWorkerKernelMirror } from './mirror.js';
2
- export type { MirrorListener, Unsubscribe, WorkerKernelMirror, WorkerKernelMirrorOptions } from './mirror.js';
3
- export type { KernelSyncMessage, KernelSyncRequestMessage, MessageSource, WorkerHost, WorkerMessage } from './protocol.js';
2
+ export type { MirrorListener, MirrorResolveContext, Unsubscribe, WorkerKernelMirror, WorkerKernelMirrorOptions } from './mirror.js';
3
+ export type { KernelResolveReplyMessage, KernelResolveRequestMessage, KernelSyncMessage, KernelSyncRequestMessage, MessageSource, WorkerHost, WorkerMessage, } from './protocol.js';
@@ -2,16 +2,23 @@ import { ConnectionPhase, ConnectionTarget } from '../core/types.js';
2
2
  import { MessageSource } from './protocol.js';
3
3
  export type Unsubscribe = () => void;
4
4
  export type MirrorListener = (next: ConnectionPhase, prev: ConnectionPhase) => void;
5
+ export interface MirrorResolveContext {
6
+ readonly connId: string;
7
+ readonly defaultServers: readonly string[];
8
+ }
5
9
  export interface WorkerKernelMirror {
6
10
  readonly phase: ConnectionPhase;
7
11
  readonly target: ConnectionTarget | null;
12
+ readonly hasResolver: boolean;
8
13
  subscribe(listener: MirrorListener): Unsubscribe;
9
14
  onRevalidate(listener: () => void): Unsubscribe;
10
15
  requestSync(): void;
16
+ resolveServers(ctx: MirrorResolveContext): Promise<string[]>;
11
17
  dispose(): void;
12
18
  }
13
19
  export interface WorkerKernelMirrorOptions {
14
20
  readonly source: MessageSource;
15
21
  readonly initial?: ConnectionPhase;
22
+ readonly resolveTimeoutMs?: number;
16
23
  }
17
24
  export declare function createWorkerKernelMirror(options: WorkerKernelMirrorOptions): WorkerKernelMirror;
@@ -3,6 +3,7 @@ export interface KernelSyncMessage {
3
3
  readonly type: 'kernel-sync';
4
4
  readonly generation: number;
5
5
  readonly phase: ConnectionPhase;
6
+ readonly resolver?: boolean;
6
7
  }
7
8
  export interface KernelSyncRequestMessage {
8
9
  readonly type: 'kernel-sync-request';
@@ -10,7 +11,19 @@ export interface KernelSyncRequestMessage {
10
11
  export interface KernelRevalidateMessage {
11
12
  readonly type: 'kernel-revalidate';
12
13
  }
13
- export type WorkerMessage = KernelSyncMessage | KernelSyncRequestMessage | KernelRevalidateMessage;
14
+ export interface KernelResolveRequestMessage {
15
+ readonly type: 'kernel-resolve-request';
16
+ readonly id: number;
17
+ readonly connId: string;
18
+ readonly defaultServers: readonly string[];
19
+ }
20
+ export interface KernelResolveReplyMessage {
21
+ readonly type: 'kernel-resolve-reply';
22
+ readonly id: number;
23
+ readonly servers?: string[];
24
+ readonly error?: string;
25
+ }
26
+ export type WorkerMessage = KernelSyncMessage | KernelSyncRequestMessage | KernelRevalidateMessage | KernelResolveRequestMessage | KernelResolveReplyMessage;
14
27
  export interface MessageSource {
15
28
  postMessage(message: WorkerMessage): void;
16
29
  addEventListener(type: 'message', listener: (event: MessageEvent<WorkerMessage>) => void): void;
package/dist/worker.js CHANGED
@@ -2,13 +2,29 @@
2
2
  function createWorkerKernelMirror(options) {
3
3
  let current = options.initial ?? { kind: "idle" };
4
4
  let lastGeneration = -1;
5
+ let hasResolver = false;
5
6
  const listeners = /* @__PURE__ */ new Set();
6
7
  const revalidateListeners = /* @__PURE__ */ new Set();
8
+ const pending = /* @__PURE__ */ new Map();
9
+ const resolveTimeoutMs = options.resolveTimeoutMs ?? 15e3;
10
+ let nextResolveId = 1;
7
11
  let disposed = false;
12
+ function settleResolve(id, servers, error) {
13
+ const entry = pending.get(id);
14
+ if (!entry) return;
15
+ pending.delete(id);
16
+ clearTimeout(entry.timer);
17
+ if (error !== void 0 || !servers) entry.reject(new Error(error ?? "resolve failed"));
18
+ else entry.resolve(servers);
19
+ }
8
20
  function handler(event) {
9
21
  if (disposed) return;
10
22
  const msg = event.data;
11
23
  if (!msg) return;
24
+ if (msg.type === "kernel-resolve-reply") {
25
+ settleResolve(msg.id, msg.servers, msg.error);
26
+ return;
27
+ }
12
28
  if (msg.type === "kernel-revalidate") {
13
29
  for (const l of [...revalidateListeners]) try {
14
30
  l();
@@ -18,6 +34,7 @@ function createWorkerKernelMirror(options) {
18
34
  if (msg.type !== "kernel-sync") return;
19
35
  if (msg.generation <= lastGeneration) return;
20
36
  lastGeneration = msg.generation;
37
+ hasResolver = msg.resolver === true;
21
38
  const prev = current;
22
39
  current = msg.phase;
23
40
  if (phaseEquals(prev, current)) return;
@@ -44,6 +61,9 @@ function createWorkerKernelMirror(options) {
44
61
  get target() {
45
62
  return targetOf(current);
46
63
  },
64
+ get hasResolver() {
65
+ return hasResolver;
66
+ },
47
67
  subscribe(listener) {
48
68
  listeners.add(listener);
49
69
  return () => {
@@ -60,11 +80,30 @@ function createWorkerKernelMirror(options) {
60
80
  if (disposed) return;
61
81
  options.source.postMessage({ type: "kernel-sync-request" });
62
82
  },
83
+ resolveServers(ctx) {
84
+ if (disposed) return Promise.reject(/* @__PURE__ */ new Error("mirror disposed"));
85
+ const id = nextResolveId++;
86
+ return new Promise((resolve, reject) => {
87
+ const timer = setTimeout(() => settleResolve(id, void 0, "resolve timeout"), resolveTimeoutMs);
88
+ pending.set(id, {
89
+ resolve,
90
+ reject,
91
+ timer
92
+ });
93
+ options.source.postMessage({
94
+ type: "kernel-resolve-request",
95
+ id,
96
+ connId: ctx.connId,
97
+ defaultServers: [...ctx.defaultServers]
98
+ });
99
+ });
100
+ },
63
101
  dispose() {
64
102
  disposed = true;
65
103
  options.source.removeEventListener("message", handler);
66
104
  listeners.clear();
67
105
  revalidateListeners.clear();
106
+ for (const id of [...pending.keys()]) settleResolve(id, void 0, "mirror disposed");
68
107
  }
69
108
  };
70
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camera.ui/transport",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
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,26 +56,26 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@camera.ui/logger": ">=0.0.3",
59
- "@camera.ui/rpc": ">=1.0.10",
60
- "axios": ">=1.18.1",
59
+ "@camera.ui/rpc": ">=1.0.11",
60
+ "axios": ">=1.20.0",
61
61
  "socket.io-client": ">=4.8.3"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@eslint/js": "9.39.4",
65
65
  "@stylistic/eslint-plugin": "^5.10.0",
66
- "@typescript-eslint/parser": "^8.65.0",
67
- "@vue/language-core": "^3.3.8",
66
+ "@typescript-eslint/parser": "^8.68.0",
67
+ "@vue/language-core": "^3.3.11",
68
68
  "eslint": "9.39.2",
69
- "globals": "^17.8.0",
69
+ "globals": "^17.11.0",
70
70
  "jiti": "^2.7.0",
71
71
  "prettier": "^3.9.6",
72
72
  "rimraf": "^6.1.3",
73
73
  "typescript": "5.9.3",
74
- "typescript-eslint": "^8.65.0",
74
+ "typescript-eslint": "^8.68.0",
75
75
  "unplugin-dts": "^1.0.3",
76
- "updates": "^17.19.1",
77
- "vite": "^8.1.5",
78
- "vitest": "^4.1.10"
76
+ "updates": "^18.0.7",
77
+ "vite": "^8.2.2",
78
+ "vitest": "^4.1.11"
79
79
  },
80
80
  "overrides": {
81
81
  "@vue/language-core": "$@vue/language-core"