midline-agent 0.3.0 → 0.4.1

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.
Files changed (56) hide show
  1. package/README.md +90 -5
  2. package/browser/package.json +8 -0
  3. package/dist/agent.d.ts +9 -1
  4. package/dist/agent.js +44 -68
  5. package/dist/browser/client.d.ts +59 -0
  6. package/dist/browser/client.js +608 -0
  7. package/dist/browser/index.d.ts +34 -0
  8. package/dist/browser/index.js +65 -0
  9. package/dist/browser/instrument.d.ts +39 -0
  10. package/dist/browser/instrument.js +217 -0
  11. package/dist/browser/transport.d.ts +43 -0
  12. package/dist/browser/transport.js +168 -0
  13. package/dist/browser/types.d.ts +94 -0
  14. package/dist/browser/types.js +2 -0
  15. package/dist/browser/version.d.ts +2 -0
  16. package/dist/browser/version.js +5 -0
  17. package/dist/browser/vitals.d.ts +16 -0
  18. package/dist/browser/vitals.js +135 -0
  19. package/dist/cli.js +0 -0
  20. package/dist/config.d.ts +8 -7
  21. package/dist/config.js +12 -24
  22. package/dist/esm/browser/client.js +601 -0
  23. package/dist/esm/browser/index.js +52 -0
  24. package/dist/esm/browser/instrument.js +210 -0
  25. package/dist/esm/browser/transport.js +164 -0
  26. package/dist/esm/browser/types.js +1 -0
  27. package/dist/esm/browser/version.js +2 -0
  28. package/dist/esm/browser/vitals.js +132 -0
  29. package/dist/esm/package.json +1 -0
  30. package/dist/esm/redact.js +224 -0
  31. package/dist/esm/types.js +1 -0
  32. package/dist/redact.d.ts +3 -0
  33. package/dist/redact.js +12 -6
  34. package/dist/socket-transport.d.ts +58 -0
  35. package/dist/socket-transport.js +157 -0
  36. package/package.json +31 -4
  37. package/scripts/mark-esm.js +6 -0
  38. package/src/agent.ts +46 -73
  39. package/src/browser/client.ts +686 -0
  40. package/src/browser/index.ts +74 -0
  41. package/src/browser/instrument.ts +275 -0
  42. package/src/browser/transport.ts +184 -0
  43. package/src/browser/types.ts +105 -0
  44. package/src/browser/version.ts +2 -0
  45. package/src/browser/vitals.ts +149 -0
  46. package/src/config.ts +12 -23
  47. package/src/redact.ts +12 -6
  48. package/src/socket-transport.ts +188 -0
  49. package/test/agent.test.js +47 -51
  50. package/test/browser.test.js +328 -0
  51. package/test/console.test.js +11 -10
  52. package/test/helpers.js +54 -1
  53. package/test/middleware.test.js +5 -5
  54. package/test/proxy.test.js +4 -4
  55. package/tsconfig.esm.json +14 -0
  56. package/src/transport.ts +0 -125
