@camera.ui/transport 0.0.26 → 0.0.28

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,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
@@ -785,8 +785,10 @@ function attachProbeLoop(options) {
785
785
  return tokens;
786
786
  } catch (err) {
787
787
  let finalErr = err;
788
- if (signal.aborted && !ctrl.signal.aborted && !isProbeFailure(err)) if (signal.reason === "race-settled") finalErr = makeProbeFailure("transient", "superseded");
789
- 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
+ }
790
792
  options.onProbeError?.(endpoint, finalErr);
791
793
  throw finalErr;
792
794
  }
@@ -1124,7 +1126,8 @@ function attachWorkerBridge(options) {
1124
1126
  return {
1125
1127
  type: "kernel-sync",
1126
1128
  generation,
1127
- phase
1129
+ phase,
1130
+ resolver: options.resolveServers !== void 0
1128
1131
  };
1129
1132
  }
1130
1133
  function broadcast(phase) {
@@ -1158,13 +1161,46 @@ function attachWorkerBridge(options) {
1158
1161
  });
1159
1162
  }
1160
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
+ }
1161
1195
  function maybeAttachHostListener(host) {
1162
- if (!options.listenForResyncRequests) return;
1196
+ if (!options.listenForResyncRequests && !options.resolveServers) return;
1163
1197
  if (!host.addEventListener) return;
1164
1198
  if (hostListenerCleanups.has(host)) return;
1165
1199
  const listener = (event) => {
1166
1200
  if (detached) return;
1167
- 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);
1168
1204
  };
1169
1205
  host.addEventListener("message", listener);
