@camera.ui/transport 0.0.30 → 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;
@@ -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,
@@ -54,14 +59,14 @@ function createHttpTransport(options = {}) {
54
59
  if (!currentTarget) return;
55
60
  for (const waiter of [...targetWaiters]) waiter.resolve();
56
61
  }
57
- function waitForNewAccess(staleAccess, signal) {
58
- if (currentTarget && currentTarget.tokens.access !== staleAccess) return Promise.resolve();
62
+ function waitForNewAccess(staleAccess, signal, refresh) {
63
+ if (currentTarget && currentTarget.tokens.access !== staleAccess) return Promise.resolve(true);
59
64
  return new Promise((resolve, reject) => {
60
65
  const waiter = {
61
66
  staleAccess,
62
- resolve: () => {
67
+ resolve: (fresh) => {
63
68
  cleanup();
64
- resolve();
69
+ resolve(fresh);
65
70
  },
66
71
  reject: (reason) => {
67
72
  cleanup();
@@ -81,11 +86,14 @@ function createHttpTransport(options = {}) {
81
86
  }
82
87
  signal?.addEventListener?.("abort", onAbort);
83
88
  accessWaiters.add(waiter);
89
+ refresh?.then((coming) => {
90
+ if (!coming) waiter.resolve(false);
91
+ });
84
92
  });
85
93
  }
86
94
  function flushAccessWaiters() {
87
95
  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();
96
+ else if (currentTarget.tokens.access !== waiter.staleAccess) waiter.resolve(true);
89
97
  }
90
98
  client.interceptors.request.use(async (config) => {
91
99
  if (!currentTarget) await waitForTarget(config.signal);
@@ -95,7 +103,6 @@ function createHttpTransport(options = {}) {
95
103
  if (!config.baseURL) config.baseURL = `${target.endpoint.url}${apiPrefix}`;
96
104
  if (options.authorize) await options.authorize(config, target);
97
105
  else config.headers.set("Authorization", `Bearer ${target.tokens.access}`);
98
- if (target.tokens.proxySession) config.headers.set("X-Proxy-Session", target.tokens.proxySession);
99
106
  return config;
100
107
  });
101
108
  client.interceptors.response.use((response) => {
@@ -113,24 +120,34 @@ function createHttpTransport(options = {}) {
113
120
  }
114
121
  if (error.response.status === 401) {
115
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);
116
127
  emitter.emit("auth-error", {
117
128
  status: 401,
118
- message: extractMessage(error)
129
+ message,
130
+ access: tokenRejected ? config?._sentAccess : void 0,
131
+ holdFor: (refresh) => refreshes.push(refresh)
119
132
  });
120
- const config = error.config;
121
133
  if (config?._sentAccess && !config._authRetried && !disposed) {
122
134
  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"})`);
135
+ if (!await shouldRetryAuth(config._sentAccess, refreshes, config.signal)) return Promise.reject(error);
136
+ logger?.debug(`retry after 401 (${config.url ?? "unknown-url"})`);
129
137
  return client(config);
130
138
  }
131
139
  }
132
140
  return Promise.reject(error);
133
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
+ }
134
151
  function markDown(reason) {
135
152
  if (status.up || status.lastError !== reason) {
136
153
  status = {
@@ -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";