@camera.ui/transport 0.0.26 → 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.
@@ -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 {
@@ -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,6 +148,7 @@ 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");
@@ -119,6 +170,10 @@ function createNatsTransport(options = {}) {
119
170
  message
120
171
  } : message);
121
172
  if (cls === "auth-expired") {
173
+ if (resolveServers) {
174
+ resign(message);
175
+ return;
176
+ }
122
177
  emitter.emit("auth-error", { message });
123
178
  return;
124
179
  }
@@ -133,6 +188,7 @@ function createNatsTransport(options = {}) {
133
188
  logger?.debug(`rebuildClient start (epoch=${epoch})`);
134
189
  const t0 = Date.now();
135
190
  stopStatusMonitor();
191
+ stopRefresh();
136
192
  pendingConnectAbort?.abort();
137
193
  if (proxy) {
138
194
  try {
@@ -142,7 +198,17 @@ function createNatsTransport(options = {}) {
142
198
  notifyClient();
143
199
  }
144
200
  connId = newConnId();
145
- const servers = buildServers(target);
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;
146
212
  const next = createRPCClient({
147
213
  servers,
148
214
  name: clientName,
@@ -174,7 +240,7 @@ function createNatsTransport(options = {}) {
174
240
  if (disposed || epoch !== applyEpoch) return;
175
241
  const msg = err instanceof Error ? err.message : String(err);
176
242
  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 });
243
+ if (!resolveServers && classifyClose(err instanceof Error ? err : msg) === "auth-expired") emitter.emit("auth-error", { message: msg });
178
244
  markDown(msg);
179
245
  throw err;
180
246
  } finally {
@@ -191,6 +257,7 @@ function createNatsTransport(options = {}) {
191
257
  markUp();
192
258
  notifyClient();
193
259
  startStatusMonitor(next);
260
+ startRefresh(next);
194
261
  }
195
262
  async function apply(target) {
196
263
  if (disposed) throw new Error("nats-transport disposed");
@@ -200,6 +267,7 @@ function createNatsTransport(options = {}) {
200
267
  currentTarget = target;
201
268
  if (!target) {
202
269
  stopStatusMonitor();
270
+ stopRefresh();
203
271
  pendingConnectAbort?.abort();
204
272
  pendingConnectAbort = null;
205
273
  if (proxy) {
@@ -221,8 +289,9 @@ function createNatsTransport(options = {}) {
221
289
  }
222
290
  return;
223
291
  }
224
- const newServers = buildServers(target);
225
- proxy.setServers(newServers);
292
+ const p = proxy;
293
+ const newServers = await serversFor(target);
294
+ if (proxy === p && epoch === applyEpoch) p.setServers(newServers);
226
295
  }
227
296
  function health() {
228
297
  if (status.up && proxy?.isStale) return {
@@ -238,6 +307,7 @@ function createNatsTransport(options = {}) {
238
307
  disposed = true;
239
308
  applyEpoch++;
240
309
  stopStatusMonitor();
310
+ stopRefresh();
241
311
  pendingConnectAbort?.abort();
242
312
  pendingConnectAbort = null;
243
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}`);
@@ -91,7 +130,7 @@ function createSocketioTransport(options = {}) {
91
130
  const sock = io(url, {
92
131
  path: socketPath(target),
93
132
  auth: buildAuth(target),
94
- query: buildQuery(target),
133
+ query: currentQuery,
95
134
  reconnection,
96
135
  reconnectionDelay,
97
136
  reconnectionDelayMax,
@@ -102,8 +141,18 @@ function createSocketioTransport(options = {}) {
102
141
  sockets.set(namespace, sock);
103
142
  return sock;
104
143
  }
105
- 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;
106
154
  closeAllSockets();
155
+ currentQuery = query;
107
156
  manager = new Manager(socketOrigin(target), {
108
157
  path: socketPath(target),
109
158
  autoConnect: false,
@@ -116,6 +165,7 @@ function createSocketioTransport(options = {}) {
116
165
  });
117
166
  const main = openSocket(mainNs, target);
118
167
  bindMainSocketEvents(main);
168
+ startRefresh();
119
169
  lastActivityAt = Date.now();
120
170
  main.io.on("open", touchActivity);
121
171
  main.io.on("ping", touchActivity);
@@ -146,12 +196,13 @@ function createSocketioTransport(options = {}) {
146
196
  currentTarget = target;
147
197
  if (!target) {
148
198
  closeAllSockets();
199
+ stopRefresh();
149
200
  lastActivityAt = 0;
150
201
  markDown("detached");
151
202
  return;
152
203
  }
153
204
  if (endpointChanged || !manager) {
154
- rebuildManager(target);
205
+ await rebuildManager(target);
155
206
  return;
156
207
  }
157
208
  rebindAuth(target);
@@ -169,6 +220,7 @@ function createSocketioTransport(options = {}) {
169
220
  async function dispose() {
170
221
  disposed = true;
171
222
  closeAllSockets();
223
+ stopRefresh();
172
224
  currentTarget = null;
173
225
  status = { up: false };
174
226
  emitter.clear();
@@ -177,7 +229,7 @@ function createSocketioTransport(options = {}) {
177
229
  return sockets.get(namespace) ?? null;
178
230
  }
179
231
  function ensureSocket(namespace) {
180
- if (!currentTarget) return null;
232
+ if (!currentTarget || !manager) return null;
181
233
  let sock = sockets.get(namespace);
182
234
  if (!sock) {
183
235
  sock = openSocket(namespace, currentTarget);
@@ -204,6 +256,7 @@ function createSocketioTransport(options = {}) {
204
256
  }
205
257
  async function ensureAlive() {
206
258
  if (disposed || !currentTarget) return health();
259
+ if (resolveQuery) await refreshQuery();
207
260
  reviveDeadSockets();
208
261
  return health();
209
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.26",
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",
@@ -57,25 +57,25 @@
57
57
  "dependencies": {
58
58
  "@camera.ui/logger": ">=0.0.3",
59
59
  "@camera.ui/rpc": ">=1.0.11",
60
- "axios": ">=1.19.0",
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"