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.
- package/README.md +90 -5
- package/browser/package.json +8 -0
- package/dist/agent.d.ts +9 -1
- package/dist/agent.js +44 -68
- package/dist/browser/client.d.ts +59 -0
- package/dist/browser/client.js +608 -0
- package/dist/browser/index.d.ts +34 -0
- package/dist/browser/index.js +65 -0
- package/dist/browser/instrument.d.ts +39 -0
- package/dist/browser/instrument.js +217 -0
- package/dist/browser/transport.d.ts +43 -0
- package/dist/browser/transport.js +168 -0
- package/dist/browser/types.d.ts +94 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/version.d.ts +2 -0
- package/dist/browser/version.js +5 -0
- package/dist/browser/vitals.d.ts +16 -0
- package/dist/browser/vitals.js +135 -0
- package/dist/cli.js +0 -0
- package/dist/config.d.ts +8 -7
- package/dist/config.js +12 -24
- package/dist/esm/browser/client.js +601 -0
- package/dist/esm/browser/index.js +52 -0
- package/dist/esm/browser/instrument.js +210 -0
- package/dist/esm/browser/transport.js +164 -0
- package/dist/esm/browser/types.js +1 -0
- package/dist/esm/browser/version.js +2 -0
- package/dist/esm/browser/vitals.js +132 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/redact.js +224 -0
- package/dist/esm/types.js +1 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +12 -6
- package/dist/socket-transport.d.ts +58 -0
- package/dist/socket-transport.js +157 -0
- package/package.json +31 -4
- package/scripts/mark-esm.js +6 -0
- package/src/agent.ts +46 -73
- package/src/browser/client.ts +686 -0
- package/src/browser/index.ts +74 -0
- package/src/browser/instrument.ts +275 -0
- package/src/browser/transport.ts +184 -0
- package/src/browser/types.ts +105 -0
- package/src/browser/version.ts +2 -0
- package/src/browser/vitals.ts +149 -0
- package/src/config.ts +12 -23
- package/src/redact.ts +12 -6
- package/src/socket-transport.ts +188 -0
- package/test/agent.test.js +47 -51
- package/test/browser.test.js +328 -0
- package/test/console.test.js +11 -10
- package/test/helpers.js +54 -1
- package/test/middleware.test.js +5 -5
- package/test/proxy.test.js +4 -4
- package/tsconfig.esm.json +14 -0
- 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
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
90
|
+
* and `https://gateway.internal/midline` all resolve to the same origin.
|
|
90
91
|
*/
|
|
91
|
-
export function
|
|
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.
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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" && !
|
|
197
|
+
if (typeof raw === "object" && !ArrayBuffer.isView(raw)) {
|
|
194
198
|
return this.fit(this.value(raw), maxBytes);
|
|
195
199
|
}
|
|
196
200
|
|
|
197
|
-
const text =
|
|
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 (
|
|
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 =
|
|
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)
|
|
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
|
+
}
|