@@ -0,0 +1,149 @@
1
+ export type VitalName = "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
2
+ export type VitalRating = "good" | "needs-improvement" | "poor";
3
+
4
+ export interface VitalsReport {
5
+ vitals: Partial<Record<VitalName, { value: number; rating: VitalRating }>>;
6
+ navigationType?: string;
7
+ }
8
+
9
+ /** web.dev thresholds: at or under the first is good, over the second is poor. */
10
+ const THRESHOLDS: Record<VitalName, [number, number]> = {
11
+ LCP: [2500, 4000],
12
+ INP: [200, 500],
13
+ CLS: [0.1, 0.25],
14
+ FCP: [1800, 3000],
15
+ TTFB: [800, 1800],
16
+ };
17
+
18
+ const rate = (name: VitalName, value: number): VitalRating =>
19
+ value <= THRESHOLDS[name][0] ? "good" : value <= THRESHOLDS[name][1] ? "needs-improvement" : "poor";
20
+
21
+ type Entry = PerformanceEntry & {
22
+ value?: number;
23
+ hadRecentInput?: boolean;
24
+ interactionId?: number;
25
+ activationStart?: number;
26
+ responseStart?: number;
27
+ type?: string;
28
+ };
29
+
30
+ /**
31
+ * Core Web Vitals without a dependency, measured the way the web-vitals library
32
+ * does and reported once, when the page is first hidden (the last moment a
33
+ * report reliably gets out). Browsers that don't expose an entry type simply
34
+ * omit that metric: Safari has no LCP, INP or CLS.
35
+ */
36
+ export function observeVitals(win: Window, onReport: (report: VitalsReport) => void): () => void {
37
+ const Observer = (win as Window & { PerformanceObserver?: typeof PerformanceObserver }).PerformanceObserver;
38
+ const supported: readonly string[] = Observer?.supportedEntryTypes ?? [];
39
+ const perf = win.performance;
40
+ const doc = win.document;
41
+
42
+ const values: Partial<Record<VitalName, number>> = {};
43
+ const observers: Array<{ observer: PerformanceObserver; handle: (entries: Entry[]) => void }> = [];
44
+
45
+ let activationStart = 0;
46
+ let navigationType: string | undefined;
47
+ try {
48
+ const navigation = perf?.getEntriesByType?.("navigation")?.[0] as Entry | undefined;
49
+ if (navigation) {
50
+ activationStart = navigation.activationStart ?? 0;
51
+ navigationType = navigation.type;
52
+ if (typeof navigation.responseStart === "number" && navigation.responseStart > 0) {
53
+ values.TTFB = Math.max(0, navigation.responseStart - activationStart);
54
+ }
55
+ }
56
+ } catch {
57
+ // no navigation timing
58
+ }
59
+
60
+ const observe = (type: string, handle: (entries: Entry[]) => void, extra: Record<string, unknown> = {}) => {
61
+ if (!Observer || !supported.includes(type)) return;
62
+ try {
63
+ const observer = new Observer((list) => handle(list.getEntries() as Entry[]));
64
+ observer.observe({ type, buffered: true, ...extra } as PerformanceObserverInit);
65
+ observers.push({ observer, handle });
66
+ } catch {
67
+ // unsupported option in this browser
68
+ }
69
+ };
70
+
71
+ observe("paint", (entries) => {
72
+ for (const entry of entries) {
73
+ if (entry.name === "first-contentful-paint") values.FCP = Math.max(0, entry.startTime - activationStart);
74
+ }
75
+ });
76
+
77
+ observe("largest-contentful-paint", (entries) => {
78
+ const last = entries[entries.length - 1];
79
+ if (last) values.LCP = Math.max(0, last.startTime - activationStart);
80
+ });
81
+
82
+ // CLS is the largest session window: shifts less than 1s apart, at most 5s long.
83
+ let windowValue = 0;
84
+ let windowStart = 0;
85
+ let windowLast = 0;
86
+ observe("layout-shift", (entries) => {
87
+ for (const entry of entries) {
88
+ if (entry.hadRecentInput || typeof entry.value !== "number") continue;
89
+ if (windowValue && entry.startTime - windowLast < 1000 && entry.startTime - windowStart < 5000) {
90
+ windowValue += entry.value;
91
+ } else {
92
+ windowValue = entry.value;
93
+ windowStart = entry.startTime;
94
+ }
95
+ windowLast = entry.startTime;
96
+ values.CLS = Math.max(values.CLS ?? 0, windowValue);
97
+ }
98
+ });
99
+
100
+ // INP: the slowest interaction, or near the 98th percentile on busy pages.
101
+ const interactions = new Map<number, number>();
102
+ const recordInteractions = (entries: Entry[]) => {
103
+ for (const entry of entries) {
104
+ if (!entry.interactionId) continue;
105
+ interactions.set(entry.interactionId, Math.max(interactions.get(entry.interactionId) ?? 0, entry.duration));
106
+ }
107
+ };
108
+ observe("event", recordInteractions, { durationThreshold: 40 });
109
+ observe("first-input", recordInteractions);
110
+
111
+ let reported = false;
112
+ const report = () => {
113
+ if (reported) return;
114
+ reported = true;
115
+ for (const { observer, handle } of observers) {
116
+ try {
117
+ handle(observer.takeRecords() as Entry[]);
118
+ observer.disconnect();
119
+ } catch {
120
+ // already disconnected
121
+ }
122
+ }
123
+ if (interactions.size) {
124
+ const durations = [...interactions.values()].sort((a, b) => b - a);
125
+ values.INP = durations[Math.min(durations.length - 1, Math.floor(durations.length / 50))];
126
+ }
127
+
128
+ const vitals: VitalsReport["vitals"] = {};
129
+ for (const name of Object.keys(values) as VitalName[]) {
130
+ const raw = values[name];
131
+ if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
132
+ const value = name === "CLS" ? Math.round(raw * 10_000) / 10_000 : Math.round(raw);
133
+ vitals[name] = { value, rating: rate(name, value) };
134
+ }
135
+ if (Object.keys(vitals).length) onReport({ vitals, navigationType });
136
+ };
137
+
138
+ const onVisibility = () => {
139
+ if (doc?.visibilityState === "hidden") report();
140
+ };
141
+ doc?.addEventListener("visibilitychange", onVisibility, true);
142
+ win.addEventListener("pagehide", report, true);
143
+
144
+ return () => {
145
+ doc?.removeEventListener("visibilitychange", onVisibility, true);
146
+ win.removeEventListener("pagehide", report, true);
147
+ for (const { observer } of observers) observer.disconnect();
148
+ };
149
+ }
package/src/config.ts CHANGED
@@ -4,7 +4,6 @@ import * as tls from "tls";
4
4
  import { CaInput, CaptureOptions, MidlineConfig } from "./types";