1170
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 {
@@ -7,6 +7,7 @@ var NATS_SPEC = {
7
7
  kind: "persistent",
8
8
  phaseGating: true
9
9
  };
10
+ var RESOLVE_RETRY_MIN_MS = 3e3;
10
11
  function newConnId() {
11
12
  const c = globalThis.crypto;
12
13
  if (c?.randomUUID) return c.randomUUID();
@@ -35,8 +36,11 @@ function createNatsTransport(options = {}) {
35
36
  const pingTimeout = options.pingTimeout ?? 2e4;
36
37
  const maxPingOut = options.maxPingOut ?? 1;
37
38
  const logger = options.logger;
39
+ const resolveServers = options.resolveServers;
40
+ const resolveRefreshMs = options.resolveRefreshMs ?? 0;
38
41
  const emitter = new TransportEmitter();
39
42
  const clientListeners = /* @__PURE__ */ new Set();
43
+ let refreshTimer = null;
40
44
  let proxy = null;
41
45
  let currentTarget = null;
42
46
  let status = { up: false };
@@ -45,6 +49,8 @@ function createNatsTransport(options = {}) {
45
49
  let connId = null;
46
50
  let applyEpoch = 0;
47
51
  let pendingConnectAbort = null;
52
+ let poolRefreshAt = 0;
53
+ let poolRefreshing = false;
48
54
  function buildServers(target) {
49
55
  const url = new URL(target.endpoint.url);
50
56
  const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
@@ -55,6 +61,58 @@ function createNatsTransport(options = {}) {
55
61
  if (connId) params.set("connId", connId);
56
62
  return [`${wsProtocol}//${url.hostname}:${port}${prefix}${proxyPath}?${params.toString()}`];
57
63
  }
64
+ async function serversFor(target) {
65
+ const defaultServers = buildServers(target);
66
+ if (!resolveServers) return defaultServers;
67
+ return resolveServers({
68
+ target,
69
+ connId: connId ?? "",
70
+ defaultServers
71
+ });
72
+ }
73
+ async function refreshPool(p) {
74
+ const target = currentTarget;
75
+ if (!resolveServers || !target || disposed) return;
76
+ if (poolRefreshing || Date.now() - poolRefreshAt < RESOLVE_RETRY_MIN_MS) return;
77
+ poolRefreshing = true;
78
+ try {
79
+ const servers = await serversFor(target);
80
+ if (proxy === p && !disposed) p.setServers(servers);
81
+ } catch (err) {
82
+ logger?.warn(`server refresh failed: ${stringifyError(err)}`);
83
+ } finally {
84
+ poolRefreshing = false;
85
+ poolRefreshAt = Date.now();
86
+ }
87
+ }
88
+ function stopRefresh() {
89
+ if (refreshTimer) {
90
+ clearInterval(refreshTimer);
91
+ refreshTimer = null;
92
+ }
93
+ }
94
+ function startRefresh(p) {
95
+ stopRefresh();
96
+ if (!resolveServers || resolveRefreshMs <= 0) return;
97
+ refreshTimer = setInterval(() => refreshPool(p), resolveRefreshMs);
98
+ }
99
+ async function resign(reason) {
100
+ const target = currentTarget;
101
+ if (!proxy || !target || disposed) return;
102
+ logger?.debug(`resign (${reason})`);
103
+ const epoch = ++applyEpoch;
104
+ stopStatusMonitor();
105
+ stopRefresh();
106
+ try {
107
+ proxy.abortClose();
108
+ } catch {}
109
+ proxy = null;
110
+ notifyClient();
111
+ markDown(`signature-expired: ${reason}`);
112
+ try {
113
+ await rebuildClient(target, epoch);
114
+ } catch {}
115
+ }
58
116
  function notifyClient() {
59
117
  const next = status.up ? proxy : null;
60
118
  for (const fn of [...clientListeners]) try {
@@ -98,6 +156,10 @@ function createNatsTransport(options = {}) {
98
156
  case "disconnect":
99
157
  markDown("disconnect");
100
158
  notifyClient();
159
+ if (resolveServers) refreshPool(p);
160
+ break;
161
+ case "reconnecting":
162
+ if (resolveServers) refreshPool(p);
101
163
  break;
102
164
  case "staleConnection":
103
165
  markDown("staleConnection");
@@ -119,6 +181,10 @@ function createNatsTransport(options = {}) {
119
181
  message
120
182
  } : message);
121
183
  if (cls === "auth-expired") {
184
+ if (resolveServers) {
185
+ resign(message);
186
+ return;
187
+ }
122
188
  emitter.emit("auth-error", { message });
123
189
  return;
124
190
  }
@@ -133,6 +199,7 @@ function createNatsTransport(options = {}) {
133
199
  logger?.debug(`rebuildClient start (epoch=${epoch})`);
134
200
  const t0 = Date.now();
135
201
  stopStatusMonitor();
202
+ stopRefresh();
136
203
  pendingConnectAbort?.abort();
137
204
  if (proxy) {
138
205
  try {
@@ -142,7 +209,17 @@ function createNatsTransport(options = {}) {
142
209
  notifyClient();
143
210
  }
144
211
  connId = newConnId();
145
- const servers = buildServers(target);
212
+ let servers;
213
+ 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;
221
+ }
222
+ if (disposed || epoch !== applyEpoch) return;
146
223
  const next = createRPCClient({
147
224
  servers,
148
225
  name: clientName,
@@ -174,7 +251,7 @@ function createNatsTransport(options = {}) {
174
251
  if (disposed || epoch !== applyEpoch) return;
175
252
  const msg = err instanceof Error ? err.message : String(err);
176
253
  logger?.debug(`rebuildClient connect FAILED after ${Date.now() - t0}ms: ${msg}`);
177
- if (classifyClose(err instanceof Error ? err : msg) === "auth-expired") emitter.emit("auth-error", { message: msg });
254
+ if (!resolveServers && classifyClose(err instanceof Error ? err : msg) === "auth-expired") emitter.emit("auth-error", { message: msg });
178
255
  markDown(msg);
179
256
  throw err;
180
257
  } finally {
@@ -191,6 +268,7 @@ function createNatsTransport(options = {}) {
191
268
  markUp();
192
269
  notifyClient();
193
270
  startStatusMonitor(next);
271
+ startRefresh(next);
194
272
  }
195
273
  async function apply(target) {
196
274
  if (disposed) throw new Error("nats-transport disposed");
@@ -200,6 +278,7 @@ function createNatsTransport(options = {}) {
200
278
  currentTarget = target;
201
279
  if (!target) {
202
280
  stopStatusMonitor();
281
+ stopRefresh();
203
282
  pendingConnectAbort?.abort();
204
283
  pendingConnectAbort = null;
205
284
  if (proxy) {
@@ -221,8 +300,9 @@ function createNatsTransport(options = {}) {
221
300
  }
222
301
  return;
223
302
  }
224
- const newServers = buildServers(target);
225
- proxy.setServers(newServers);
303
+ const p = proxy;
304
+ const newServers = await serversFor(target);
305
+ if (proxy === p && epoch === applyEpoch) p.setServers(newServers);
226
306
  }
227
307
  function health() {
228
308
  if (status.up && proxy?.isStale) return {
@@ -238,6 +318,7 @@ function createNatsTransport(options = {}) {
238
318
  disposed = true;
239
319
  applyEpoch++;
240
320
  stopStatusMonitor();
321
+ stopRefresh();
241
322
  pendingConnectAbort?.abort();
242
323
  pendingConnectAbort = null;
243
324
  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;
@@ -2,6 +2,7 @@ import { n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from
2
2
  import { t as classifyClose } from "../closeCodes-ClWJVOpq.js";
3
3
  import { Manager, io } from "socket.io-client";
4
4
  //#region src/transports/socketio.ts
5
+ var RESOLVE_RETRY_MIN_MS = 3e3;
5
6
  var SOCKETIO_SPEC = {
6
7
  id: "socketio",
7
8
  kind: "persistent",
@@ -19,10 +20,16 @@ function createSocketioTransport(options = {}) {
19
20
  const reconnectionDelayMax = options.reconnectionDelayMax ?? 5e3;
20
21
  const timeout = options.timeout ?? 2e4;
21
22
  const logger = options.logger;
23
+ const resolveQuery = options.resolveQuery;
24
+ const resolveRefreshMs = options.resolveRefreshMs ?? 0;
22
25
  const emitter = new TransportEmitter();
23
26
  const sockets = /* @__PURE__ */ new Map();
24
27
  let manager = null;
25
28
  let currentTarget = null;
29
+ let currentQuery = {};
30
+ let refreshTimer = null;
31
+ let queryRefreshAt = 0;
32
+ let queryRefreshing = false;
26
33
  let status = { up: false };
27
34
  let disposed = false;
28
35
  const staleAfterMs = options.staleAfterMs ?? 6e4;
@@ -43,6 +50,43 @@ function createSocketioTransport(options = {}) {
43
50
  if (target.tokens.proxySession) q.session = target.tokens.proxySession;
44
51
  return q;
45
52
  }
53
+ async function queryFor(target) {
54
+ const base = buildQuery(target);
55
+ if (!resolveQuery) return base;
56
+ return resolveQuery({
57
+ target,
58
+ path: socketPath(target),
59
+ query: base
60
+ });
61
+ }
62
+ async function refreshQuery() {
63
+ const target = currentTarget;
64
+ if (!resolveQuery || !target || disposed) return;
65
+ if (queryRefreshing || Date.now() - queryRefreshAt < RESOLVE_RETRY_MIN_MS) return;
66
+ queryRefreshing = true;
67
+ try {
68
+ const query = await queryFor(target);
69
+ if (currentTarget !== target || disposed) return;
70
+ currentQuery = query;
71
+ for (const sock of sockets.values()) sock.io.opts.query = query;
72
+ } catch (err) {
73
+ logger?.warn(`query refresh failed: ${err instanceof Error ? err.message : String(err)}`);
74
+ } finally {
75
+ queryRefreshing = false;
76
+ queryRefreshAt = Date.now();
77
+ }
78
+ }
79
+ function stopRefresh() {
80
+ if (refreshTimer) {
81
+ clearInterval(refreshTimer);
82
+ refreshTimer = null;
83
+ }
84
+ }
85
+ function startRefresh() {
86
+ stopRefresh();
87
+ if (!resolveQuery || resolveRefreshMs <= 0) return;
88
+ refreshTimer = setInterval(() => refreshQuery(), resolveRefreshMs);
89
+ }
46
90
  function markUp() {
47
91
  if (status.up) return;
48
92
  status = { up: true };
@@ -63,10 +107,14 @@ function createSocketioTransport(options = {}) {
63
107
  }
64
108
  function bindMainSocketEvents(socket) {
65
109
  socket.on("connect", () => markUp());
66
- socket.on("disconnect", (reason) => markDown(reason));
110
+ socket.on("disconnect", (reason) => {
111
+ markDown(reason);
112
+ if (resolveQuery) refreshQuery();
113
+ });
67
114
  socket.on("connect_error", (err) => {
68
115
  const msg = err?.message ?? "connect_error";
69
116
  logger?.debug(`connect_error (main): ${msg}`);
117
+ if (resolveQuery) refreshQuery();
70
118
  if (isAuthError(msg)) {
71
119
  emitter.emit("auth-error", { message: msg });
72
120
  return;
@@ -91,7 +139,7 @@ function createSocketioTransport(options = {}) {
91
139
  const sock = io(url, {
92
140
  path: socketPath(target),
93
141
  auth: buildAuth(target),
94
- query: buildQuery(target),
142
+ query: currentQuery,
95
143
  reconnection,
96
144
  reconnectionDelay,
97
145
  reconnectionDelayMax,
@@ -102,8 +150,18 @@ function createSocketioTransport(options = {}) {
102
150
  sockets.set(namespace, sock);
103
151
  return sock;
104
152
  }
105
- function rebuildManager(target) {
153
+ async function rebuildManager(target) {
154
+ stopRefresh();
155
+ let query;
156
+ try {
157
+ query = await queryFor(target);
158
+ } catch (err) {
159
+ markDown(err instanceof Error ? err.message : String(err));
160
+ throw err;
161
+ }
162
+ if (currentTarget !== target || disposed) return;
106
163
  closeAllSockets();
164
+ currentQuery = query;
107
165
  manager = new Manager(socketOrigin(target), {
108
166
  path: socketPath(target),
109
167
  autoConnect: false,
@@ -116,6 +174,7 @@ function createSocketioTransport(options = {}) {
116
174
  });
117
175
  const main = openSocket(mainNs, target);
118
176
  bindMainSocketEvents(main);
177
+ startRefresh();
119
178
  lastActivityAt = Date.now();
120
179
  main.io.on("open", touchActivity);
121
180
  main.io.on("ping", touchActivity);
@@ -146,12 +205,13 @@ function createSocketioTransport(options = {}) {
146
205
  currentTarget = target;
147
206
  if (!target) {
148
207
  closeAllSockets();
208
+ stopRefresh();
149
209
  lastActivityAt = 0;
150
210
  markDown("detached");
151
211
  return;
152
212
  }
153
213
  if (endpointChanged || !manager) {
154
- rebuildManager(target);
214
+ await rebuildManager(target);
155
215
  return;
156
216
  }
157
217
  rebindAuth(target);
@@ -169,6 +229,7 @@ function createSocketioTransport(options = {}) {
169
229
  async function dispose() {
170
230
  disposed = true;
171
231
  closeAllSockets();
232
+ stopRefresh();
172
233
  currentTarget = null;
173
234
  status = { up: false };
174
235
  emitter.clear();
@@ -177,7 +238,7 @@ function createSocketioTransport(options = {}) {
177
238
  return sockets.get(namespace) ?? null;
178
239
  }
179
240
  function ensureSocket(namespace) {
180
- if (!currentTarget) return null;
241
+ if (!currentTarget || !manager) return null;
181
242
  let sock = sockets.get(namespace);
182
243
  if (!sock) {
183
244
  sock = openSocket(namespace, currentTarget);
@@ -204,6 +265,7 @@ function createSocketioTransport(options = {}) {
204
265
  }
205
266
  async function ensureAlive() {
206
267
  if (disposed || !currentTarget) return health();
268
+ if (resolveQuery) await refreshQuery();
207
269
  reviveDeadSockets();
208
270
  return health();
209
271
  }
@@ -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.26",
3
+ "version": "0.0.28",
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.11",
60
- "axios": ">=1.19.0",
59
+ "@camera.ui/rpc": ">=1.0.12",
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.67.0",
67
- "@vue/language-core": "^3.3.10",
66
+ "@typescript-eslint/parser": "^8.68.0",
67
+ "@vue/language-core": "^3.3.11",
68
68
  "eslint": "9.39.2",
69
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.67.0",
74
+ "typescript-eslint": "^8.68.0",
75
75
  "unplugin-dts": "^1.0.3",
76
- "updates": "^18.0.1",
77
- "vite": "^8.2.1",
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"