@camera.ui/transport 0.0.29 → 0.1.0

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.
package/dist/signal.d.ts CHANGED
@@ -8,6 +8,7 @@ export type ConnectionSignal = {
8
8
  } | {
9
9
  readonly kind: 'degraded';
10
10
  readonly channels: readonly TransportId[];
11
+ readonly reason?: 'first-connect';
11
12
  } | {
12
13
  readonly kind: 'offline';
13
14
  readonly retryAt?: number;
@@ -18,6 +19,7 @@ export interface ConnectionSignalOptions {
18
19
  readonly kernel: Kernel;
19
20
  readonly transports: readonly Transport[];
20
21
  readonly debounceMs?: number;
22
+ readonly firstConnectTimeoutMs?: number;
21
23
  }
22
24
  export interface ConnectionSignalHandle {
23
25
  readonly current: ConnectionSignal;
@@ -8,6 +8,7 @@ export declare class FakeTransport implements Transport {
8
8
  readonly spec: TransportSpec;
9
9
  readonly applyCalls: (ConnectionTarget | null)[];
10
10
  failNextApplies: number;
11
+ failWith: unknown;
11
12
  ensureAliveCalls: number;
12
13
  private readonly emitter;
13
14
  private readonly applyDelayMs;
@@ -22,6 +23,6 @@ export declare class FakeTransport implements Transport {
22
23
  dispose(): Promise<void>;
23
24
  emitDown(reason: string): void;
24
25
  emitUp(): void;
25
- emitAuthError(status?: number): void;
26
+ emitAuthError(status?: number, access?: string): void;
26
27
  get target(): ConnectionTarget | null;
27
28
  }
package/dist/testing.js CHANGED
@@ -4,6 +4,7 @@ var FakeTransport = class {
4
4
  spec;
5
5
  applyCalls = [];
6
6
  failNextApplies = 0;
7
+ failWith;
7
8
  ensureAliveCalls = 0;
8
9
  emitter = new TransportEmitter();
9
10
  applyDelayMs;
@@ -24,7 +25,7 @@ var FakeTransport = class {
24
25
  up: false,
25
26
  lastError: "apply failed"
26
27
  };
27
- throw new Error("apply failed");
28
+ throw this.failWith ?? /* @__PURE__ */ new Error("apply failed");
28
29
  }
29
30
  this.currentTarget = target;
30
31
  this.currentStatus = target ? { up: true } : { up: false };
@@ -56,8 +57,11 @@ var FakeTransport = class {
56
57
  this.currentStatus = { up: true };
57
58
  this.emitter.emit("up", void 0);
58
59
  }
59
- emitAuthError(status) {
60
- this.emitter.emit("auth-error", { status });
60
+ emitAuthError(status, access) {
61
+ this.emitter.emit("auth-error", access === void 0 ? { status } : {
62
+ status,
63
+ access
64
+ });
61
65
  }
62
66
  get target() {
63
67
  return this.currentTarget;
@@ -4,5 +4,7 @@ export interface CloseLike {
4
4
  readonly wsCloseCode?: number;
5
5
  readonly message?: string;
6
6
  readonly reason?: string;
7
+ readonly data?: unknown;
7
8
  }
8
9
  export declare function classifyClose(input: CloseLike | string): CloseClass;
10
+ export declare function refusedByServer(input: unknown): boolean;
@@ -5,6 +5,8 @@ export type TransportEventPayload<E extends TransportEvent> = E extends 'down' ?
5
5
  } : E extends 'auth-error' ? {
6
6
  readonly status?: number;
7
7
  readonly message?: string;
8
+ readonly access?: string;
9
+ readonly holdFor?: (refresh: Promise<boolean>) => void;
8
10
  } : void;
9
11
  export type TransportEventHandler<E extends TransportEvent> = (payload: TransportEventPayload<E>) => void;
10
12
  export type Unsubscribe = () => void;
@@ -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;
@@ -6,6 +6,11 @@ var HTTP_SPEC = {
6
6
  kind: "request",
7
7
  phaseGating: false
8
8
  };
9
+ var TOKEN_REJECTED_MESSAGES = /* @__PURE__ */ new Set([
10
+ "Unauthorized",
11
+ "Token expired",
12
+ "Token revoked"
13
+ ]);
9
14
  function createHttpTransport(options = {}) {
10
15
  const spec = {
11
16
  ...HTTP_SPEC,
@@ -13,12 +18,14 @@ function createHttpTransport(options = {}) {
13
18
  };
14
19
  const apiPrefix = options.apiPrefix ?? "/api";
15
20
  const targetWaitMs = options.targetWaitMs ?? 15e3;
21
+ const authRetryWaitMs = options.authRetryWaitMs ?? 1e4;
16
22
  const logger = options.logger;
17
23
  const emitter = new TransportEmitter();
18
24
  let currentTarget = null;
19
25
  let status = { up: false };
20
26
  let disposed = false;
21
27
  const targetWaiters = /* @__PURE__ */ new Set();
28
+ const accessWaiters = /* @__PURE__ */ new Set();
22
29
  const client = axios.create({ timeout: options.timeoutMs ?? 3e4 });
23
30
  function waitForTarget(signal) {
24
31
  if (currentTarget) return Promise.resolve();
@@ -52,14 +59,50 @@ function createHttpTransport(options = {}) {
52
59
  if (!currentTarget) return;
53
60
  for (const waiter of [...targetWaiters]) waiter.resolve();
54
61
  }
62
+ function waitForNewAccess(staleAccess, signal, refresh) {
63
+ if (currentTarget && currentTarget.tokens.access !== staleAccess) return Promise.resolve(true);
64
+ return new Promise((resolve, reject) => {
65
+ const waiter = {
66
+ staleAccess,
67
+ resolve: (fresh) => {
68
+ cleanup();
69
+ resolve(fresh);
70
+ },
71
+ reject: (reason) => {
72
+ cleanup();
73
+ reject(reason);
74
+ }
75
+ };
76
+ const timer = setTimeout(() => waiter.reject(/* @__PURE__ */ new Error("http-transport: token refresh timed out")), authRetryWaitMs);
77
+ const onAbort = () => waiter.reject(new axios.Cancel("http-transport: aborted"));
78
+ function cleanup() {
79
+ clearTimeout(timer);
80
+ accessWaiters.delete(waiter);
81
+ signal?.removeEventListener?.("abort", onAbort);
82
+ }
83
+ if (signal?.aborted) {
84
+ waiter.reject(new axios.Cancel("http-transport: aborted"));
85
+ return;
86
+ }
87
+ signal?.addEventListener?.("abort", onAbort);
88
+ accessWaiters.add(waiter);
89
+ refresh?.then((coming) => {
90
+ if (!coming) waiter.resolve(false);
91
+ });
92
+ });
93
+ }
94
+ function flushAccessWaiters() {
95
+ for (const waiter of [...accessWaiters]) if (!currentTarget) waiter.reject(new axios.Cancel("http-transport: no target"));
96
+ else if (currentTarget.tokens.access !== waiter.staleAccess) waiter.resolve(true);
97
+ }
55
98
  client.interceptors.request.use(async (config) => {
56
99
  if (!currentTarget) await waitForTarget(config.signal);
57
100
  const target = currentTarget;
58
101
  if (!target) throw new axios.Cancel("http-transport: no target");
102
+ config._sentAccess = target.tokens.access;
59
103
  if (!config.baseURL) config.baseURL = `${target.endpoint.url}${apiPrefix}`;
60
104
  if (options.authorize) await options.authorize(config, target);
61
105
  else config.headers.set("Authorization", `Bearer ${target.tokens.access}`);
62
- if (target.tokens.proxySession) config.headers.set("X-Proxy-Session", target.tokens.proxySession);
63
106
  return config;
64
107
  });
65
108
  client.interceptors.response.use((response) => {
@@ -69,7 +112,7 @@ function createHttpTransport(options = {}) {
69
112
  emitter.emit("up", void 0);
70
113
  }
71
114
  return response;
72
- }, (error) => {
115
+ }, async (error) => {
73
116
  if (axios.isCancel(error)) return Promise.reject(error);
74
117
  if (!error.response) {
75
118
  markDown(error.message ?? "network");
@@ -77,13 +120,34 @@ function createHttpTransport(options = {}) {
77
120
  }
78
121
  if (error.response.status === 401) {
79
122
  logger?.debug(`auth-error 401 (${error.config?.url ?? "unknown-url"})`);
123
+ const refreshes = [];
124
+ const config = error.config;
125
+ const message = extractMessage(error);
126
+ const tokenRejected = !options.authorize && message !== void 0 && TOKEN_REJECTED_MESSAGES.has(message);
80
127
  emitter.emit("auth-error", {
81
128
  status: 401,
82
- message: extractMessage(error)
129
+ message,
130
+ access: tokenRejected ? config?._sentAccess : void 0,
131
+ holdFor: (refresh) => refreshes.push(refresh)
83
132
  });
133
+ if (config?._sentAccess && !config._authRetried && !disposed) {
134
+ config._authRetried = true;
135
+ if (!await shouldRetryAuth(config._sentAccess, refreshes, config.signal)) return Promise.reject(error);
136
+ logger?.debug(`retry after 401 (${config.url ?? "unknown-url"})`);
137
+ return client(config);
138
+ }
84
139
  }
85
140
  return Promise.reject(error);
86
141
  });
142
+ async function shouldRetryAuth(sentAccess, refreshes, signal) {
143
+ if (refreshes.length === 0 && options.authorize) return true;
144
+ const refresh = refreshes.length > 0 ? Promise.all(refreshes).then((results) => results.some(Boolean), () => false) : void 0;
145
+ try {
146
+ return await waitForNewAccess(sentAccess, signal, refresh) || Boolean(options.authorize);
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
87
151
  function markDown(reason) {
88
152
  if (status.up || status.lastError !== reason) {
89
153
  status = {
@@ -102,10 +166,12 @@ function createHttpTransport(options = {}) {
102
166
  if (!target) {
103
167
  status = { up: false };
104
168
  emitter.emit("down", { reason: "detached" });
169
+ flushAccessWaiters();
105
170
  return;
106
171
  }
107
172
  if (endpointChanged) status = { up: false };
108
173
  flushTargetWaiters();
174
+ flushAccessWaiters();
109
175
  }
110
176
  function health() {
111
177
  return status;
@@ -118,6 +184,7 @@ function createHttpTransport(options = {}) {
118
184
  currentTarget = null;
119
185
  status = { up: false };
120
186
  for (const waiter of [...targetWaiters]) waiter.reject(new axios.Cancel("http-transport: disposed"));
187
+ for (const waiter of [...accessWaiters]) waiter.reject(new axios.Cancel("http-transport: disposed"));
121
188
  emitter.clear();
122
189
  }
123
190
  return {
@@ -6,6 +6,7 @@ export interface NativeHttpRequestInit {
6
6
  headers: Record<string, string>;
7
7
  params?: Record<string, string>;
8
8
  data?: unknown;
9
+ dataType?: 'file' | 'formData';
9
10
  responseType: NativeResponseType;
10
11
  connectTimeout?: number;
11
12
  readTimeout?: number;
@@ -24,10 +25,11 @@ export interface NativeHttpBinding {
24
25
  export declare function isAbsoluteURL(url: string): boolean;
25
26
  export declare function buildFullUrl(config: InternalAxiosRequestConfig): string;
26
27
  export declare function isPublicFqdnUrl(url: string): boolean;
27
- export declare function shouldUseNativeHttp(config: InternalAxiosRequestConfig): boolean;
28
+ export declare function shouldUseNativeHttp(config: InternalAxiosRequestConfig, exclude?: (url: string) => boolean): boolean;
28
29
  export declare function createNativeHttpAdapter(binding: NativeHttpBinding): AxiosAdapter;
29
30
  export declare function applyNativeHttp(client: AxiosInstance, opts: {
30
31
  enabled: boolean;
32
+ exclude?: (url: string) => boolean;
31
33
  } & NativeHttpBinding): void;
32
34
  export declare function normalizeBody(config: InternalAxiosRequestConfig): unknown;
33
35
  export declare function mapResponseType(type?: ResponseType): NativeResponseType;
@@ -1,5 +1,6 @@
1
1
  import axios, { AxiosError, AxiosHeaders, CanceledError } from "axios";
2
2
  //#region src/transports/nativeHttp.ts
3
+ var BASE64_CHUNK = 24576;
3
4
  function isAbsoluteURL(url) {
4
5
  return /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url);
5
6
  }
@@ -21,37 +22,44 @@ function isPublicFqdnUrl(url) {
21
22
  if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
22
23
  return host.includes(".");
23
24
  }
24
- function shouldUseNativeHttp(config) {
25
- return !config.withCredentials && isPublicFqdnUrl(buildFullUrl(config));
25
+ function shouldUseNativeHttp(config, exclude) {
26
+ if (config.withCredentials) return false;
27
+ const url = buildFullUrl(config);
28
+ return isPublicFqdnUrl(url) && !exclude?.(url);
26
29
  }
27
30
  function createNativeHttpAdapter(binding) {
28
31
  return async (config) => {
29
32
  throwIfAborted(config.signal);
30
33
  const headers = flattenHeaders(config.headers);
31
34
  if (binding.extraHeaders) Object.assign(headers, binding.extraHeaders);
35
+ const body = await encodeBody(config, headers);
36
+ throwIfAborted(config.signal);
37
+ if (body.contentType) setContentType(headers, body.contentType);
32
38
  const timeout = typeof config.timeout === "number" && config.timeout > 0 ? config.timeout : void 0;
33
39
  const pending = binding.request({
34
40
  url: buildFullUrl(config),
35
41
  method: (config.method ?? "get").toUpperCase(),
36
42
  headers,
37
43
  params: normalizeParams(config.params),
38
- data: normalizeBody(config),
44
+ data: body.data,
45
+ dataType: body.dataType,
39
46
  responseType: mapResponseType(config.responseType),
40
47
  connectTimeout: timeout,
41
48
  readTimeout: timeout
42
49
  });
43
50
  let res;
44
51
  try {
45
- res = await raceAbort(pending, config.signal);
52
+ res = await raceNative(pending, config, timeout);
46
53
  } catch (err) {
47
- if (err instanceof CanceledError) throw err;
54
+ if (err instanceof AxiosError) throw err;
48
55
  throw new AxiosError(err instanceof Error ? err.message : "Network Error", AxiosError.ERR_NETWORK, config, pending);
49
56
  }
57
+ const responseHeaders = AxiosHeaders.from(res.headers ?? {});
50
58
  const response = {
51
- data: res.data,
59
+ data: decodeBinaryResponse(res.data, config.responseType, String(responseHeaders.get("Content-Type") ?? "")),
52
60
  status: res.status,
53
61
  statusText: "",
54
- headers: AxiosHeaders.from(res.headers ?? {}),
62
+ headers: responseHeaders,
55
63
  config,
56
64
  request: pending
57
65
  };
@@ -63,18 +71,30 @@ function applyNativeHttp(client, opts) {
63
71
  if (!opts.enabled) return;
64
72
  const fallback = axios.getAdapter(client.defaults.adapter ?? axios.defaults.adapter);
65
73
  const native = createNativeHttpAdapter(opts);
66
- client.defaults.adapter = (config) => shouldUseNativeHttp(config) ? native(config) : fallback(config);
74
+ client.defaults.adapter = (config) => shouldUseNativeHttp(config, opts.exclude) ? native(config) : fallback(config);
67
75
  }
68
76
  function throwIfAborted(signal) {
69
77
  if (signal?.aborted) throw new CanceledError("canceled");
70
78
  }
71
- function raceAbort(pending, signal) {
72
- if (!signal || typeof signal.addEventListener !== "function") return pending;
73
- if (signal.aborted) return Promise.reject(new CanceledError("canceled"));
79
+ function raceNative(pending, config, timeout) {
80
+ const signal = config.signal;
81
+ const abortable = !!signal && typeof signal.addEventListener === "function";
82
+ if (!abortable && !timeout) return pending;
83
+ if (signal?.aborted) return Promise.reject(new CanceledError("canceled"));
74
84
  return new Promise((resolve, reject) => {
75
- const onAbort = () => reject(new CanceledError("canceled"));
76
- signal.addEventListener("abort", onAbort, { once: true });
77
- const cleanup = () => signal.removeEventListener?.("abort", onAbort);
85
+ const onAbort = () => {
86
+ cleanup();
87
+ reject(new CanceledError("canceled"));
88
+ };
89
+ const timer = timeout ? setTimeout(() => {
90
+ cleanup();
91
+ reject(timeoutError(config, timeout, pending));
92
+ }, timeout) : void 0;
93
+ if (abortable) signal.addEventListener("abort", onAbort, { once: true });
94
+ function cleanup() {
95
+ clearTimeout(timer);
96
+ signal?.removeEventListener?.("abort", onAbort);
97
+ }
78
98
  pending.then((value) => {
79
99
  cleanup();
80
100
  resolve(value);
@@ -84,6 +104,10 @@ function raceAbort(pending, signal) {
84
104
  });
85
105
  });
86
106
  }
107
+ function timeoutError(config, timeout, request) {
108
+ const code = config.transitional?.clarifyTimeoutError ? AxiosError.ETIMEDOUT : AxiosError.ECONNABORTED;
109
+ return new AxiosError(config.timeoutErrorMessage || `timeout of ${timeout}ms exceeded`, code, config, request);
110
+ }
87
111
  function flattenHeaders(headers) {
88
112
  const out = {};
89
113
  if (!headers) return out;
@@ -109,6 +133,86 @@ function normalizeBody(config) {
109
133
  } catch {}
110
134
  return data;
111
135
  }
136
+ async function encodeBody(config, headers) {
137
+ const data = config.data;
138
+ if (data instanceof FormData) return {
139
+ data: await encodeFormData(data),
140
+ dataType: "formData",
141
+ contentType: "multipart/form-data"
142
+ };
143
+ const bytes = await binaryOf(data);
144
+ if (!bytes) return { data: normalizeBody(config) };
145
+ const explicit = headerValue(headers, "Content-Type");
146
+ const contentType = explicit && !explicit.includes("application/x-www-form-urlencoded") ? explicit : data instanceof Blob && data.type || "application/octet-stream";
147
+ return {
148
+ data: bytesToBase64(bytes),
149
+ dataType: "file",
150
+ contentType
151
+ };
152
+ }
153
+ async function encodeFormData(form) {
154
+ const pairs = [];
155
+ form.forEach((value, key) => pairs.push([key, value]));
156
+ const entries = [];
157
+ for (const [key, value] of pairs) {
158
+ if (typeof value === "string") {
159
+ entries.push({
160
+ key,
161
+ value,
162
+ type: "string"
163
+ });
164
+ continue;
165
+ }
166
+ entries.push({
167
+ key,
168
+ value: bytesToBase64(new Uint8Array(await value.arrayBuffer())),
169
+ type: "base64File",
170
+ fileName: value.name || "blob",
171
+ contentType: value.type || "application/octet-stream"
172
+ });
173
+ }
174
+ return entries;
175
+ }
176
+ async function binaryOf(data) {
177
+ if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
178
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
179
+ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
180
+ return null;
181
+ }
182
+ function headerValue(headers, name) {
183
+ const wanted = name.toLowerCase();
184
+ for (const key of Object.keys(headers)) if (key.toLowerCase() === wanted) return headers[key];
185
+ }
186
+ function setContentType(headers, value) {
187
+ for (const key of Object.keys(headers)) if (key.toLowerCase() === "content-type") delete headers[key];
188
+ headers["Content-Type"] = value;
189
+ }
190
+ function decodeBinaryResponse(data, type, contentType) {
191
+ if (type !== "arraybuffer" && type !== "blob" || typeof data !== "string" || contentType.includes("application/json")) return data;
192
+ if (/[ \t\f]/.test(data)) return data;
193
+ let bytes;
194
+ try {
195
+ bytes = base64ToBytes(data);
196
+ } catch {
197
+ return data;
198
+ }
199
+ return type === "blob" ? new Blob([bytes], { type: contentType }) : bytes.buffer;
200
+ }
201
+ function bytesToBase64(bytes) {
202
+ const native = bytes.toBase64;
203
+ if (typeof native === "function") return native.call(bytes);
204
+ let out = "";
205
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK) out += btoa(String.fromCharCode.apply(null, bytes.subarray(i, i + BASE64_CHUNK)));
206
+ return out;
207
+ }
208
+ function base64ToBytes(text) {
209
+ const native = Uint8Array.fromBase64;
210
+ if (typeof native === "function") return native(text);
211
+ const binary = atob(text);
212
+ const bytes = new Uint8Array(binary.length);
213
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
214
+ return bytes;
215
+ }
112
216
  function mapResponseType(type) {
113
217
  switch (type) {
114
218
  case "blob": return "blob";