5
5
 
6
6
  export const DEFAULT_ENDPOINT = "https://api.usemidline.com";
7
- export const INGEST_PATH = "/api/api-monitor/events";
8
7
 
9
8
  /** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
10
9
  export class ConfigError extends Error {
@@ -25,8 +24,8 @@ export interface ResolvedCapture {
25
24
  export interface ResolvedConfig {
26
25
  apiKey: string;
27
26
  serviceName?: string;
28
- ingestUrl: URL;
29
- batchUrl: URL;
27
+ /** The Midline server's origin; the ingest gateway lives at `${socketOrigin}/ingest`. */
28
+ socketOrigin: URL;
30
29
  /** Full trust store for the Midline endpoint, or undefined for Node's default. */
31
30
  ca?: Array<string | Buffer>;
32
31
  hasCustomCa: boolean;
@@ -83,12 +82,14 @@ export function isLoopback(hostname: string): boolean {
83
82
  }
84
83
 
85
84
  /**
86
- * Turns whatever the user configured into the ingest URL.
87
- *
85
+ * Turns whatever the user configured into the Midline server's origin — the
86
+ * agent connects to `${origin}/ingest` over socket.io, so only the origin
87
+ * matters now. Tolerates an old-style full ingest URL some existing
88
+ * `MIDLINE_ENDPOINT` values still carry (from before the socket.io transport):
88
89
  * `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
89
- * and `https://gateway.internal/midline` all work; the last is treated as a prefix.
90
+ * and `https://gateway.internal/midline` all resolve to the same origin.
90
91
  */
