@camera.ui/transport 0.0.28 → 0.0.30

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.
@@ -7,6 +7,7 @@ export interface HttpTransportOptions {
7
7
  readonly apiPrefix?: string;
8
8
  readonly timeoutMs?: number;
9
9
  readonly targetWaitMs?: number;
10
+ readonly authRetryWaitMs?: number;
10
11
  readonly spec?: Partial<TransportSpec>;
11
12
  readonly authorize?: HttpAuthorizer;
12
13
  readonly logger?: Logger;
@@ -13,12 +13,14 @@ function createHttpTransport(options = {}) {
13
13
  };
14
14
  const apiPrefix = options.apiPrefix ?? "/api";
15
15
  const targetWaitMs = options.targetWaitMs ?? 15e3;
16
+ const authRetryWaitMs = options.authRetryWaitMs ?? 1e4;
16
17
  const logger = options.logger;
17
18
  const emitter = new TransportEmitter();
18
19
  let currentTarget = null;
19
20
  let status = { up: false };
20
21
  let disposed = false;
21
22
  const targetWaiters = /* @__PURE__ */ new Set();
23
+ const accessWaiters = /* @__PURE__ */ new Set();
22
24
  const client = axios.create({ timeout: options.timeoutMs ?? 3e4 });
23
25
  function waitForTarget(signal) {
24
26
  if (currentTarget) return Promise.resolve();
@@ -52,10 +54,44 @@ function createHttpTransport(options = {}) {
52
54
  if (!currentTarget) return;
53
55
  for (const waiter of [...targetWaiters]) waiter.resolve();
54
56
  }
57
+ function waitForNewAccess(staleAccess, signal) {
58
+ if (currentTarget && currentTarget.tokens.access !== staleAccess) return Promise.resolve();
59
+ return new Promise((resolve, reject) => {
60
+ const waiter = {
61
+ staleAccess,
62
+ resolve: () => {
63
+ cleanup();
64
+ resolve();
65
+ },
66
+ reject: (reason) => {
67
+ cleanup();
68
+ reject(reason);
69
+ }
70
+ };
71
+ const timer = setTimeout(() => waiter.reject(/* @__PURE__ */ new Error("http-transport: token refresh timed out")), authRetryWaitMs);
72
+ const onAbort = () => waiter.reject(new axios.Cancel("http-transport: aborted"));
73
+ function cleanup() {
74
+ clearTimeout(timer);
75
+ accessWaiters.delete(waiter);
76
+ signal?.removeEventListener?.("abort", onAbort);
77
+ }
78
+ if (signal?.aborted) {
79
+ waiter.reject(new axios.Cancel("http-transport: aborted"));
80
+ return;
81
+ }
82
+ signal?.addEventListener?.("abort", onAbort);
83
+ accessWaiters.add(waiter);
84
+ });
85
+ }
86
+ function flushAccessWaiters() {
87
+ for (const waiter of [...accessWaiters]) if (!currentTarget) waiter.reject(new axios.Cancel("http-transport: no target"));
88
+ else if (currentTarget.tokens.access !== waiter.staleAccess) waiter.resolve();
89
+ }
55
90
  client.interceptors.request.use(async (config) => {
56
91
  if (!currentTarget) await waitForTarget(config.signal);
57
92
  const target = currentTarget;
58
93
  if (!target) throw new axios.Cancel("http-transport: no target");
94
+ config._sentAccess = target.tokens.access;
59
95
  if (!config.baseURL) config.baseURL = `${target.endpoint.url}${apiPrefix}`;
60
96
  if (options.authorize) await options.authorize(config, target);
61
97
  else config.headers.set("Authorization", `Bearer ${target.tokens.access}`);
@@ -69,7 +105,7 @@ function createHttpTransport(options = {}) {
69
105
  emitter.emit("up", void 0);
70
106
  }
71
107
  return response;
72
- }, (error) => {
108
+ }, async (error) => {
73
109
  if (axios.isCancel(error)) return Promise.reject(error);
74
110
  if (!error.response) {
75
111
  markDown(error.message ?? "network");
@@ -81,6 +117,17 @@ function createHttpTransport(options = {}) {
81
117
  status: 401,
82
118
  message: extractMessage(error)
83
119
  });
120
+ const config = error.config;
121
+ if (config?._sentAccess && !config._authRetried && !disposed) {
122
+ config._authRetried = true;
123
+ try {
124
+ await waitForNewAccess(config._sentAccess, config.signal);
125
+ } catch {
126
+ return Promise.reject(error);
127
+ }
128
+ logger?.debug(`retry after token refresh (${config.url ?? "unknown-url"})`);
129
+ return client(config);
130
+ }
84
131
  }
85
132
  return Promise.reject(error);
86
133
  });
@@ -102,10 +149,12 @@ function createHttpTransport(options = {}) {
102
149
  if (!target) {
103
150
  status = { up: false };
104
151
  emitter.emit("down", { reason: "detached" });
152
+ flushAccessWaiters();
105
153
  return;
106
154
  }
107
155
  if (endpointChanged) status = { up: false };
108
156
  flushTargetWaiters();
157
+ flushAccessWaiters();
109
158
  }
110
159
  function health() {
111
160
  return status;
@@ -118,6 +167,7 @@ function createHttpTransport(options = {}) {
118
167
  currentTarget = null;
119
168
  status = { up: false };
120
169
  for (const waiter of [...targetWaiters]) waiter.reject(new axios.Cancel("http-transport: disposed"));
170
+ for (const waiter of [...accessWaiters]) waiter.reject(new axios.Cancel("http-transport: disposed"));
121
171
  emitter.clear();
122
172
  }
123
173
  return {
@@ -32,6 +32,7 @@ function createSocketioTransport(options = {}) {
32
32
  let queryRefreshing = false;
33
33
  let status = { up: false };
34
34
  let disposed = false;
35
+ let applyEpoch = 0;
35
36
  const staleAfterMs = options.staleAfterMs ?? 6e4;
36
37
  let lastActivityAt = 0;
37
38
  function touchActivity() {
@@ -211,7 +212,13 @@ function createSocketioTransport(options = {}) {
211
212
  return;
212
213
  }
213
214
  if (endpointChanged || !manager) {
214
- await rebuildManager(target);
215
+ const epoch = ++applyEpoch;
216
+ try {
217
+ await rebuildManager(target);
218
+ } catch (err) {
219
+ if (epoch === applyEpoch && currentTarget === target) currentTarget = null;
220
+ throw err;
221
+ }
215
222
  return;
216
223
  }
217
224
  rebindAuth(target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camera.ui/transport",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
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",
@@ -52,7 +52,7 @@
52
52
  "test": "vitest run",
53
53
  "test:watch": "vitest",
54
54
  "type-check": "tsc --noEmit",
55
- "update": "updates --update ./"
55
+ "update": "updates -M npm,pypi,go,cargo,docker,make --update ./"
56
56
  },
57
57
  "dependencies": {
58
58
  "@camera.ui/logger": ">=0.0.3",
@@ -63,19 +63,19 @@
63
63
  "devDependencies": {
64
64
  "@eslint/js": "9.39.4",
65
65
  "@stylistic/eslint-plugin": "^5.10.0",
66
- "@typescript-eslint/parser": "^8.68.0",
66
+ "@typescript-eslint/parser": "^8.70.0",
67
67
  "@vue/language-core": "^3.3.11",
68
68
  "eslint": "9.39.2",
69
- "globals": "^17.11.0",
69
+ "globals": "^17.12.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.68.0",
75
- "unplugin-dts": "^1.0.3",
76
- "updates": "^18.0.7",
77
- "vite": "^8.2.2",
78
- "vitest": "^4.1.11"
74
+ "typescript-eslint": "^8.70.0",
75
+ "unplugin-dts": "^1.1.0",
76
+ "updates": "^18.1.0",
77
+ "vite": "^8.3.0",
78
+ "vitest": "^5.0.1"
79
79
  },
80
80
  "overrides": {
81
81
  "@vue/language-core": "$@vue/language-core"