midline-agent 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # midline-agent
2
2
 
3
- **The Node.js SDK for Midline** — request and error monitoring with security and threat detection.
3
+ **SDK for Midline** — request and error monitoring with security and threat detection.
4
4
  It also ships **`midline-agent/browser`** for web apps: see [Browser apps](#browser-apps).
5
5
 
6
6
  Midline itself is not tied to Node. Every event lands on the same stream through a plain JSON
package/dist/agent.d.ts CHANGED
@@ -80,7 +80,15 @@ export declare class MidlineAgent {
80
80
  recordHttp(exchange: HttpExchange): void;
81
81
  private recordConsole;
82
82
  private takeConsoleToken;
83
- /** Sends whatever is buffered now, ignoring backoff, and keeps the process alive until done. */
83
+ /**
84
+ * Sends whatever is buffered now, ignoring backoff.
85
+ *
86
+ * The transport's connection is always unref'd (telemetry alone must never
87
+ * keep an otherwise-idle process alive), so this relies on something else in
88
+ * the process keeping the event loop open until the await resolves — true for
89
+ * the overwhelmingly common case (a running server), not guaranteed for a
90
+ * bare script whose last statement is `await agent.flush()`.
91
+ */
84
92
  flush(): Promise<void>;
85
93
  /** Flushes with a deadline, then closes. For graceful shutdown. */
86
94
  shutdown(timeoutMs?: number): Promise<void>;
package/dist/agent.js CHANGED
@@ -4,7 +4,7 @@ exports.MidlineAgent = exports.SDK_VERSION = void 0;
4
4
  const config_1 = require("./config");
5
5
  const console_1 = require("./console");
6
6
  const redact_1 = require("./redact");
7
- const transport_1 = require("./transport");
7
+ const socket_transport_1 = require("./socket-transport");
8
8
  exports.SDK_VERSION = (() => {
9
9
  try {
10
10
  return require("../package.json").version;
@@ -104,15 +104,19 @@ class MidlineAgent {
104
104
  return;
105
105
  }
106
106
  this.config = resolved;
107
- this.transport = new transport_1.Transport(resolved.ingestUrl, {
107
+ this.transport = new socket_transport_1.SocketTransport(resolved.socketOrigin, {
108
+ apiKey: resolved.apiKey,
108
109
  ca: resolved.ca,
109
110
  connectTimeoutMs: resolved.connectTimeoutMs,
110
111
  timeoutMs: resolved.timeoutMs,
112
+ reconnectionDelayMs: resolved.flushIntervalMs,
113
+ reconnectionDelayMaxMs: resolved.maxRetryDelayMs,
114
+ idleDisconnectMs: Math.max(5 * resolved.flushIntervalMs, 30000),
111
115
  userAgent: `midline-agent/${exports.SDK_VERSION} node/${process.version}`,
112
116
  });
113
117
  this.timer = setInterval(() => {
114
118
  this.consoleCapture?.flushPending();
115
- void this.drain(false);
119
+ void this.drain();
116
120
  }, resolved.flushIntervalMs);
117
121
  // Telemetry must never be the reason a process refuses to exit.
118
122
  this.timer.unref?.();
@@ -212,7 +216,15 @@ class MidlineAgent {
212
216
  this.consoleTokens -= 1;
213
217
  return true;
214
218
  }
215
- /** Sends whatever is buffered now, ignoring backoff, and keeps the process alive until done. */
219
+ /**
220
+ * Sends whatever is buffered now, ignoring backoff.
221
+ *
222
+ * The transport's connection is always unref'd (telemetry alone must never
223
+ * keep an otherwise-idle process alive), so this relies on something else in
224
+ * the process keeping the event loop open until the await resolves — true for
225
+ * the overwhelmingly common case (a running server), not guaranteed for a
226
+ * bare script whose last statement is `await agent.flush()`.
227
+ */
216
228
  async flush() {
217
229
  if (!this.active)
218
230
  return;
@@ -221,7 +233,7 @@ class MidlineAgent {
221
233
  if (this.drainPromise) {
222
234
  await this.drainPromise;
223
235
  }
224
- await this.drain(true);
236
+ await this.drain();
225
237
  }
226
238
  /** Flushes with a deadline, then closes. For graceful shutdown. */
227
239
  async shutdown(timeoutMs = 5000) {
@@ -417,23 +429,23 @@ class MidlineAgent {
417
429
  }
418
430
  return batch;
419
431
  }
420
- drain(keepProcessAlive) {
432
+ drain() {
421
433
  if (this.drainPromise)
422
434
  return this.drainPromise;
423
435
  if (!this.active || !this.queue.length || Date.now() < this.retryAfter) {
424
436
  return Promise.resolve();
425
437
  }
426
- this.drainPromise = this.runDrain(keepProcessAlive).finally(() => {
438
+ this.drainPromise = this.runDrain().finally(() => {
427
439
  this.drainPromise = null;
428
440
  });
429
441
  return this.drainPromise;
430
442
  }
431
- async runDrain(keepProcessAlive) {
443
+ async runDrain() {
432
444
  while (this.queue.length && this.active) {
433
445
  // Taken off the queue while in flight, so overflow trimming can't remove
434
446
  // events that are mid-send and then be confused about what was accepted.
435
447
  const batch = this.takeBatch();
436
- const outcome = await this.send(batch, keepProcessAlive);
448
+ const outcome = await this.send(batch);
437
449
  if (outcome.kind === "ok") {
438
450
  this.onSuccess();
439
451
  continue;
@@ -454,7 +466,7 @@ class MidlineAgent {
454
466
  }
455
467
  else {
456
468
  this.dropped += 1;
457
- this.report("http-413", "midline: the Midline server rejected an event as too large (HTTP 413); dropped it.");
469
+ this.report("too-large", "midline: the Midline server rejected an event as too large; dropped it.");
458
470
  }
459
471
  continue;
460
472
  }
@@ -468,7 +480,7 @@ class MidlineAgent {
468
480
  }
469
481
  // One malformed event fails validation for the whole batch. Send them one
470
482
  // at a time so it only costs that event.
471
- const isolated = await this.sendIndividually(batch, keepProcessAlive);
483
+ const isolated = await this.sendIndividually(batch);
472
484
  if (!isolated)
473
485
  return;
474
486
  continue;
@@ -479,16 +491,16 @@ class MidlineAgent {
479
491
  }
480
492
  }
481
493
  /** Returns false if delivery should stop for this drain. */
482
- async sendIndividually(batch, keepProcessAlive) {
494
+ async sendIndividually(batch) {
483
495
  for (let index = 0; index < batch.length; index++) {
484
496
  if (this.consoleUnsupported && batch[index].wire.eventType === "console")
485
497
  continue;
486
- const outcome = await this.send([batch[index]], keepProcessAlive);
498
+ const outcome = await this.send([batch[index]]);
487
499
  if (outcome.kind === "ok") {
488
500
  this.onSuccess();
489
501
  }
490
502
  else if (outcome.kind === "rejected" || outcome.kind === "tooLarge") {
491
- const detail = outcome.kind === "rejected" ? outcome.detail : "HTTP 413";
503
+ const detail = outcome.kind === "rejected" ? outcome.detail : "too_large";
492
504
  if (outcome.kind === "rejected" && this.refusedConsole(batch[index], detail))
493
505
  continue;
494
506
  this.dropped += 1;
@@ -506,53 +518,40 @@ class MidlineAgent {
506
518
  }
507
519
  return true;
508
520
  }
509
- async send(batch, keepProcessAlive) {
510
- const config = this.config;
511
- const body = JSON.stringify({ events: batch.map((item) => item.wire) });
512
- let result;
521
+ async send(batch) {
522
+ let ack;
513
523
  try {
514
- result = await this.transport.post(config.batchUrl, body, { "x-api-key": config.apiKey }, keepProcessAlive);
524
+ ack = await this.transport.send(batch.map((item) => item.wire));
515
525
  }
516
526
  catch (err) {
517
527
  this.report(`transport:${errorCode(err)}`, this.describe(err, batch.length));
518
528
  return { kind: "retry" };
519
529
  }
520
- const { status } = result;
521
- if (status >= 200 && status < 300) {
522
- // Servers that predate 401-on-bad-key answer 201 and count the rejects.
523
- const summary = parseJson(result.body);
524
- const failed = typeof summary?.failed === "number" ? summary.failed : 0;
525
- if (failed >= batch.length && batch.length > 0) {
530
+ if (ack.ok) {
531
+ // Servers that predate 401-on-bad-key ack ok and count the rejects.
532
+ if (ack.rejected >= batch.length && batch.length > 0) {
526
533
  this.log("error", "midline: the Midline server did not accept this API key. Monitoring is now off — retrying wouldn't help. Check apiKey / MIDLINE_API_KEY.");
527
534
  return { kind: "stop" };
528
535
  }
529
- if (failed > 0) {
530
- this.dropped += failed;
531
- this.report("partial", `midline: the Midline server rejected ${failed} of ${batch.length} events.`);
536
+ if (ack.rejected > 0) {
537
+ this.dropped += ack.rejected;
538
+ this.report("partial", `midline: the Midline server rejected ${ack.rejected} of ${batch.length} events.`);
532
539
  }
533
540
  return { kind: "ok" };
534
541
  }
535
- if (status === 401 || status === 403) {
536
- this.log("error", `midline: the Midline server rejected the API key (HTTP ${status}). ` +
542
+ if (ack.code === "unauthorized" || ack.code === "forbidden") {
543
+ this.log("error", `midline: the Midline server rejected the API key (${ack.code}). ` +
537
544
  "Monitoring is now off — retrying wouldn't help. Check apiKey / MIDLINE_API_KEY.");
538
545
  return { kind: "stop" };
539
546
  }
540
- if (status === 413) {
547
+ if (ack.code === "too_large") {
541
548
  return { kind: "tooLarge" };
542
549
  }
543
- if (status === 408 || status === 429 || status >= 500) {
544
- const retryAfterMs = parseRetryAfter(result.headers["retry-after"]);
545
- this.report(`http-${status}`, `midline: the Midline server returned HTTP ${status}; events are buffered and will be retried.`);
546
- return { kind: "retry", retryAfterMs };
550
+ if (ack.code === "rate_limited") {
551
+ this.report("rate_limited", `midline: the Midline server is rate-limiting this project; events are buffered and will be retried.`);
552
+ return { kind: "retry", retryAfterMs: ack.retryAfterMs };
547
553
  }
548
- if (status >= 300 && status < 400) {
549
- // Never followed: that would hand the API key to wherever the redirect points.
550
- const location = String(result.headers.location ?? "").slice(0, 200);
551
- this.report(`http-${status}`, `midline: the Midline endpoint redirected (HTTP ${status}${location ? ` to ${location}` : ""}). ` +
552
- "Redirects are not followed; set MIDLINE_ENDPOINT to the final URL.");
553
- return { kind: "retry" };
554
- }
555
- return { kind: "rejected", detail: `HTTP ${status}${serverMessage(result.body)}` };
554
+ return { kind: "rejected", detail: `${ack.code}: ${ack.message}` };
556
555
  }
557
556
  /**
558
557
  * True when a rejected event is a console line the server has no event type for,
@@ -582,7 +581,7 @@ class MidlineAgent {
582
581
  }
583
582
  describe(err, inFlight) {
584
583
  const config = this.config;
585
- const origin = config.ingestUrl.origin;
584
+ const origin = config.socketOrigin.origin;
586
585
  const code = errorCode(err);
587
586
  const buffered = ` ${this.queue.length + inFlight} event(s) buffered; your application is unaffected.`;
588
587
  if (TLS_ERROR_REASONS[code] || code.startsWith("ERR_SSL") || code === "EPROTO") {
@@ -600,7 +599,7 @@ class MidlineAgent {
600
599
  return `midline: could not connect to ${origin} within ${config.connectTimeoutMs}ms; retrying with backoff.${buffered}`;
601
600
  }
602
601
  if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
603
- return `midline: cannot resolve ${config.ingestUrl.hostname} (${code}) — check MIDLINE_ENDPOINT and DNS; retrying with backoff.${buffered}`;
602
+ return `midline: cannot resolve ${config.socketOrigin.hostname} (${code}) — check MIDLINE_ENDPOINT and DNS; retrying with backoff.${buffered}`;
604
603
  }
605
604
  if (code === "ECONNREFUSED") {
606
605
  return `midline: ${origin} refused the connection; retrying with backoff.${buffered}`;
@@ -745,26 +744,3 @@ function errorCode(err) {
745
744
  const e = err;
746
745
  return String(e?.code ?? e?.cause?.code ?? e?.errno ?? e?.name ?? "");
747
746
  }
748
- function parseJson(text) {
749
- try {
750
- return JSON.parse(text);
751
- }
752
- catch {
753
- return undefined;
754
- }
755
- }
756
- function serverMessage(body) {
757
- const message = parseJson(body)?.message;
758
- const text = Array.isArray(message) ? message.join("; ") : typeof message === "string" ? message : "";
759
- return text ? `: ${text.slice(0, 300)}` : "";
760
- }
761
- function parseRetryAfter(header) {
762
- const value = Array.isArray(header) ? header[0] : header;
763
- if (!value)
764
- return undefined;
765
- const seconds = Number(value);
766
- if (Number.isFinite(seconds))
767
- return Math.max(0, seconds * 1000);
768
- const date = Date.parse(value);
769
- return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
770
- }
package/dist/config.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { CaInput, CaptureOptions, MidlineConfig } from "./types";
2
2
  export declare const DEFAULT_ENDPOINT = "https://api.usemidline.com";
3
- export declare const INGEST_PATH = "/api/api-monitor/events";
4
3
  /** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
5
4
  export declare class ConfigError extends Error {
6
5
  constructor(message: string);
@@ -15,8 +14,8 @@ export interface ResolvedCapture {
15
14
  export interface ResolvedConfig {
16
15
  apiKey: string;
17
16
  serviceName?: string;
18
- ingestUrl: URL;
19
- batchUrl: URL;
17
+ /** The Midline server's origin; the ingest gateway lives at `${socketOrigin}/ingest`. */
18
+ socketOrigin: URL;
20
19
  /** Full trust store for the Midline endpoint, or undefined for Node's default. */
21
20
  ca?: Array<string | Buffer>;
22
21
  hasCustomCa: boolean;
@@ -43,12 +42,14 @@ export declare function envFlag(name: string): boolean | undefined;
43
42
  export declare function envInt(name: string): number | undefined;
44
43
  export declare function isLoopback(hostname: string): boolean;
45
44
  /**
46
- * Turns whatever the user configured into the ingest URL.
47
- *
45
+ * Turns whatever the user configured into the Midline server's origin — the
46
+ * agent connects to `${origin}/ingest` over socket.io, so only the origin
47
+ * matters now. Tolerates an old-style full ingest URL some existing
48
+ * `MIDLINE_ENDPOINT` values still carry (from before the socket.io transport):
48
49
  * `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
49
- * and `https://gateway.internal/midline` all work; the last is treated as a prefix.
50
+ * and `https://gateway.internal/midline` all resolve to the same origin.
50
51
  */
51
- export declare function resolveIngestUrl(endpoint: string): URL;
52
+ export declare function resolveEndpointOrigin(endpoint: string): URL;
52
53
  /**
53
54
  * Loads extra CAs. Every entry must contain at least one parseable certificate, so a
54
55
  * typo in a path fails loudly at startup instead of silently trusting nothing.
package/dist/config.js CHANGED
@@ -33,12 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.ConfigError = exports.INGEST_PATH = exports.DEFAULT_ENDPOINT = void 0;
36
+ exports.ConfigError = exports.DEFAULT_ENDPOINT = void 0;
37
37
  exports.env = env;
38
38
  exports.envFlag = envFlag;
39
39
  exports.envInt = envInt;
40
40
  exports.isLoopback = isLoopback;
41
- exports.resolveIngestUrl = resolveIngestUrl;
41
+ exports.resolveEndpointOrigin = resolveEndpointOrigin;
42
42
  exports.loadCa = loadCa;
43
43
  exports.trustStore = trustStore;
44
44
  exports.resolveCapture = resolveCapture;
@@ -47,7 +47,6 @@ const fs_1 = require("fs");
47
47
  const crypto_1 = require("crypto");
48
48
  const tls = __importStar(require("tls"));
49
49
  exports.DEFAULT_ENDPOINT = "https://api.usemidline.com";
50
- exports.INGEST_PATH = "/api/api-monitor/events";
51
50
  /** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
52
51
  class ConfigError extends Error {
53
52
  constructor(message) {
@@ -88,12 +87,14 @@ function isLoopback(hostname) {
88
87
  /^127(\.\d{1,3}){3}$/.test(host));
89
88
  }
90
89
  /**
91
- * Turns whatever the user configured into the ingest URL.
92
- *
90
+ * Turns whatever the user configured into the Midline server's origin — the
91
+ * agent connects to `${origin}/ingest` over socket.io, so only the origin
92
+ * matters now. Tolerates an old-style full ingest URL some existing
93
+ * `MIDLINE_ENDPOINT` values still carry (from before the socket.io transport):
93
94
  * `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
94
- * and `https://gateway.internal/midline` all work; the last is treated as a prefix.
95
+ * and `https://gateway.internal/midline` all resolve to the same origin.
95
96
  */
96
- function resolveIngestUrl(endpoint) {
97
+ function resolveEndpointOrigin(endpoint) {
97
98
  let url;
98
99
  try {
99
100
  url = new URL(endpoint);
@@ -111,19 +112,7 @@ function resolveIngestUrl(endpoint) {
111
112
  if (url.username || url.password) {
112
113
  throw new ConfigError("endpoint must not contain credentials; pass the key as apiKey");
113
114
  }
114
- url.search = "";
115
- url.hash = "";
116
- const path = url.pathname.replace(/\/+$/, "");
117
- if (path.endsWith(`${exports.INGEST_PATH}/batch`)) {
118
- url.pathname = path.slice(0, -"/batch".length);
119
- }
120
- else if (path.endsWith(exports.INGEST_PATH)) {
121
- url.pathname = path;
122
- }
123
- else {
124
- url.pathname = `${path}${exports.INGEST_PATH}`;
125
- }
126
- return url;
115
+ return new URL(url.origin);
127
116
  }
128
117
  /**
129
118
  * Loads extra CAs. Every entry must contain at least one parseable certificate, so a
@@ -197,16 +186,15 @@ function resolveCapture(capture) {
197
186
  }
198
187
  function resolveConfig(config) {
199
188
  const apiKey = (config.apiKey ?? env("MIDLINE_API_KEY") ?? "").trim();
200
- const ingestUrl = resolveIngestUrl(config.endpoint || env("MIDLINE_ENDPOINT") || exports.DEFAULT_ENDPOINT);
189
+ const socketOrigin = resolveEndpointOrigin(config.endpoint || env("MIDLINE_ENDPOINT") || exports.DEFAULT_ENDPOINT);
201
190
  const extraCa = loadCa(config.ca ?? env("MIDLINE_CUSTOM_CA"), "ca / MIDLINE_CUSTOM_CA");
202
- if (extraCa && ingestUrl.protocol !== "https:") {
191
+ if (extraCa && socketOrigin.protocol !== "https:") {
203
192
  throw new ConfigError("ca / MIDLINE_CUSTOM_CA is set but the endpoint is not https://");
204
193
  }
205
194
  return {
206
195
  apiKey,
207
196
  serviceName: config.serviceName ?? env("MIDLINE_SERVICE_NAME"),
208
- ingestUrl,
209
- batchUrl: new URL(`${ingestUrl.pathname}/batch`, ingestUrl),
197
+ socketOrigin,
210
198
  ca: trustStore(extraCa),
211
199
  hasCustomCa: Boolean(extraCa),
212
200
  environment: config.environment ?? env("MIDLINE_ENVIRONMENT"),
@@ -0,0 +1,58 @@
1
+ /** Mirrors midline-core-api/src/realtime/ingest-protocol.ts's IngestAck. Duplicated,
2
+ * not shared: the two are separate packages, kept in sync by wire convention. */
3
+ export type IngestAckErrorCode = "unauthorized" | "forbidden" | "rate_limited" | "bad_request" | "too_large" | "internal_error";
4
+ export interface IngestAckOk {
5
+ ok: true;
6
+ accepted: number;
7
+ rejected: number;
8
+ }
9
+ export interface IngestAckError {
10
+ ok: false;
11
+ code: IngestAckErrorCode;
12
+ message: string;
13
+ retryAfterMs?: number;
14
+ }
15
+ export type IngestAck = IngestAckOk | IngestAckError;
16
+ export interface SocketTransportOptions {
17
+ apiKey: string;
18
+ /** Full trust store for the Midline endpoint, or undefined for Node's default. */
19
+ ca?: Array<string | Buffer>;
20
+ connectTimeoutMs: number;
21
+ /** Per-emit ack timeout. */
22
+ timeoutMs: number;
23
+ /** Reconnection backoff bounds, derived from the agent's own flush/backoff config (see agent.ts). */
24
+ reconnectionDelayMs: number;
25
+ reconnectionDelayMaxMs: number;
26
+ /** Nothing queued for this long -> disconnect; reconnects lazily on the next send(). */
27
+ idleDisconnectMs: number;
28
+ userAgent: string;
29
+ }
30
+ /** A transport failure with a stable `code`, matching what agent.ts's errorCode()/describe() expect. */
31
+ export declare class TransportError extends Error {
32
+ readonly code: string;
33
+ constructor(message: string, code: string);
34
+ }
35
+ /**
36
+ * Owns one socket.io-client connection to the ingest gateway: connects lazily on
37
+ * first send, lets socket.io-client's own reconnection/backoff handle transport
38
+ * drops, and disconnects after a sustained idle period (autoUnref also unrefs the
39
+ * underlying socket, so — as with the old HTTP transport — telemetry alone never
40
+ * keeps the process alive; the idle disconnect is extra hygiene on top of that,
41
+ * not the only thing standing between this and a hung process).
42
+ */
43
+ export declare class SocketTransport {
44
+ private readonly origin;
45
+ private readonly options;
46
+ private socket;
47
+ private connecting;
48
+ private idleTimer;
49
+ constructor(origin: URL, options: SocketTransportOptions);
50
+ /** Sends one batch, connecting first if needed. Resolves with the server's ack or throws. */
51
+ send(events: Array<Record<string, unknown>>): Promise<IngestAck>;
52
+ destroy(): void;
53
+ private ensureConnected;
54
+ private createSocket;
55
+ private emit;
56
+ private scheduleIdleDisconnect;
57
+ private clearIdleTimer;
58
+ }
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SocketTransport = exports.TransportError = void 0;
4
+ const socket_io_client_1 = require("socket.io-client");
5
+ const INGEST_NAMESPACE = "/ingest";
6
+ const INGEST_EVENT = "ingest";
7
+ /** A transport failure with a stable `code`, matching what agent.ts's errorCode()/describe() expect. */
8
+ class TransportError extends Error {
9
+ constructor(message, code) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = "TransportError";
13
+ }
14
+ }
15
+ exports.TransportError = TransportError;
16
+ /**
17
+ * Owns one socket.io-client connection to the ingest gateway: connects lazily on
18
+ * first send, lets socket.io-client's own reconnection/backoff handle transport
19
+ * drops, and disconnects after a sustained idle period (autoUnref also unrefs the
20
+ * underlying socket, so — as with the old HTTP transport — telemetry alone never
21
+ * keeps the process alive; the idle disconnect is extra hygiene on top of that,
22
+ * not the only thing standing between this and a hung process).
23
+ */
24
+ class SocketTransport {
25
+ constructor(origin, options) {
26
+ this.origin = origin;
27
+ this.options = options;
28
+ this.socket = null;
29
+ this.connecting = null;
30
+ this.idleTimer = null;
31
+ }
32
+ /** Sends one batch, connecting first if needed. Resolves with the server's ack or throws. */
33
+ async send(events) {
34
+ this.clearIdleTimer();
35
+ try {
36
+ await this.ensureConnected();
37
+ return await this.emit(events);
38
+ }
39
+ finally {
40
+ this.scheduleIdleDisconnect();
41
+ }
42
+ }
43
+ destroy() {
44
+ this.clearIdleTimer();
45
+ this.socket?.removeAllListeners();
46
+ this.socket?.disconnect();
47
+ this.socket = null;
48
+ }
49
+ ensureConnected() {
50
+ if (this.socket?.connected)
51
+ return Promise.resolve();
52
+ if (this.connecting)
53
+ return this.connecting;
54
+ if (!this.socket) {
55
+ this.socket = this.createSocket();
56
+ }
57
+ else if (!this.socket.active) {
58
+ // Cleanly disconnected earlier (our own idle timeout, or the server closed
59
+ // it): socket.io-client won't retry on its own, so ask it to.
60
+ this.socket.connect();
61
+ }
62
+ // Otherwise the existing socket is already reconnecting on its own; just wait below.
63
+ const socket = this.socket;
64
+ this.connecting = new Promise((resolve, reject) => {
65
+ const timer = setTimeout(() => {
66
+ cleanup();
67
+ reject(new TransportError(`connection not established within ${this.options.connectTimeoutMs}ms`, "ECONNECT_TIMEOUT"));
68
+ }, this.options.connectTimeoutMs);
69
+ const onConnect = () => {
70
+ cleanup();
71
+ resolve();
72
+ };
73
+ const onError = (err) => {
74
+ cleanup();
75
+ reject(classifyConnectError(err));
76
+ };
77
+ const cleanup = () => {
78
+ clearTimeout(timer);
79
+ socket.off("connect", onConnect);
80
+ socket.off("connect_error", onError);
81
+ };
82
+ socket.once("connect", onConnect);
83
+ socket.once("connect_error", onError);
84
+ }).finally(() => {
85
+ this.connecting = null;
86
+ });
87
+ return this.connecting;
88
+ }
89
+ createSocket() {
90
+ const socket = (0, socket_io_client_1.io)(`${this.origin.origin}${INGEST_NAMESPACE}`, {
91
+ auth: { apiKey: this.options.apiKey },
92
+ transports: ["websocket"],
93
+ reconnection: true,
94
+ reconnectionAttempts: Infinity,
95
+ reconnectionDelay: this.options.reconnectionDelayMs,
96
+ reconnectionDelayMax: this.options.reconnectionDelayMaxMs,
97
+ randomizationFactor: 0.5,
98
+ timeout: this.options.connectTimeoutMs,
99
+ // Never the reason the process stays alive — same property the old HTTP
100
+ // transport's unref'd keep-alive sockets had.
101
+ autoUnref: true,
102
+ forceNew: true,
103
+ ca: this.options.ca,
104
+ extraHeaders: { "user-agent": this.options.userAgent },
105
+ });
106
+ return socket;
107
+ }
108
+ emit(events) {
109
+ const socket = this.socket;
110
+ if (!socket) {
111
+ return Promise.reject(new TransportError("not connected", "ENOTCONNECTED"));
112
+ }
113
+ return new Promise((resolve, reject) => {
114
+ socket.timeout(this.options.timeoutMs).emit(INGEST_EVENT, { events }, (err, ack) => {
115
+ if (err) {
116
+ reject(new TransportError(`no response within ${this.options.timeoutMs}ms`, "ETIMEDOUT"));
117
+ }
118
+ else {
119
+ resolve(ack);
120
+ }
121
+ });
122
+ });
123
+ }
124
+ scheduleIdleDisconnect() {
125
+ this.idleTimer = setTimeout(() => {
126
+ this.idleTimer = null;
127
+ this.socket?.disconnect();
128
+ }, this.options.idleDisconnectMs);
129
+ this.idleTimer.unref?.();
130
+ }
131
+ clearIdleTimer() {
132
+ if (this.idleTimer) {
133
+ clearTimeout(this.idleTimer);
134
+ this.idleTimer = null;
135
+ }
136
+ }
137
+ }
138
+ exports.SocketTransport = SocketTransport;
139
+ /**
140
+ * `connect_error` from a plain `io()` connect-timeout has message "timeout" and
141
+ * no further detail. Anything else wraps the real Node error (ECONNREFUSED,
142
+ * ENOTFOUND, a TLS failure, ...) inside engine.io-client's TransportError, whose
143
+ * `.description` — for the websocket transport — is a `ws` ErrorEvent exposing
144
+ * the underlying error via its public `.error` getter (mirrors the DOM
145
+ * ErrorEvent.error field; see ws/lib/event-target.js). Verified empirically
146
+ * against the installed socket.io-client/ws versions, not assumed.
147
+ */
148
+ function classifyConnectError(err) {
149
+ const anyErr = err;
150
+ if (anyErr?.message === "timeout") {
151
+ return new TransportError("connection timed out", "ECONNECT_TIMEOUT");
152
+ }
153
+ const inner = anyErr?.description?.error;
154
+ const code = inner?.code ?? "";
155
+ const message = inner?.message ?? anyErr?.message ?? "connection failed";
156
+ return new TransportError(message, code);
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "midline-agent",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Midline — request, error and security monitoring for Node, and error, network and Web Vitals monitoring for browsers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -55,9 +55,13 @@
55
55
  "@types/express": "^4.17.19",
56
56
  "@types/node": "^24.10.1",
57
57
  "express": "^4.18.2",
58
+ "socket.io": "^4.8.3",
58
59
  "typescript": "^5.1.3"
59
60
  },
60
61
  "publishConfig": {
61
62
  "access": "public"
63
+ },
64
+ "dependencies": {
65
+ "socket.io-client": "^4.8.3"
62
66
  }
63
67
  }