@pinagent/react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Transcript reducer + wire types for the RN widget.
4
+ *
5
+ * Deliberate, dependency-free mirror of `@pinagent/shared`'s `AgentEvent`
6
+ * union (event-bus.ts), `ServerMessage` (ws-protocol.ts), and the canonical
7
+ * `renderTranscript` (render-transcript.ts). We DON'T import the package: the
8
+ * native client ships as **source** and is bundled onto the device by the
9
+ * consumer's Metro, but `@pinagent/shared` is `private` (unpublished) and
10
+ * built for Node — importing it into device code would break a real
11
+ * `npm install @pinagent/react-native`, the same way bundling any unpublished
12
+ * `@pinagent/*` dep does. The package's `./server` entry CAN depend on shared
13
+ * (tsdown bundles it into dist); device source cannot.
14
+ *
15
+ * Keep in sync with those three files. The shapes are stable and the reducer
16
+ * mirrors the shared one, extended with the streaming-only kinds (ask/status)
17
+ * the interactive RN sheet renders.
18
+ */
19
+
20
+ /** Flat AgentEvent union — mirror of `@pinagent/shared`'s `event-bus.ts`. */
21
+ export type AgentEvent =
22
+ | { type: 'init'; sessionId: string; model: string; permissionMode: string; apiKeySource: string }
23
+ | { type: 'text'; text: string }
24
+ | { type: 'tool_use'; name: string; summary: string }
25
+ | { type: 'tool_result'; ok: boolean }
26
+ | { type: 'progress'; turn: number }
27
+ | { type: 'ask_user'; askId: string; question: string; context?: string; options?: string[] }
28
+ | { type: 'error'; message: string }
29
+ | { type: 'result'; subtype: string; numTurns: number; totalCostUsd: number; durationMs: number }
30
+ | {
31
+ type: 'status_changed';
32
+ status: 'pending' | 'fixed' | 'wontfix' | 'deferred';
33
+ note: string | null;
34
+ commitSha: string | null;
35
+ resolvedAt: string | null;
36
+ };
37
+
38
+ /** Server → client frames the RN client acts on (subset of the web protocol). */
39
+ export type ServerMessage =
40
+ | { type: 'event'; feedbackId: string; event: AgentEvent }
41
+ | { type: 'done'; feedbackId: string }
42
+ | { type: 'error'; feedbackId?: string; message: string }
43
+ | { type: 'worktree_state'; feedbackId: string; state: string; commitSha?: string }
44
+ // Frames the RN client receives but ignores (project / extension fan-out, pong).
45
+ | { type: string; [k: string]: unknown };
46
+
47
+ export type TranscriptKind = 'text' | 'tool' | 'error' | 'result' | 'ask' | 'status';
48
+
49
+ export interface TranscriptRow {
50
+ /** Stable key for React lists; derived from event index. */
51
+ id: string;
52
+ kind: TranscriptKind;
53
+ /** Primary text. For tools, the tool name. */
54
+ text: string;
55
+ /** Tool argument summary / ask options, when present. */
56
+ detail?: string;
57
+ /** tool_result success flag → ✓/✗ marker. */
58
+ ok?: boolean;
59
+ }
60
+
61
+ /**
62
+ * Fold AgentEvents into render-ready rows. Pure and deterministic — mirrors
63
+ * the shared `renderTranscript`, plus `ask_user` / `status_changed` rows the
64
+ * interactive RN sheet shows. `init`, `progress`, `tool_result` produce no row
65
+ * of their own (`tool_result` annotates the preceding tool row with ✓/✗).
66
+ */
67
+ export function renderTranscript(events: AgentEvent[]): TranscriptRow[] {
68
+ const rows: TranscriptRow[] = [];
69
+ events.forEach((event, i) => {
70
+ switch (event.type) {
71
+ case 'text': {
72
+ const text = event.text.trim();
73
+ if (text) rows.push({ id: `e${i}`, kind: 'text', text });
74
+ break;
75
+ }
76
+ case 'tool_use':
77
+ rows.push({
78
+ id: `e${i}`,
79
+ kind: 'tool',
80
+ text: event.name,
81
+ ...(event.summary ? { detail: event.summary } : {}),
82
+ });
83
+ break;
84
+ case 'tool_result':
85
+ for (let j = rows.length - 1; j >= 0; j--) {
86
+ if (rows[j].kind === 'tool') {
87
+ rows[j].ok = event.ok;
88
+ break;
89
+ }
90
+ }
91
+ break;
92
+ case 'ask_user':
93
+ rows.push({
94
+ id: `e${i}`,
95
+ kind: 'ask',
96
+ text: event.question,
97
+ ...(event.options?.length ? { detail: event.options.join(' · ') } : {}),
98
+ });
99
+ break;
100
+ case 'error':
101
+ rows.push({ id: `e${i}`, kind: 'error', text: event.message });
102
+ break;
103
+ case 'status_changed':
104
+ rows.push({
105
+ id: `e${i}`,
106
+ kind: 'status',
107
+ text: event.note
108
+ ? `Resolved (${event.status}): ${event.note}`
109
+ : `Resolved (${event.status})`,
110
+ });
111
+ break;
112
+ case 'result': {
113
+ const ok = event.subtype === 'success';
114
+ const cost = event.totalCostUsd > 0 ? ` · $${event.totalCostUsd.toFixed(4)}` : '';
115
+ const turns = `${event.numTurns} turn${event.numTurns === 1 ? '' : 's'}`;
116
+ rows.push({
117
+ id: `e${i}`,
118
+ kind: 'result',
119
+ text: ok ? `Done · ${turns}${cost}` : `Ended: ${event.subtype} · ${turns}${cost}`,
120
+ ok,
121
+ });
122
+ break;
123
+ }
124
+ // init, progress: no transcript row.
125
+ }
126
+ });
127
+ return rows;
128
+ }
129
+
130
+ /** The latest unanswered ask_user, if the run is currently blocked on one. */
131
+ export function pendingAsk(
132
+ events: AgentEvent[],
133
+ ): { askId: string; question: string; options: string[] } | null {
134
+ let ask: { askId: string; question: string; options: string[] } | null = null;
135
+ for (const event of events) {
136
+ if (event.type === 'ask_user') {
137
+ ask = { askId: event.askId, question: event.question, options: event.options ?? [] };
138
+ }
139
+ // A terminal result/error clears any pending question.
140
+ if (event.type === 'result' || event.type === 'error') ask = null;
141
+ }
142
+ return ask;
143
+ }
@@ -0,0 +1,162 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * POST the assembled feedback to the Metro dev server.
4
+ *
5
+ * The web widget POSTs to a same-origin `/__pinagent/feedback`. RN has no
6
+ * origin, so we derive the dev-server base from the bundle URL
7
+ * (`NativeModules.SourceCode.scriptURL`) — that's the host Metro is
8
+ * already serving from, which also resolves the awkward cases for free:
9
+ * a physical device gets the LAN host, the iOS simulator gets localhost,
10
+ * the Android emulator gets `10.0.2.2`. No hard-coded host needed.
11
+ */
12
+ import { NativeModules, Platform } from 'react-native';
13
+ import type { FeedbackInput } from './types';
14
+
15
+ interface DevServerInfo {
16
+ url?: string;
17
+ bundleLoadedFromServer?: boolean;
18
+ }
19
+
20
+ let cachedGetDevServer: (() => DevServerInfo) | null | undefined;
21
+
22
+ /**
23
+ * RN's own dev-server resolver. It reads the `NativeSourceCode` **TurboModule**,
24
+ * which — unlike the legacy `NativeModules.SourceCode` bridge proxy — is
25
+ * populated under the New Architecture (bridgeless, RN 0.82+, the only mode RN
26
+ * ships now). Lazy + cached: a release build (widget `__DEV__`-gated away)
27
+ * never reaches into RN internals. `require` takes a static string literal —
28
+ * Metro forbids `require(variable)`.
29
+ */
30
+ function loadGetDevServer(): (() => DevServerInfo) | null {
31
+ if (cachedGetDevServer !== undefined) return cachedGetDevServer;
32
+ try {
33
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
34
+ const mod = require('react-native/Libraries/Core/Devtools/getDevServer');
35
+ const fn = (mod as { default?: unknown })?.default ?? mod;
36
+ cachedGetDevServer = typeof fn === 'function' ? (fn as () => DevServerInfo) : null;
37
+ } catch {
38
+ cachedGetDevServer = null;
39
+ }
40
+ return cachedGetDevServer;
41
+ }
42
+
43
+ /**
44
+ * Parse `http://192.168.1.5:8081/index.bundle?...` (or the RN packager's
45
+ * variants) down to `http://192.168.1.5:8081`. Returns null in release builds,
46
+ * where no Metro server is reachable.
47
+ *
48
+ * Prefers RN's `getDevServer()` (TurboModule-backed, works under the New
49
+ * Architecture) and falls back to the legacy `NativeModules.SourceCode.scriptURL`
50
+ * for pre-bridgeless RN. The legacy proxy is empty under bridgeless, which is
51
+ * what made every submit fail with "No dev server" on RN 0.82+.
52
+ */
53
+ export function devServerBaseUrl(): string | null {
54
+ const getDevServer = loadGetDevServer();
55
+ if (getDevServer) {
56
+ try {
57
+ const info = getDevServer();
58
+ // `bundleLoadedFromServer` is false in release builds (url defaults to
59
+ // localhost:8081 there, so the url alone can't be trusted).
60
+ if (info?.url && info.bundleLoadedFromServer !== false) {
61
+ const m = /^(https?:\/\/[^/]+)/.exec(info.url);
62
+ if (m) return m[1]!;
63
+ }
64
+ } catch {
65
+ // Fall through to the legacy bridge read.
66
+ }
67
+ }
68
+ const scriptURL: string | undefined = NativeModules?.SourceCode?.scriptURL;
69
+ if (!scriptURL) return null;
70
+ const match = /^(https?:\/\/[^/]+)/.exec(scriptURL);
71
+ return match ? match[1]! : null;
72
+ }
73
+
74
+ export interface SubmitResult {
75
+ ok: boolean;
76
+ id?: string;
77
+ agentSpawned?: boolean;
78
+ error?: string;
79
+ }
80
+
81
+ /**
82
+ * Send one comment. Mirrors the web widget's POST to
83
+ * `/__pinagent/feedback`; the response (`{ id, agentSpawned }`) is the
84
+ * same one the Vite/Next middleware returns.
85
+ */
86
+ export async function submitFeedback(input: FeedbackInput): Promise<SubmitResult> {
87
+ const base = devServerBaseUrl();
88
+ if (!base) {
89
+ return { ok: false, error: 'No dev server (release build?)' };
90
+ }
91
+ try {
92
+ const res = await fetch(`${base}/__pinagent/feedback`, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify(input),
96
+ });
97
+ if (!res.ok) {
98
+ const text = await res.text().catch(() => '');
99
+ return { ok: false, error: `${res.status} ${text}`.trim() };
100
+ }
101
+ const json = (await res.json()) as { id?: string; agentSpawned?: boolean };
102
+ return { ok: true, id: json.id, agentSpawned: json.agentSpawned };
103
+ } catch (e) {
104
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Fetch the conversation list from the dev server (`GET /__pinagent/feedback`).
110
+ * Used on `<Pinagent/>` mount to restore minimized pills after an app reload —
111
+ * the server's `.pinagent/db.sqlite` is the source of truth, so RN keeps no
112
+ * device-local mirror. Returns `[]` (degrade silently) when the dev server is
113
+ * unreachable or the request fails — exactly as today when there's no server.
114
+ *
115
+ * The items are the `storage.list()` projection (`FeedbackRecord[]`); the
116
+ * caller filters them with `restorePills`. Typed loosely here so the wire JSON
117
+ * doesn't drag the agent-runner type into RN source.
118
+ */
119
+ export async function fetchFeedbackList(): Promise<unknown[]> {
120
+ const base = devServerBaseUrl();
121
+ if (!base) return [];
122
+ try {
123
+ const res = await fetch(`${base}/__pinagent/feedback`, {
124
+ method: 'GET',
125
+ headers: { Accept: 'application/json' },
126
+ });
127
+ if (!res.ok) return [];
128
+ const json = (await res.json()) as unknown;
129
+ return Array.isArray(json) ? json : [];
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Ask the dev server to open a source location in the editor on the machine
137
+ * running Metro — the RN analog of the web composer's "navigate to file".
138
+ * Fire-and-forget: the device gets no useful signal beyond "request sent".
139
+ */
140
+ export async function openInEditor(loc: {
141
+ file: string;
142
+ line: number;
143
+ col: number;
144
+ }): Promise<boolean> {
145
+ const base = devServerBaseUrl();
146
+ if (!base) return false;
147
+ try {
148
+ const res = await fetch(`${base}/__pinagent/open`, {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify(loc),
152
+ });
153
+ return res.ok;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /** `${Platform.OS} ${Platform.Version}` — RN's stand-in for a UA string. */
160
+ export function platformTag(): string {
161
+ return `${Platform.OS} ${String(Platform.Version)}`;
162
+ }
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * The wire shape the RN widget produces. It mirrors `FeedbackInputSchema`
4
+ * in `packages/agent-runner/src/storage.ts` so the existing server
5
+ * accepts a phone-filed comment with zero backend changes. Keep this in
6
+ * lockstep with that zod schema — the middleware re-validates against it.
7
+ */
8
+ export interface FeedbackInput {
9
+ /** Comment text the developer typed. 1..8000 chars (server-enforced). */
10
+ comment: string;
11
+ /**
12
+ * Source location of the tapped component, from the fiber's
13
+ * `_debugSource` (the RN analog of web's `data-pa-loc`). Null when the
14
+ * tapped view has no resolvable source (a deep native view with no
15
+ * composite owner in dev).
16
+ */
17
+ loc: { file: string; line: number; col: number } | null;
18
+ /**
19
+ * Web sends a CSS selector here for HMR re-anchoring. RN has no
20
+ * selectors, so v1 sends the component display-name chain (e.g.
21
+ * "App > HomeScreen > PrimaryButton") purely to satisfy the schema and
22
+ * give the agent a human-readable hint. See the design doc's
23
+ * "Deliberate cuts for v1".
24
+ */
25
+ selector: string;
26
+ /** The current route/screen name (web sends the page URL). */
27
+ url: string;
28
+ /** Window dimensions at pick time. */
29
+ viewport: { w: number; h: number };
30
+ /** `${Platform.OS} ${Platform.Version}` — the RN analog of a UA string. */
31
+ userAgent: string;
32
+ /** base64 PNG (no data: prefix). Capped at 5MB by the middleware. */
33
+ screenshot: string;
34
+ /** ISO timestamp. */
35
+ createdAt: string;
36
+ /**
37
+ * Extra elements the developer multi-picked into the SAME comment (the
38
+ * 2nd…Nth taps). Optional — omitted entirely for a single pick, exactly
39
+ * like the web widget (the server stores `additional_anchors` as null
40
+ * unless this is a non-empty array). The agent receives them as
41
+ * `additionalTargets` and addresses every location. Same per-anchor shape
42
+ * the web sends in `FeedbackInputSchema.additionalAnchors`
43
+ * (`packages/agent-runner/src/storage.ts`).
44
+ */
45
+ additionalAnchors?: AdditionalAnchor[];
46
+ }
47
+
48
+ /**
49
+ * One extra (non-primary) pick. Matches the web `AdditionalAnchorSchema`
50
+ * exactly so the server accepts it unchanged: `clickX`/`clickY` are required
51
+ * integers (the tap point in window coordinates); `component` is optional.
52
+ */
53
+ export interface AdditionalAnchor {
54
+ file: string | null;
55
+ line: number | null;
56
+ col: number | null;
57
+ selector: string;
58
+ clickX: number;
59
+ clickY: number;
60
+ component?: string | null;
61
+ }
62
+
63
+ /** One segment of the component breadcrumb. */
64
+ export interface PickCrumb {
65
+ /** Component display name (e.g. `FeatureCard`). */
66
+ name: string;
67
+ /**
68
+ * Source location of that component, from its own `data-pa-loc`. Null when
69
+ * the component carries no resolvable source (e.g. an untagged 3rd-party
70
+ * wrapper). Lets the breadcrumb re-anchor the comment onto an ancestor.
71
+ */
72
+ loc: FeedbackInput['loc'];
73
+ /**
74
+ * Highlight rectangle for this component (window coordinates), so pressing
75
+ * the crumb moves the on-screen selection outline onto it. Null when it
76
+ * couldn't be measured.
77
+ */
78
+ frame: { x: number; y: number; width: number; height: number } | null;
79
+ }
80
+
81
+ /** Result of resolving a tap point to a source location. */
82
+ export interface PickResult {
83
+ /** The precise tapped element's source location (the default target). */
84
+ loc: FeedbackInput['loc'];
85
+ /** Component display-name breadcrumb, newest (tapped) last. */
86
+ nameChain: string[];
87
+ /**
88
+ * Per-segment breadcrumb (same order as {@link nameChain}: root first,
89
+ * tapped component last), each carrying its own `loc` so pressing a
90
+ * breadcrumb can re-anchor the comment onto that ancestor.
91
+ */
92
+ chain: PickCrumb[];
93
+ /** Highlight rectangle in window coordinates, for the overlay outline. */
94
+ frame: { x: number; y: number; width: number; height: number } | null;
95
+ }
@@ -0,0 +1,173 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * RN WebSocket client for live agent streaming.
4
+ *
5
+ * Connects to the same Metro host the feedback POST uses (derived from
6
+ * `devServerBaseUrl()`), swapping the scheme to `ws(s)` and hitting
7
+ * `/__pinagent/ws` — the endpoint the server mounts via
8
+ * `pinagentWebsocketEndpoints` (Metro `config.server.websocketEndpoints`).
9
+ * Because it rides Metro's own port, a physical device needs no port
10
+ * discovery: if the bundle loaded, this URL is reachable.
11
+ *
12
+ * The wire protocol is the web one (`@pinagent/shared`'s ws-protocol): we send
13
+ * `subscribe` / `user_message` / `ask_response` / `interrupt`, and receive
14
+ * `event` / `done` / `error`. On reconnect the server replays the full
15
+ * transcript, so we fire `onReset` first to let the UI rebuild from scratch.
16
+ *
17
+ * Scoped to ONE feedback id per client — the RN widget streams a single
18
+ * conversation at a time (the one just submitted). That keeps this far smaller
19
+ * than the web client's multiplexed map.
20
+ */
21
+
22
+ import type { AgentEvent, ServerMessage } from './transcript';
23
+ import { devServerBaseUrl } from './transport';
24
+
25
+ const RECONNECT_MIN_MS = 500;
26
+ const RECONNECT_MAX_MS = 8000;
27
+
28
+ export interface StreamHandlers {
29
+ /** A reconnect is about to replay the transcript — clear and rebuild. */
30
+ onReset(): void;
31
+ onEvent(event: AgentEvent): void;
32
+ /** The run's bus closed (agent finished or idle). */
33
+ onDone(): void;
34
+ onError(message: string): void;
35
+ }
36
+
37
+ /** Derive `ws(s)://host:port/__pinagent/ws` from Metro's bundle URL. */
38
+ export function devServerWsUrl(): string | null {
39
+ const base = devServerBaseUrl();
40
+ if (!base) return null;
41
+ return `${base.replace(/^http/, 'ws')}/__pinagent/ws`;
42
+ }
43
+
44
+ export class StreamClient {
45
+ private socket: WebSocket | null = null;
46
+ private reconnectDelay = RECONNECT_MIN_MS;
47
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
48
+ private closed = false;
49
+ private connectedBefore = false;
50
+
51
+ constructor(
52
+ private readonly feedbackId: string,
53
+ private readonly handlers: StreamHandlers,
54
+ ) {}
55
+
56
+ /** Open the socket and subscribe. Safe to call once per instance. */
57
+ start(): void {
58
+ this.closed = false;
59
+ this.connect();
60
+ }
61
+
62
+ /** Tear down for good — no further reconnects. Call on unmount/dismiss. */
63
+ stop(): void {
64
+ this.closed = true;
65
+ if (this.reconnectTimer) {
66
+ clearTimeout(this.reconnectTimer);
67
+ this.reconnectTimer = null;
68
+ }
69
+ try {
70
+ this.socket?.close();
71
+ } catch {
72
+ // already closing
73
+ }
74
+ this.socket = null;
75
+ }
76
+
77
+ /** Queue a follow-up turn for the agent. No-op if the socket isn't open. */
78
+ sendUserMessage(content: string): void {
79
+ this.send({ type: 'user_message', feedbackId: this.feedbackId, content });
80
+ }
81
+
82
+ /** Answer an `ask_user` prompt. */
83
+ sendAskResponse(askId: string, answer: string): void {
84
+ this.send({ type: 'ask_response', askId, answer });
85
+ }
86
+
87
+ /** Interrupt the in-flight run. */
88
+ interrupt(): void {
89
+ this.send({ type: 'interrupt', feedbackId: this.feedbackId });
90
+ }
91
+
92
+ private connect(): void {
93
+ const url = devServerWsUrl();
94
+ if (!url) {
95
+ this.handlers.onError('No dev server (release build?)');
96
+ return;
97
+ }
98
+ let socket: WebSocket;
99
+ try {
100
+ socket = new WebSocket(url);
101
+ } catch {
102
+ this.scheduleReconnect();
103
+ return;
104
+ }
105
+ this.socket = socket;
106
+
107
+ socket.onopen = () => {
108
+ // On a reconnect the server replays the whole transcript; let the UI
109
+ // wipe and rebuild so we don't double-render.
110
+ if (this.connectedBefore) this.handlers.onReset();
111
+ this.connectedBefore = true;
112
+ this.reconnectDelay = RECONNECT_MIN_MS;
113
+ this.send({ type: 'subscribe', feedbackId: this.feedbackId });
114
+ };
115
+
116
+ socket.onmessage = (ev: { data?: unknown }) => {
117
+ this.onMessage(ev.data);
118
+ };
119
+
120
+ socket.onclose = () => {
121
+ if (this.closed) return;
122
+ this.scheduleReconnect();
123
+ };
124
+
125
+ // RN fires onerror then onclose; reconnect is driven by onclose.
126
+ socket.onerror = () => {};
127
+ }
128
+
129
+ private onMessage(data: unknown): void {
130
+ if (typeof data !== 'string') return;
131
+ let msg: ServerMessage;
132
+ try {
133
+ msg = JSON.parse(data) as ServerMessage;
134
+ } catch {
135
+ return;
136
+ }
137
+ switch (msg.type) {
138
+ case 'event':
139
+ if (msg.feedbackId === this.feedbackId && msg.event) this.handlers.onEvent(msg.event);
140
+ break;
141
+ case 'done':
142
+ if (msg.feedbackId === this.feedbackId) this.handlers.onDone();
143
+ break;
144
+ case 'error':
145
+ // Server scopes some errors to a feedback id; surface ours + global.
146
+ if (!msg.feedbackId || msg.feedbackId === this.feedbackId) {
147
+ this.handlers.onError(msg.message ?? 'unknown error');
148
+ }
149
+ break;
150
+ // worktree_state / pong / project fan-out: ignored by the RN widget.
151
+ }
152
+ }
153
+
154
+ private send(msg: Record<string, unknown>): void {
155
+ const s = this.socket;
156
+ if (!s || s.readyState !== 1 /* OPEN */) return;
157
+ try {
158
+ s.send(JSON.stringify(msg));
159
+ } catch {
160
+ // socket closing mid-write
161
+ }
162
+ }
163
+
164
+ private scheduleReconnect(): void {
165
+ if (this.closed || this.reconnectTimer) return;
166
+ const delay = this.reconnectDelay;
167
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
168
+ this.reconnectTimer = setTimeout(() => {
169
+ this.reconnectTimer = null;
170
+ if (!this.closed) this.connect();
171
+ }, delay);
172
+ }
173
+ }