@eliware/elera-lib 0.1.6 → 0.1.8
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 +23 -8
- package/RELEASE_NOTES.md +40 -1
- package/package.json +1 -1
- package/src/client/create-db.mjs +2 -1
- package/src/errors.mjs +7 -0
- package/src/index.d.ts +6 -1
- package/src/index.mjs +2 -1
- package/src/pools/route-pool.mjs +3 -1
- package/src/routing/event-contract.mjs +11 -0
- package/src/routing/stream-client.mjs +8 -5
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The alternative SQL client for Eliware applications. It provides generic
|
|
4
4
|
primary/balanced MySQL or MariaDB routing without embedding Elera, HAProxy,
|
|
5
|
-
backup, or GitOps policy. It is a v0.1.
|
|
5
|
+
backup, or GitOps policy. It is a v0.1.8 alternative to `@eliware/mysql`; the
|
|
6
6
|
existing package is intentionally unchanged. The current package version is
|
|
7
|
-
0.1.
|
|
7
|
+
0.1.8.
|
|
8
8
|
|
|
9
9
|
`primary` is the preferred connection path. `balanced` is an optional alternate
|
|
10
10
|
path. Both may accept writes; automatic routing sends only conservative,
|
|
@@ -58,14 +58,29 @@ state. Only conservative, single-statement reads are eligible for automatic
|
|
|
58
58
|
retry after a connection failure; uncertain writes are never retried
|
|
59
59
|
automatically.
|
|
60
60
|
|
|
61
|
+
`client.availability()` reports whether a primary route is usable. It returns
|
|
62
|
+
`state: 'cluster-unavailable'` when every primary candidate is draining or
|
|
63
|
+
unavailable; the `routes` fields report primary and balanced availability
|
|
64
|
+
independently. New operations in that state fail with the exported
|
|
65
|
+
`ClusterUnavailableError` using code `CLUSTER_UNAVAILABLE`.
|
|
66
|
+
|
|
61
67
|
When an attached routing stream receives a `routing.shutdown` event, the
|
|
62
68
|
client drains the identified node, performs a REST bundle resynchronization,
|
|
63
|
-
and closes the retiring WebSocket with restart code `1012`.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
and closes the retiring WebSocket with restart code `1012`. If the event
|
|
70
|
+
contains `loadBalancerEndpoint`, that endpoint replaces the current endpoint
|
|
71
|
+
before resynchronization and reconnect. `reconnectDeadlineMs` bounds the
|
|
72
|
+
planned reconnect window; after it expires, no new reconnect is scheduled.
|
|
73
|
+
Reconnects, failovers, and measured reconnect delay are included in telemetry.
|
|
74
|
+
If the WebSocket remains unavailable before the deadline, REST
|
|
75
|
+
resynchronization and bounded reconnect backoff continue until the deadline or
|
|
76
|
+
until the caller closes the stream.
|
|
77
|
+
|
|
78
|
+
Routing events are validated before application. Shutdown events may include
|
|
79
|
+
`node`, `reason`, `reconnect`, `reconnectDeadlineMs`, and
|
|
80
|
+
`loadBalancerEndpoint`; invalid event fields are reported through `onError` and
|
|
81
|
+
do not change routing state. `routing.update` replaces the writer and reader
|
|
82
|
+
pools atomically, while `routing.drain` excludes only the named node from new
|
|
83
|
+
work and allows active operations to finish.
|
|
69
84
|
|
|
70
85
|
The public client intentionally exposes SQL operations, health, routing,
|
|
71
86
|
lifecycle, and optional routing-event synchronization methods. REST and
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.1.8 — Explicit outage state and recovery observability
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Exposes `ClusterUnavailableError` with the stable `CLUSTER_UNAVAILABLE` code
|
|
8
|
+
when no eligible SQL node can serve a route.
|
|
9
|
+
- Adds `DbClient.availability()` so applications can distinguish an available
|
|
10
|
+
client from a fully unavailable primary route, including per-route status.
|
|
11
|
+
- Covers standalone node drain, total primary-route outage, and route recovery.
|
|
12
|
+
- Updates the TypeScript declarations for the new runtime API and error type.
|
|
13
|
+
|
|
14
|
+
### Validation
|
|
15
|
+
|
|
16
|
+
- Maintains 100% statements, branches, functions, and lines coverage.
|
|
17
|
+
- Tests and TypeScript typechecking pass with zero lint warnings.
|
|
18
|
+
|
|
19
|
+
## 0.1.7 — Routing shutdown contract completion
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Validates supervisor routing-control events before applying them.
|
|
24
|
+
- Supports node-specific shutdown events so only the retiring SQL node is
|
|
25
|
+
drained by the client.
|
|
26
|
+
- Honors an optional `loadBalancerEndpoint` supplied during shutdown before
|
|
27
|
+
REST resynchronization and WebSocket reconnect.
|
|
28
|
+
- Honors `reconnectDeadlineMs` and stops scheduling reconnect attempts after
|
|
29
|
+
the deadline expires.
|
|
30
|
+
- Exposes the active endpoint and reconnect deadline in routing-stream state.
|
|
31
|
+
- Publishes typed routing-event declarations for TypeScript consumers.
|
|
32
|
+
|
|
33
|
+
### Validation
|
|
34
|
+
|
|
35
|
+
- Adds regression coverage for node-specific shutdown draining, endpoint
|
|
36
|
+
replacement, deadline expiry, malformed events, and reconnect behavior.
|
|
37
|
+
- Maintains 100% statements, branches, functions, and lines coverage with
|
|
38
|
+
zero lint warnings.
|
|
39
|
+
- Typecheck and diff validation pass.
|
|
40
|
+
|
|
3
41
|
## 0.1.6 — Graceful routing shutdown handoff
|
|
4
42
|
|
|
5
43
|
### Added
|
|
@@ -18,7 +56,8 @@
|
|
|
18
56
|
|
|
19
57
|
### Validation
|
|
20
58
|
|
|
21
|
-
-
|
|
59
|
+
- The complete test suite passes with 100% statements, branches, functions,
|
|
60
|
+
and lines coverage.
|
|
22
61
|
- Syntax and diff validation pass.
|
|
23
62
|
|
|
24
63
|
## 0.1.5 — Telemetry and convention alignment
|
package/package.json
CHANGED
package/src/client/create-db.mjs
CHANGED
|
@@ -35,9 +35,10 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
|
|
|
35
35
|
async execute(sql, values, options) { return choose(sql, options).execute(sql, values); },
|
|
36
36
|
async transaction(callback) { const node = primaryPool.choose(); const connection = await node.getConnection(); try { await connection.beginTransaction(); const tx = { query: (sql, values) => connection.query(sql, values), execute: (sql, values) => connection.execute(sql, values) }; const result = await callback(tx); await connection.commit(); return result; } catch (error) { await connection.rollback().catch(() => {}); throw asSqlError(error); } finally { connection.release(); } },
|
|
37
37
|
async health(route = 'primary') { const started = now(); const selected = route === 'balanced' && balancedPool ? balancedPool : primaryPool; const nodes = await selected.health(); return { ok: nodes.some((node) => node.ok), route: selected === balancedPool ? 'balanced' : 'primary', nodes, latencyMs: now() - started }; },
|
|
38
|
-
async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); if (olderVersion(candidate.bundleVersion, activeBundle?.bundleVersion)) return { bundleVersion: activeBundle?.bundleVersion, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; const writer = candidate.writer ?? candidate.routes.primary?.[0]; const reader = candidate.readers?.[0] ?? candidate.routes.balanced?.[0]; primaryConfig = validateProfile({ ...primaryConfig, host: writer
|
|
38
|
+
async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); if (olderVersion(candidate.bundleVersion, activeBundle?.bundleVersion)) return { bundleVersion: activeBundle?.bundleVersion, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; const writer = candidate.writer ?? candidate.routes.primary?.[0]; const reader = candidate.readers?.[0] ?? candidate.routes.balanced?.[0]; primaryConfig = validateProfile({ ...primaryConfig, host: writer.host, port: writer.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = reader ? validateProfile({ ...primaryConfig, host: reader.host, port: reader.port }, 'balanced') : undefined; activeBundle = candidate; primaryPool = makeRoute('primary', primaryConfig); balancedPool = balancedConfig ? makeRoute('balanced', balancedConfig) : null; await Promise.all(previous.filter(Boolean).map((pool) => pool.close())); return { bundleVersion: activeBundle.bundleVersion ?? null, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; },
|
|
39
39
|
async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); metrics?.start?.(stream); stream.setTelemetry?.(metrics); stream.setOnUpdate?.(async (event) => { const update = event.type === 'routing.update' ? event : event.type === 'routing.resync' ? event.bundle : undefined; if (update && (update.writer || update.routes?.primary?.length)) await client.refresh({ ...activeBundle, ...update, database: update.database ?? activeBundle?.database ?? primaryConfig.database, credentials: update.credentials ?? activeBundle?.credentials, routes: update.routes ?? activeBundle?.routes, bundleVersion: update.bundleVersion ?? update.version ?? activeBundle?.bundleVersion, expiresAt: update.expiresAt ?? activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); if (event.type === 'routing.drain' || event.type === 'routing.shutdown') for (const pool of [primaryPool, balancedPool].filter(Boolean)) (event.type === 'routing.drain' ? pool.drain : pool.drain)(event.node, drainTimeoutMs); if (event.type === 'routing.recovery') for (const pool of [primaryPool, balancedPool].filter(Boolean)) pool.recover(event.node, drainTimeoutMs); }); await stream.connect(); return () => stream.close?.(); },
|
|
40
40
|
drain(host, timeoutMs = drainTimeoutMs) { const effectiveTimeout = clientDrainTimeout(timeoutMs); const pools = [primaryPool, balancedPool].filter(Boolean); pools.forEach((pool) => pool.drain(host, effectiveTimeout)); return { host, timeoutMs: effectiveTimeout, wait: () => Promise.all(pools.map((pool) => pool.waitForIdle(effectiveTimeout))), forceClose: () => Promise.all(pools.map((pool) => pool.forceClose(host))) }; },
|
|
41
|
+
availability() { const states = this.nodeStates(); const primaryAvailable = states.some((node) => node.route === 'primary' && node.available); return { state: primaryAvailable ? 'available' : 'cluster-unavailable', routes: { primary: primaryAvailable, balanced: states.some((node) => node.route === 'balanced' && node.available) } }; },
|
|
41
42
|
nodeStates() { return [primaryPool, balancedPool].filter(Boolean).flatMap((pool) => pool.nodes.map((node) => ({ host: node.host, port: node.port, route: pool === primaryPool ? 'primary' : 'balanced', state: node.state, active: node.active, available: node.available }))); },
|
|
42
43
|
setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
|
|
43
44
|
bundle: () => activeBundle,
|
package/src/errors.mjs
CHANGED
|
@@ -4,6 +4,13 @@ export class SqlClientError extends Error {
|
|
|
4
4
|
}
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
export class ClusterUnavailableError extends SqlClientError {
|
|
8
|
+
constructor(message = 'No eligible SQL nodes are available', options = {}) {
|
|
9
|
+
super(message, { code: 'CLUSTER_UNAVAILABLE', retryable: false, ...options });
|
|
10
|
+
this.name = 'ClusterUnavailableError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
export function classifyError(error) {
|
|
8
15
|
const code = error?.code;
|
|
9
16
|
if (['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'PROTOCOL_CONNECTION_LOST', 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR'].includes(code)) return { code: 'CONNECTION_ERROR', retryable: true };
|
package/src/index.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface DbClient {
|
|
|
42
42
|
classify(sql: string): 'primary' | 'balanced';
|
|
43
43
|
attachRoutingStream(stream: RoutingStream): Promise<() => void>;
|
|
44
44
|
setNodeAvailability(route: 'primary' | 'balanced', host: string, available: boolean): void;
|
|
45
|
+
availability(): { state: 'available' | 'cluster-unavailable'; routes: { primary: boolean; balanced: boolean } };
|
|
45
46
|
drain(host: string, timeoutMs?: number): { host: string; timeoutMs: number; wait(): Promise<unknown[]>; forceClose(): Promise<unknown[]> };
|
|
46
47
|
nodeStates(): Array<{ host: string; port: number; route: 'primary' | 'balanced'; state: 'ready' | 'draining' | 'unavailable' | 'recovering'; active: number; available: boolean }>;
|
|
47
48
|
config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
|
|
@@ -54,9 +55,12 @@ export interface RoutingStream {
|
|
|
54
55
|
close(): void;
|
|
55
56
|
sendTelemetry(payload: unknown): void;
|
|
56
57
|
setTelemetry?(telemetry?: Pick<Telemetry, 'recordReconnect'>): void;
|
|
57
|
-
state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string };
|
|
58
|
+
state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string; endpoint: string; reconnectDeadlineAt?: number };
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
export type RoutingEvent = { type: 'routing.update' | 'routing.resync' | 'routing.drain' | 'routing.recovery'; node?: string; version?: number | string; [key: string]: unknown } | { type: 'routing.shutdown'; node?: string; reason?: string; reconnect?: boolean; reconnectDeadlineMs?: number; loadBalancerEndpoint?: string };
|
|
62
|
+
export function validateRoutingEvent(event: unknown): RoutingEvent;
|
|
63
|
+
|
|
60
64
|
export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number; telemetry?: true | Telemetry }): Promise<DbClient>;
|
|
61
65
|
export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; telemetry?: true | Telemetry }): Promise<DbClient>;
|
|
62
66
|
export function classifyQuery(sql: unknown): 'primary' | 'balanced';
|
|
@@ -64,6 +68,7 @@ export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balance
|
|
|
64
68
|
export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
|
|
65
69
|
export function redactedProfile(profile: ConnectionProfile): ConnectionProfile;
|
|
66
70
|
export class SqlClientError extends Error { code?: string; retryable?: boolean; cause?: unknown; }
|
|
71
|
+
export class ClusterUnavailableError extends SqlClientError {}
|
|
67
72
|
export function classifyError(error: unknown): { retryable: boolean; code?: string };
|
|
68
73
|
export function asSqlError(error: unknown): SqlClientError;
|
|
69
74
|
export function validateBundle(bundle: RoutingBundle): RoutingBundle;
|
package/src/index.mjs
CHANGED
|
@@ -3,12 +3,13 @@ export { createDbFromBundle, profilesFromBundle } from './client/from-bundle.mjs
|
|
|
3
3
|
export { createDbFromEnvironment } from './client/environment.mjs';
|
|
4
4
|
export { classifyQuery, routeFor } from './routing.mjs';
|
|
5
5
|
export { validateProfile, redactedProfile } from './config.mjs';
|
|
6
|
-
export { SqlClientError, classifyError, asSqlError } from './errors.mjs';
|
|
6
|
+
export { SqlClientError, ClusterUnavailableError, classifyError, asSqlError } from './errors.mjs';
|
|
7
7
|
export { validateBundle, bundleExpired, bundleNeedsRefresh } from './bundle.mjs';
|
|
8
8
|
export { createAdminSql } from './admin/sql.mjs';
|
|
9
9
|
export { createMigrationRunner } from './admin/migrations.mjs';
|
|
10
10
|
export { selectRouteNodes } from './routing/node-set.mjs';
|
|
11
11
|
export { createRoutingStream } from './routing/stream-client.mjs';
|
|
12
|
+
export { validateRoutingEvent } from './routing/event-contract.mjs';
|
|
12
13
|
export { writerAssignment, failoverNodes } from './routing/assignment.mjs';
|
|
13
14
|
export { compareBundleVersions } from './routing/bundle-version.mjs';
|
|
14
15
|
export { CLIENT_DRAIN_TIMEOUT_MS, clientDrainTimeout } from './lifecycle/drain-policy.mjs';
|
package/src/pools/route-pool.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { ClusterUnavailableError } from '../errors.mjs';
|
|
2
|
+
|
|
1
3
|
export function createRoutePool(nodes, { preferred = false } = {}) {
|
|
2
4
|
let cursor = 0;
|
|
3
5
|
const candidates = () => nodes.filter((node) => node.available);
|
|
4
6
|
const choose = () => {
|
|
5
7
|
const available = candidates();
|
|
6
|
-
if (!available.length) throw new
|
|
8
|
+
if (!available.length) throw new ClusterUnavailableError('no eligible SQL nodes available');
|
|
7
9
|
if (preferred) return available[0];
|
|
8
10
|
const total = available.reduce((sum, node) => sum + Math.max(0, node.weight), 0);
|
|
9
11
|
if (!total) return available[cursor++ % available.length];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const eventTypes = new Set(['routing.update', 'routing.resync', 'routing.drain', 'routing.recovery', 'routing.shutdown']);
|
|
2
|
+
|
|
3
|
+
export function validateRoutingEvent(event) {
|
|
4
|
+
if (!event || typeof event !== 'object' || !eventTypes.has(event.type)) throw new TypeError('unsupported routing event');
|
|
5
|
+
if (event.type === 'routing.shutdown') {
|
|
6
|
+
if (event.node !== undefined && typeof event.node !== 'string') throw new TypeError('routing shutdown node must be a string');
|
|
7
|
+
if (event.reconnectDeadlineMs !== undefined && (!Number.isFinite(Number(event.reconnectDeadlineMs)) || Number(event.reconnectDeadlineMs) < 0)) throw new TypeError('routing shutdown deadline must be non-negative');
|
|
8
|
+
if (event.loadBalancerEndpoint !== undefined && typeof event.loadBalancerEndpoint !== 'string') throw new TypeError('routing shutdown endpoint must be a string');
|
|
9
|
+
}
|
|
10
|
+
return event;
|
|
11
|
+
}
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import { log as defaultLog } from '@eliware/common';
|
|
2
2
|
import { compareBundleVersions } from './bundle-version.mjs';
|
|
3
|
+
import { validateRoutingEvent } from './event-contract.mjs';
|
|
3
4
|
|
|
4
5
|
export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now(), telemetry } = {}) {
|
|
5
6
|
if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
|
|
6
|
-
let socket; let closed = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected'; let plannedReconnect = false; let lastReconnectWasPlanned = false; let disconnectedAt;
|
|
7
|
+
let socket; let closed = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected'; let plannedReconnect = false; let lastReconnectWasPlanned = false; let disconnectedAt; let reconnectDeadlineAt;
|
|
7
8
|
const log = arguments[0]?.log ?? defaultLog;
|
|
8
9
|
const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?application=${encodeURIComponent(application)}&token=${encodeURIComponent(token ?? '')}`;
|
|
9
10
|
async function fallback() { try { const bundle = await fetchBundle(application); if (closed) return; mode = 'rest'; updateHandler?.({ type: 'routing.resync', version: expectedVersion, bundle, receivedAt: now() }); } catch (error) { if (closed) return; mode = 'disconnected'; onError?.(error); log.warn?.('Routing REST fallback failed', { error }); } }
|
|
10
|
-
function schedule() { if (closed || timer) return; timer = setTimeout(() => { timer = undefined; void connect(); },
|
|
11
|
+
function schedule() { if (closed || timer || (reconnectDeadlineAt !== undefined && now() >= reconnectDeadlineAt)) return; const wait = Math.min(delay, Math.max(0, reconnectDeadlineAt === undefined ? delay : reconnectDeadlineAt - now())); timer = setTimeout(() => { timer = undefined; void connect(); }, wait); delay = Math.min(maxReconnectMs, delay * 2); }
|
|
11
12
|
async function connect() {
|
|
12
13
|
if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
|
|
13
14
|
try {
|
|
14
15
|
socket = new WebSocketImpl(streamUrl());
|
|
15
|
-
socket.onopen = () => { mode = 'websocket'; if (disconnectedAt !== undefined) { telemetry?.recordReconnect?.({ delayMs: Math.max(0, now() - disconnectedAt), failover: lastReconnectWasPlanned }); disconnectedAt = undefined; lastReconnectWasPlanned = false; } delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
|
|
16
|
+
socket.onopen = () => { mode = 'websocket'; reconnectDeadlineAt = undefined; if (disconnectedAt !== undefined) { telemetry?.recordReconnect?.({ delayMs: Math.max(0, now() - disconnectedAt), failover: lastReconnectWasPlanned }); disconnectedAt = undefined; lastReconnectWasPlanned = false; } delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
|
|
16
17
|
socket.onmessage = async ({ data }) => {
|
|
17
18
|
try {
|
|
18
|
-
const event = JSON.parse(data);
|
|
19
|
+
const event = validateRoutingEvent(JSON.parse(data));
|
|
19
20
|
if (event.type === 'routing.shutdown') {
|
|
20
21
|
updateHandler?.(event);
|
|
22
|
+
if (typeof event.loadBalancerEndpoint === 'string' && event.loadBalancerEndpoint) endpoint = event.loadBalancerEndpoint;
|
|
23
|
+
reconnectDeadlineAt = Number.isFinite(Number(event.reconnectDeadlineMs)) ? now() + Math.max(0, Number(event.reconnectDeadlineMs)) : undefined;
|
|
21
24
|
plannedReconnect = true;
|
|
22
25
|
delay = reconnectMs;
|
|
23
26
|
await fallback();
|
|
@@ -36,5 +39,5 @@ export function createRoutingStream({ endpoint, token, application = 'default',
|
|
|
36
39
|
socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { disconnectedAt = now(); lastReconnectWasPlanned = plannedReconnect; if (!plannedReconnect) void fallback(); plannedReconnect = false; schedule(); } };
|
|
37
40
|
} catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
|
|
38
41
|
}
|
|
39
|
-
return { connect, sendTelemetry: (payload) => { if (socket?.readyState === 1) socket.send(JSON.stringify(payload)); }, setOnUpdate: (handler) => { updateHandler = handler; }, setTelemetry: (value) => { telemetry = value; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
|
|
42
|
+
return { connect, sendTelemetry: (payload) => { if (socket?.readyState === 1) socket.send(JSON.stringify(payload)); }, setOnUpdate: (handler) => { updateHandler = handler; }, setTelemetry: (value) => { telemetry = value; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion, endpoint, reconnectDeadlineAt }) };
|
|
40
43
|
}
|