91
- export function resolveIngestUrl(endpoint: string): URL {
92
+ export function resolveEndpointOrigin(endpoint: string): URL {
92
93
  let url: URL;
93
94
  try {
94
95
  url = new URL(endpoint);
@@ -109,18 +110,7 @@ export function resolveIngestUrl(endpoint: string): URL {
109
110
  throw new ConfigError("endpoint must not contain credentials; pass the key as apiKey");
110
111
  }
111
112
 
112
- url.search = "";
113
- url.hash = "";
114
-
115
- const path = url.pathname.replace(/\/+$/, "");
116
- if (path.endsWith(`${INGEST_PATH}/batch`)) {
117
- url.pathname = path.slice(0, -"/batch".length);
118
- } else if (path.endsWith(INGEST_PATH)) {
119
- url.pathname = path;
120
- } else {
121
- url.pathname = `${path}${INGEST_PATH}`;
122
- }
123
- return url;
113
+ return new URL(url.origin);
124
114
  }
125
115
 
126
116
  /**
@@ -198,18 +188,17 @@ export function resolveCapture(capture: CaptureOptions | undefined): ResolvedCap
198
188
 
199
189
  export function resolveConfig(config: MidlineConfig): ResolvedConfig {
200
190
  const apiKey = (config.apiKey ?? env("MIDLINE_API_KEY") ?? "").trim();
201
- const ingestUrl = resolveIngestUrl(config.endpoint || env("MIDLINE_ENDPOINT") || DEFAULT_ENDPOINT);
191
+ const socketOrigin = resolveEndpointOrigin(config.endpoint || env("MIDLINE_ENDPOINT") || DEFAULT_ENDPOINT);
202
192
  const extraCa = loadCa(config.ca ?? env("MIDLINE_CUSTOM_CA"), "ca / MIDLINE_CUSTOM_CA");
203
193
 
204
- if (extraCa && ingestUrl.protocol !== "https:") {
194
+ if (extraCa && socketOrigin.protocol !== "https:") {
205
195
  throw new ConfigError("ca / MIDLINE_CUSTOM_CA is set but the endpoint is not https://");
206
196
  }
207
197
 
208
198
  return {
209
199
  apiKey,
210
200
  serviceName: config.serviceName ?? env("MIDLINE_SERVICE_NAME"),
211
- ingestUrl,
212
- batchUrl: new URL(`${ingestUrl.pathname}/batch`, ingestUrl),
201
+ socketOrigin,
213
202
  ca: trustStore(extraCa),
214
203
  hasCustomCa: Boolean(extraCa),
215
204
  environment: config.environment ?? env("MIDLINE_ENVIRONMENT"),
package/src/redact.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  /**
2
+ * Shared by the Node agent and the browser SDK, so nothing here may touch a
3
+ * Node-only global (Buffer, process) — TextEncoder and URLSearchParams exist in both.
4
+ *
2
5
  * Redaction happens in the host process, before an event is queued. Whatever is
3
6
  * removed here never reaches a socket, a log line or the Midline server.
4
7
  *
@@ -124,7 +127,8 @@ export class Redactor {
124
127
  if (typeof input === "bigint") return input.toString();
125
128
  if (typeof input === "function" || typeof input === "symbol") return undefined;
126
129
  if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input.toISOString();
127
- if (Buffer.isBuffer(input) || ArrayBuffer.isView(input)) {
130
+ // Buffer is a Uint8Array, so this also covers it without naming a Node-only global.
131
+ if (ArrayBuffer.isView(input)) {
128
132
  return `[Binary ${(input as ArrayBufferView).byteLength} bytes]`;
129
133
  }
130
134
  if (typeof input !== "object") return undefined;
@@ -190,11 +194,13 @@ export class Redactor {
190
194
 
191
195
  const type = (contentType || "").toLowerCase();
192
196
 
193
- if (typeof raw === "object" && !Buffer.isBuffer(raw)) {
197
+ if (typeof raw === "object" && !ArrayBuffer.isView(raw)) {
194
198
  return this.fit(this.value(raw), maxBytes);
195
199
  }
196
200
 
197
- const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
201
+ const text = ArrayBuffer.isView(raw)
202
+ ? new TextDecoder().decode(raw as ArrayBufferView)
203
+ : String(raw);
198
204
 
199
205
  if (type.includes("json")) {
200
206
  try {
@@ -214,18 +220,18 @@ export class Redactor {
214
220
 
215
221
  private fit(value: unknown, maxBytes: number): { body?: unknown; truncated?: boolean } {
216
222
  const serialized = JSON.stringify(value) ?? "";
217
- if (Buffer.byteLength(serialized) <= maxBytes) {
223
+ if (new TextEncoder().encode(serialized).length <= maxBytes) {
218
224
  return { body: value };
219
225
  }
220
226
  return this.cut(serialized, maxBytes);
221
227
  }
222
228
 
223
229
  private cut(text: string, maxBytes: number): { body?: unknown; truncated?: boolean } {
224
- const bytes = Buffer.from(text);
230
+ const bytes = new TextEncoder().encode(text);
225
231
  if (bytes.length <= maxBytes) {
226
232
  return { body: text };
227
233
  }
228
234
  // Slicing bytes can split a multi-byte character; the replacement char is harmless here.
229
- return { body: bytes.subarray(0, maxBytes).toString("utf8"), truncated: true };
235
+ return { body: new TextDecoder().decode(bytes.subarray(0, maxBytes)), truncated: true };
230
236
  }
231
237
  }
@@ -0,0 +1,188 @@
1
+ import { io, Socket } from "socket.io-client";
2
+
3
+ /** Mirrors midline-core-api/src/realtime/ingest-protocol.ts's IngestAck. Duplicated,
4
+ * not shared: the two are separate packages, kept in sync by wire convention. */
5
+ export type IngestAckErrorCode = "unauthorized" | "forbidden" | "rate_limited" | "bad_request" | "too_large" | "internal_error";
6
+ export interface IngestAckOk {
7
+ ok: true;
8
+ accepted: number;
9
+ rejected: number;
10
+ }
11
+ export interface IngestAckError {
12
+ ok: false;
13
+ code: IngestAckErrorCode;
14
+ message: string;
15
+ retryAfterMs?: number;
16
+ }
17
+ export type IngestAck = IngestAckOk | IngestAckError;
18
+
19
+ const INGEST_NAMESPACE = "/ingest";
20
+ const INGEST_EVENT = "ingest";
21
+
22
+ export interface SocketTransportOptions {
23
+ apiKey: string;
24
+ /** Full trust store for the Midline endpoint, or undefined for Node's default. */
25
+ ca?: Array<string | Buffer>;
26
+ connectTimeoutMs: number;
27
+ /** Per-emit ack timeout. */
28
+ timeoutMs: number;
29
+ /** Reconnection backoff bounds, derived from the agent's own flush/backoff config (see agent.ts). */
30
+ reconnectionDelayMs: number;
31
+ reconnectionDelayMaxMs: number;
32
+ /** Nothing queued for this long -> disconnect; reconnects lazily on the next send(). */
33
+ idleDisconnectMs: number;
34
+ userAgent: string;
35
+ }
36
+
37
+ /** A transport failure with a stable `code`, matching what agent.ts's errorCode()/describe() expect. */
38
+ export class TransportError extends Error {
39
+ constructor(message: string, readonly code: string) {
40
+ super(message);
41
+ this.name = "TransportError";
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Owns one socket.io-client connection to the ingest gateway: connects lazily on
47
+ * first send, lets socket.io-client's own reconnection/backoff handle transport
48
+ * drops, and disconnects after a sustained idle period (autoUnref also unrefs the
49
+ * underlying socket, so — as with the old HTTP transport — telemetry alone never
50
+ * keeps the process alive; the idle disconnect is extra hygiene on top of that,
51
+ * not the only thing standing between this and a hung process).
52
+ */
53
+ export class SocketTransport {
54
+ private socket: Socket | null = null;
55
+ private connecting: Promise<void> | null = null;
56
+ private idleTimer: NodeJS.Timeout | null = null;
57
+
58
+ constructor(private readonly origin: URL, private readonly options: SocketTransportOptions) {}
59
+
60
+ /** Sends one batch, connecting first if needed. Resolves with the server's ack or throws. */
61
+ async send(events: Array<Record<string, unknown>>): Promise<IngestAck> {
62
+ this.clearIdleTimer();
63
+ try {
64
+ await this.ensureConnected();
65
+ return await this.emit(events);
66
+ } finally {
67
+ this.scheduleIdleDisconnect();
68
+ }
69
+ }
70
+
71
+ destroy(): void {
72
+ this.clearIdleTimer();
73
+ this.socket?.removeAllListeners();
74
+ this.socket?.disconnect();
75
+ this.socket = null;
76
+ }
77
+
78
+ private ensureConnected(): Promise<void> {
79
+ if (this.socket?.connected) return Promise.resolve();
80
+ if (this.connecting) return this.connecting;
81
+
82
+ if (!this.socket) {
83
+ this.socket = this.createSocket();
84
+ } else if (!this.socket.active) {
85
+ // Cleanly disconnected earlier (our own idle timeout, or the server closed
86
+ // it): socket.io-client won't retry on its own, so ask it to.
87
+ this.socket.connect();
88
+ }
89
+ // Otherwise the existing socket is already reconnecting on its own; just wait below.
90
+
91
+ const socket = this.socket;
92
+ this.connecting = new Promise<void>((resolve, reject) => {
93
+ const timer = setTimeout(() => {
94
+ cleanup();
95
+ reject(new TransportError(`connection not established within ${this.options.connectTimeoutMs}ms`, "ECONNECT_TIMEOUT"));
96
+ }, this.options.connectTimeoutMs);
97
+ const onConnect = () => {
98
+ cleanup();
99
+ resolve();
100
+ };
101
+ const onError = (err: unknown) => {
102
+ cleanup();
103
+ reject(classifyConnectError(err));
104
+ };
105
+ const cleanup = () => {
106
+ clearTimeout(timer);
107
+ socket.off("connect", onConnect);
108
+ socket.off("connect_error", onError);
109
+ };
110
+ socket.once("connect", onConnect);
111
+ socket.once("connect_error", onError);
112
+ }).finally(() => {
113
+ this.connecting = null;
114
+ });
115
+ return this.connecting;
116
+ }
117
+
118
+ private createSocket(): Socket {
119
+ const socket = io(`${this.origin.origin}${INGEST_NAMESPACE}`, {
120
+ auth: { apiKey: this.options.apiKey },
121
+ transports: ["websocket"],
122
+ reconnection: true,
123
+ reconnectionAttempts: Infinity,
124
+ reconnectionDelay: this.options.reconnectionDelayMs,
125
+ reconnectionDelayMax: this.options.reconnectionDelayMaxMs,
126
+ randomizationFactor: 0.5,
127
+ timeout: this.options.connectTimeoutMs,
128
+ // Never the reason the process stays alive — same property the old HTTP
129
+ // transport's unref'd keep-alive sockets had.
130
+ autoUnref: true,
131
+ forceNew: true,
132
+ ca: this.options.ca,
133
+ extraHeaders: { "user-agent": this.options.userAgent },
134
+ } as Record<string, unknown>);
135
+ return socket;
136
+ }
137
+
138
+ private emit(events: Array<Record<string, unknown>>): Promise<IngestAck> {
139
+ const socket = this.socket;
140
+ if (!socket) {
141
+ return Promise.reject(new TransportError("not connected", "ENOTCONNECTED"));
142
+ }
143
+ return new Promise<IngestAck>((resolve, reject) => {
144
+ socket.timeout(this.options.timeoutMs).emit(INGEST_EVENT, { events }, (err: Error | null, ack: IngestAck) => {
145
+ if (err) {
146
+ reject(new TransportError(`no response within ${this.options.timeoutMs}ms`, "ETIMEDOUT"));
147
+ } else {
148
+ resolve(ack);
149
+ }
150
+ });
151
+ });
152
+ }
153
+
154
+ private scheduleIdleDisconnect(): void {
155
+ this.idleTimer = setTimeout(() => {
156
+ this.idleTimer = null;
157
+ this.socket?.disconnect();
158
+ }, this.options.idleDisconnectMs);
159
+ this.idleTimer.unref?.();
160
+ }
161
+
162
+ private clearIdleTimer(): void {
163
+ if (this.idleTimer) {
164
+ clearTimeout(this.idleTimer);
165
+ this.idleTimer = null;
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * `connect_error` from a plain `io()` connect-timeout has message "timeout" and
172
+ * no further detail. Anything else wraps the real Node error (ECONNREFUSED,
173
+ * ENOTFOUND, a TLS failure, ...) inside engine.io-client's TransportError, whose
174
+ * `.description` — for the websocket transport — is a `ws` ErrorEvent exposing
175
+ * the underlying error via its public `.error` getter (mirrors the DOM
176
+ * ErrorEvent.error field; see ws/lib/event-target.js). Verified empirically
177
+ * against the installed socket.io-client/ws versions, not assumed.
178
+ */
179
+ function classifyConnectError(err: unknown): TransportError {
180
+ const anyErr = err as { message?: string; description?: { error?: { code?: string; message?: string } } };
181
+ if (anyErr?.message === "timeout") {
182
+ return new TransportError("connection timed out", "ECONNECT_TIMEOUT");
183
+ }
184
+ const inner = anyErr?.description?.error;
185
+ const code = inner?.code ?? "";
186
+ const message = inner?.message ?? anyErr?.message ?? "connection failed";
187
+ return new TransportError(message, code);
188
+ }