@reticlehq/browser 2.4.0 → 2.6.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.
- package/dist/actions/actions-dom.d.ts +8 -1
- package/dist/actions/actions-dom.js +26 -12
- package/dist/actions/actions.d.ts +46 -0
- package/dist/actions/actions.js +114 -26
- package/dist/actions/appeared-text.d.ts +27 -0
- package/dist/actions/appeared-text.js +92 -0
- package/dist/actions/scroll.js +1 -1
- package/dist/commands/commands.js +45 -22
- package/dist/connect-options.d.ts +6 -0
- package/dist/dom/a11y.js +20 -20
- package/dist/dom/auto-anchor.js +1 -1
- package/dist/dom/blind-spots.js +1 -1
- package/dist/dom/chart.js +5 -5
- package/dist/dom/desktop-capture.d.ts +2 -1
- package/dist/dom/desktop-capture.js +4 -4
- package/dist/dom/occlusion.js +2 -2
- package/dist/dom/query.js +68 -25
- package/dist/dom/realm.js +13 -13
- package/dist/dom/refs.d.ts +14 -0
- package/dist/dom/refs.js +20 -1
- package/dist/dom/shadow-registry.js +2 -2
- package/dist/dom/snapshot.js +32 -11
- package/dist/dom/source.js +3 -3
- package/dist/dom/theme.js +6 -6
- package/dist/dom/virtualized.js +1 -1
- package/dist/observers/blind-spots.js +2 -2
- package/dist/observers/console.js +3 -3
- package/dist/observers/dom.js +5 -5
- package/dist/observers/download.d.ts +2 -1
- package/dist/observers/download.js +2 -2
- package/dist/observers/focus.js +1 -1
- package/dist/observers/frames.js +1 -1
- package/dist/observers/health.js +70 -2
- package/dist/observers/install-all.d.ts +2 -1
- package/dist/observers/ipc.d.ts +2 -7
- package/dist/observers/ipc.js +7 -7
- package/dist/observers/network-body.js +2 -2
- package/dist/observers/network-redact.js +5 -5
- package/dist/observers/network-stream.js +2 -2
- package/dist/observers/network.d.ts +3 -3
- package/dist/observers/network.js +9 -9
- package/dist/observers/perf.js +1 -1
- package/dist/observers/scroll.js +1 -1
- package/dist/observers/state.js +1 -1
- package/dist/observers/storage.js +7 -7
- package/dist/presenter/presenter-brand.d.ts +19 -0
- package/dist/presenter/presenter-brand.js +21 -0
- package/dist/presenter/presenter-config.d.ts +17 -0
- package/dist/presenter/presenter-config.js +21 -0
- package/dist/presenter/presenter-controls.d.ts +1 -11
- package/dist/presenter/presenter-controls.js +6 -7
- package/dist/presenter/presenter-log.js +2 -2
- package/dist/presenter/presenter-run-state.js +1 -1
- package/dist/presenter/presenter-styles.d.ts +1 -1
- package/dist/presenter/presenter-styles.js +20 -9
- package/dist/presenter/presenter-tally.js +3 -3
- package/dist/presenter/presenter-test-helpers.d.ts +2 -1
- package/dist/presenter/presenter-test-helpers.js +2 -2
- package/dist/presenter/presenter-verbs.js +16 -11
- package/dist/presenter/presenter.js +13 -10
- package/dist/recorder/recorder.js +10 -9
- package/dist/registry/store-adapters.js +1 -1
- package/dist/registry/stores.js +3 -3
- package/dist/reticle-presenter-helpers.js +3 -3
- package/dist/reticle.js +31 -13
- package/dist/review/annotator.js +7 -7
- package/dist/security/serialization.js +9 -9
- package/dist/session-continuity.d.ts +17 -0
- package/dist/session-continuity.js +42 -0
- package/dist/timers/clock.js +28 -15
- package/dist/timers/native-console.js +1 -1
- package/dist/timers/native-timers.js +4 -4
- package/dist/transport/transport.d.ts +23 -0
- package/dist/transport/transport.js +85 -25
- package/package.json +6 -4
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep a tab's session id across a reload.
|
|
3
|
+
*
|
|
4
|
+
* The id was minted fresh on every `connect()`. A reload is a new document, so the page came back as
|
|
5
|
+
* a NEW session while the agent still held the old id — and every call after
|
|
6
|
+
* `reticle_navigate { reload: true }` was refused with "no browser session connected". Reported on 6
|
|
7
|
+
* of 6 apps; a reload is the most ordinary thing an agent does.
|
|
8
|
+
*
|
|
9
|
+
* `sessionStorage` is exactly the right scope: it survives reloads and same-tab navigations, and is
|
|
10
|
+
* NOT shared with another tab of the same app — so two tabs stay two sessions, which is what the
|
|
11
|
+
* multi-session tooling assumes.
|
|
12
|
+
*
|
|
13
|
+
* An explicit id still wins. `connect({ session })` and the `__reticle_session` URL param a lease
|
|
14
|
+
* stamps are callers ASSERTING an identity, not asking for one; they are also remembered, so a
|
|
15
|
+
* leased tab that reloads rejoins its own lease.
|
|
16
|
+
*/
|
|
17
|
+
/** Where the id lives. Namespaced like the URL param it mirrors. */
|
|
18
|
+
const STORAGE_KEY = '__reticle_session';
|
|
19
|
+
export function rememberSessionLabel(explicit, store, generate) {
|
|
20
|
+
const read = () => {
|
|
21
|
+
try {
|
|
22
|
+
const value = store?.getItem(STORAGE_KEY);
|
|
23
|
+
return value !== null && value !== undefined && value.length > 0 ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined; // a sandboxed iframe throws on access; a session must still start
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const write = (value) => {
|
|
30
|
+
try {
|
|
31
|
+
store?.setItem(STORAGE_KEY, value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* same: never block connect on storage */
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
// `explicit` may be '' — resolveSessionLabel yields that for "none given" — and `??` would take it.
|
|
38
|
+
const asked = explicit !== undefined && explicit.length > 0 ? explicit : undefined;
|
|
39
|
+
const chosen = asked ?? read() ?? generate();
|
|
40
|
+
write(chosen);
|
|
41
|
+
return chosen;
|
|
42
|
+
}
|
package/dist/timers/clock.js
CHANGED
|
@@ -12,7 +12,7 @@ export function isClockFrozen() {
|
|
|
12
12
|
return installed;
|
|
13
13
|
}
|
|
14
14
|
export function freezeClock() {
|
|
15
|
-
if (installed || typeof window
|
|
15
|
+
if (installed || 'undefined' === typeof window)
|
|
16
16
|
return;
|
|
17
17
|
installed = true;
|
|
18
18
|
virtualNow = 0;
|
|
@@ -81,7 +81,7 @@ export function advanceClock(ms) {
|
|
|
81
81
|
* in still has ~3s to go), and the ids the app is holding keep working — see the translation note.
|
|
82
82
|
*/
|
|
83
83
|
export function resetClock() {
|
|
84
|
-
if (!installed ||
|
|
84
|
+
if (!installed || null === originals)
|
|
85
85
|
return;
|
|
86
86
|
const natives = originals;
|
|
87
87
|
const pending = tasks;
|
|
@@ -95,7 +95,7 @@ export function resetClock() {
|
|
|
95
95
|
tasks = [];
|
|
96
96
|
installed = false;
|
|
97
97
|
virtualNow = 0;
|
|
98
|
-
if (pending.length
|
|
98
|
+
if (0 === pending.length)
|
|
99
99
|
return;
|
|
100
100
|
// Re-arm the app's pending work onto REAL timers, translating ids as we go.
|
|
101
101
|
//
|
|
@@ -109,18 +109,26 @@ export function resetClock() {
|
|
|
109
109
|
//
|
|
110
110
|
// So keep a translation map and a clear-shim for exactly as long as re-armed work is outstanding.
|
|
111
111
|
// Nothing is dropped, and the app's handles keep working.
|
|
112
|
+
// Bound to `window`, not called off `natives`. A timer native invoked with any other receiver
|
|
113
|
+
// throws `TypeError: Illegal invocation` in a real browser — and `natives.setTimeout(...)` passes
|
|
114
|
+
// `natives`. jsdom does not enforce the receiver, so every unit test here passed while the browser
|
|
115
|
+
// threw on the one path that matters: re-arming work the app queued during the freeze. The early
|
|
116
|
+
// return above means it only fires when something IS pending, which is exactly when this function
|
|
117
|
+
// has a job to do. Caught by `bench/harness/clock-timetravel.mjs` driving real Chromium.
|
|
118
|
+
const nativeSetTimeout = natives.setTimeout.bind(window);
|
|
119
|
+
const nativeSetInterval = natives.setInterval.bind(window);
|
|
112
120
|
const reArmed = new Map();
|
|
113
121
|
const done = (virtualId) => {
|
|
114
122
|
reArmed.delete(virtualId);
|
|
115
|
-
if (reArmed.size
|
|
123
|
+
if (0 === reArmed.size)
|
|
116
124
|
restoreRawClears(natives);
|
|
117
125
|
};
|
|
118
126
|
for (const task of pending) {
|
|
119
127
|
if (task.interval !== undefined) {
|
|
120
|
-
reArmed.set(task.id,
|
|
128
|
+
reArmed.set(task.id, nativeSetInterval(task.cb, task.interval));
|
|
121
129
|
}
|
|
122
130
|
else {
|
|
123
|
-
reArmed.set(task.id,
|
|
131
|
+
reArmed.set(task.id, nativeSetTimeout(() => {
|
|
124
132
|
done(task.id);
|
|
125
133
|
task.cb();
|
|
126
134
|
}, Math.max(0, task.time - resumeFrom)));
|
|
@@ -130,15 +138,20 @@ export function resetClock() {
|
|
|
130
138
|
}
|
|
131
139
|
/** Route a clear() for a re-armed virtual id to its real native id; everything else passes through. */
|
|
132
140
|
function installTranslatingClears(reArmed, natives, done) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
// `.bind(window)` for the same reason as the re-arm above: a bare `rawClear(id)` reaches the DOM
|
|
142
|
+
// with the wrong receiver and throws `Illegal invocation` in a real browser.
|
|
143
|
+
const translate = (rawClear) => {
|
|
144
|
+
const clear = rawClear.bind(window);
|
|
145
|
+
return ((id) => {
|
|
146
|
+
const nativeId = reArmed.get(id);
|
|
147
|
+
if (nativeId === undefined) {
|
|
148
|
+
clear(id);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
clear(nativeId);
|
|
152
|
+
done(id);
|
|
153
|
+
});
|
|
154
|
+
};
|
|
142
155
|
window.clearTimeout = translate(natives.clearTimeout);
|
|
143
156
|
window.clearInterval = translate(natives.clearInterval);
|
|
144
157
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// MUST use this instead of bare `console.warn` — otherwise they emit spurious CONSOLE_WARN
|
|
4
4
|
// events to the agent, polluting the observation stream with SDK internals.
|
|
5
5
|
const g = globalThis;
|
|
6
|
-
const realWarn = typeof g.console?.warn
|
|
6
|
+
const realWarn = 'function' === typeof g.console?.warn ? g.console.warn.bind(g.console) : null;
|
|
7
7
|
export const nativeWarn = (...args) => {
|
|
8
8
|
realWarn?.(...args);
|
|
9
9
|
};
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// Important: we bind the reference now. Resolving `setTimeout` at call time would pick up the
|
|
6
6
|
// patched (frozen) timer once reticle_clock freezes the clock — which would hang the SDK.
|
|
7
7
|
const g = globalThis;
|
|
8
|
-
const realSetTimeout = typeof g.setTimeout
|
|
9
|
-
const realClearTimeout = typeof g.clearTimeout
|
|
10
|
-
const realRaf = typeof g.requestAnimationFrame
|
|
11
|
-
const realPerfNow = typeof g.performance?.now
|
|
8
|
+
const realSetTimeout = 'function' === typeof g.setTimeout ? g.setTimeout.bind(g) : null;
|
|
9
|
+
const realClearTimeout = 'function' === typeof g.clearTimeout ? g.clearTimeout.bind(g) : null;
|
|
10
|
+
const realRaf = 'function' === typeof g.requestAnimationFrame ? g.requestAnimationFrame.bind(g) : null;
|
|
11
|
+
const realPerfNow = 'function' === typeof g.performance?.now ? g.performance.now.bind(g.performance) : null;
|
|
12
12
|
/**
|
|
13
13
|
* Monotonic "now" bound at load. clock.ts deliberately does NOT patch performance.now, so this
|
|
14
14
|
* stays live even when the app clock (reticle_clock) is frozen — the presenter state machine relies
|
|
@@ -36,7 +36,30 @@ interface TransportDeps {
|
|
|
36
36
|
* unsubscribe; injected so a test can drive visibility without a real document.
|
|
37
37
|
*/
|
|
38
38
|
onVisible?: (handler: () => void) => () => void;
|
|
39
|
+
/**
|
|
40
|
+
* Schedule an internal retry (default: `nativeSetTimeout`). Injected for the same reason `now` and
|
|
41
|
+
* `onVisible` are — the default is bound to the real timer at module load so a frozen app clock
|
|
42
|
+
* cannot deadlock the SDK, which also means no fake clock can observe what was scheduled.
|
|
43
|
+
*/
|
|
44
|
+
schedule?: (fn: () => void, ms: number) => void;
|
|
39
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The ceiling on reconnect backoff.
|
|
48
|
+
*
|
|
49
|
+
* A flat 1s retry with no growth and no cap meant a tab left open against a stopped daemon made
|
|
50
|
+
* ~600 failed WebSocket opens in ten minutes — CPU, and a devtools console full of
|
|
51
|
+
* ERR_CONNECTION_REFUSED. Backing off is only safe BECAUSE this transport also reconnects on
|
|
52
|
+
* focus/visibility, so a human returning to the tab retries immediately rather than waiting out
|
|
53
|
+
* the delay; the backoff resets on that path for the same reason.
|
|
54
|
+
*/
|
|
55
|
+
export declare const RECONNECT_MAX_DELAY_MS = 30000;
|
|
56
|
+
/**
|
|
57
|
+
* The next reconnect delay: double, then clamp. Pure, and exported so the POLICY is testable —
|
|
58
|
+
* the scheduling itself runs on `nativeSetTimeout`, which is bound to the real timer at module load
|
|
59
|
+
* (deliberately, so a frozen `reticle_clock` cannot deadlock the SDK) and therefore cannot be driven
|
|
60
|
+
* by a fake clock.
|
|
61
|
+
*/
|
|
62
|
+
export declare function nextReconnectDelay(previous: number): number;
|
|
40
63
|
/** Offline queue cap. Exported so tests overflow the real bound instead of a mirrored copy of it. */
|
|
41
64
|
export declare const MAX_QUEUE = 500;
|
|
42
65
|
/** WebSocket client to the bridge. Reconnects across reloads; buffers events while down. */
|
|
@@ -1,19 +1,39 @@
|
|
|
1
|
-
import { CommandMessageSchema, EventType, MessageKind, SESSION_LIFECYCLE, } from '@reticlehq/core';
|
|
1
|
+
import { CHURN_TYPES, CommandMessageSchema, EventType, MessageKind, SESSION_LIFECYCLE, } from '@reticlehq/core';
|
|
2
2
|
import { nativeSetTimeout, nativeNow } from '../timers/native-timers.js';
|
|
3
3
|
import { nativeWarn } from '../timers/native-console.js';
|
|
4
4
|
import { safeStringify } from '../security/serialization.js';
|
|
5
5
|
/** Default visibility source: fire `handler` whenever the document returns to the foreground. */
|
|
6
6
|
function subscribeDocumentVisible(handler) {
|
|
7
|
-
if (typeof document
|
|
7
|
+
if ('undefined' === typeof document)
|
|
8
8
|
return () => undefined;
|
|
9
9
|
const listener = () => {
|
|
10
|
-
if (document.visibilityState
|
|
10
|
+
if ('visible' === document.visibilityState)
|
|
11
11
|
handler();
|
|
12
12
|
};
|
|
13
13
|
document.addEventListener('visibilitychange', listener);
|
|
14
14
|
return () => document.removeEventListener('visibilitychange', listener);
|
|
15
15
|
}
|
|
16
|
+
/** The first retry after a drop. Kept short: the common outage is a daemon restarting. */
|
|
16
17
|
const RECONNECT_DELAY_MS = 1000;
|
|
18
|
+
/**
|
|
19
|
+
* The ceiling on reconnect backoff.
|
|
20
|
+
*
|
|
21
|
+
* A flat 1s retry with no growth and no cap meant a tab left open against a stopped daemon made
|
|
22
|
+
* ~600 failed WebSocket opens in ten minutes — CPU, and a devtools console full of
|
|
23
|
+
* ERR_CONNECTION_REFUSED. Backing off is only safe BECAUSE this transport also reconnects on
|
|
24
|
+
* focus/visibility, so a human returning to the tab retries immediately rather than waiting out
|
|
25
|
+
* the delay; the backoff resets on that path for the same reason.
|
|
26
|
+
*/
|
|
27
|
+
export const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
28
|
+
/**
|
|
29
|
+
* The next reconnect delay: double, then clamp. Pure, and exported so the POLICY is testable —
|
|
30
|
+
* the scheduling itself runs on `nativeSetTimeout`, which is bound to the real timer at module load
|
|
31
|
+
* (deliberately, so a frozen `reticle_clock` cannot deadlock the SDK) and therefore cannot be driven
|
|
32
|
+
* by a fake clock.
|
|
33
|
+
*/
|
|
34
|
+
export function nextReconnectDelay(previous) {
|
|
35
|
+
return Math.min(previous * 2, RECONNECT_MAX_DELAY_MS);
|
|
36
|
+
}
|
|
17
37
|
/** Offline queue cap. Exported so tests overflow the real bound instead of a mirrored copy of it. */
|
|
18
38
|
export const MAX_QUEUE = 500;
|
|
19
39
|
/**
|
|
@@ -29,10 +49,14 @@ const UNREACHABLE_WARN_AFTER = 3;
|
|
|
29
49
|
/** WebSocket client to the bridge. Reconnects across reloads; buffers events while down. */
|
|
30
50
|
export class Transport {
|
|
31
51
|
#ws;
|
|
32
|
-
#
|
|
52
|
+
#churnQueue = [];
|
|
53
|
+
#signalQueue = [];
|
|
54
|
+
#insertOrder = 0;
|
|
33
55
|
#closed = false;
|
|
34
56
|
/** When the current continuous outage began (nativeNow), or undefined while connected. */
|
|
35
57
|
#disconnectedSince;
|
|
58
|
+
/** Current reconnect delay; grows on each failure, resets on a successful open or on focus. */
|
|
59
|
+
#reconnectDelay = RECONNECT_DELAY_MS;
|
|
36
60
|
/** Whether onConnectionLost has already fired for the current outage (fire-once). */
|
|
37
61
|
#lost = false;
|
|
38
62
|
/** True once the socket has opened at least once — gates the "unreachable" first-connect warning. */
|
|
@@ -60,7 +84,7 @@ export class Transport {
|
|
|
60
84
|
this.#now = deps.now ?? nativeNow;
|
|
61
85
|
}
|
|
62
86
|
connect() {
|
|
63
|
-
if (typeof WebSocket
|
|
87
|
+
if ('undefined' === typeof WebSocket)
|
|
64
88
|
return;
|
|
65
89
|
this.#closed = false;
|
|
66
90
|
this.#unsubscribeVisible ??= (this.#deps.onVisible ?? subscribeDocumentVisible)(() => this.#onVisible());
|
|
@@ -72,7 +96,14 @@ export class Transport {
|
|
|
72
96
|
* exists (connected or mid-connect), so we never open a duplicate racing the scheduled retry.
|
|
73
97
|
*/
|
|
74
98
|
#onVisible() {
|
|
75
|
-
if (this.#closed
|
|
99
|
+
if (this.#closed)
|
|
100
|
+
return;
|
|
101
|
+
// A human just came back to the tab, which is the strongest signal the outage may be over — and
|
|
102
|
+
// it is what makes a 30s ceiling safe. Reset the backoff BEFORE the mid-connect early return: if
|
|
103
|
+
// a socket is already in flight and then fails, that retry should be prompt too, rather than
|
|
104
|
+
// inheriting a delay earned while nobody was watching.
|
|
105
|
+
this.#reconnectDelay = RECONNECT_DELAY_MS;
|
|
106
|
+
if (this.#ws !== undefined)
|
|
76
107
|
return;
|
|
77
108
|
this.#open();
|
|
78
109
|
}
|
|
@@ -90,8 +121,7 @@ export class Transport {
|
|
|
90
121
|
// note the outage and schedule the normal retry.
|
|
91
122
|
this.#noteOutage();
|
|
92
123
|
this.#noteInitialFailure();
|
|
93
|
-
|
|
94
|
-
nativeSetTimeout(() => this.#reopen(), RECONNECT_DELAY_MS);
|
|
124
|
+
this.#scheduleReopen();
|
|
95
125
|
return;
|
|
96
126
|
}
|
|
97
127
|
this.#ws = ws;
|
|
@@ -99,22 +129,26 @@ export class Transport {
|
|
|
99
129
|
this.#disconnectedSince = undefined; // healthy again — reset the loss timer
|
|
100
130
|
this.#lost = false;
|
|
101
131
|
this.#everConnected = true; // a real connection happened ⇒ never warn "unreachable"
|
|
132
|
+
this.#reconnectDelay = RECONNECT_DELAY_MS; // recovered — the next drop starts prompt again
|
|
102
133
|
// HELLO is SDK-owned schema data. Preserve its pairing token; the generic sanitizer
|
|
103
134
|
// intentionally redacts fields named "token" from app-controlled payloads.
|
|
104
135
|
const greeting = this.#deps.hello();
|
|
105
136
|
this.#sessionId ??= greeting.sessionId;
|
|
106
137
|
ws.send(JSON.stringify(greeting));
|
|
107
|
-
// Declare any gap BEFORE replaying the backlog
|
|
108
|
-
//
|
|
138
|
+
// Declare any gap BEFORE replaying the backlog, stamped with the OLDEST event the queue
|
|
139
|
+
// dropped — see #sendRaw. The two queues are drained back into one stream in insertion order,
|
|
140
|
+
// so what the bridge receives is the order the page produced, minus the holes.
|
|
109
141
|
this.#sendGapMarker(ws, greeting.sessionId);
|
|
110
|
-
|
|
142
|
+
const replay = [...this.#churnQueue, ...this.#signalQueue].sort((a, b) => a.order - b.order);
|
|
143
|
+
for (const msg of replay)
|
|
111
144
|
ws.send(msg.text);
|
|
112
|
-
this.#
|
|
145
|
+
this.#churnQueue = [];
|
|
146
|
+
this.#signalQueue = [];
|
|
113
147
|
this.#deps.onConnected?.();
|
|
114
148
|
};
|
|
115
149
|
ws.onmessage = (event) => {
|
|
116
150
|
const data = event.data;
|
|
117
|
-
void this.#onMessage(typeof data
|
|
151
|
+
void this.#onMessage('string' === typeof data ? data : String(data));
|
|
118
152
|
};
|
|
119
153
|
ws.onclose = (event) => {
|
|
120
154
|
this.#ws = undefined;
|
|
@@ -131,8 +165,7 @@ export class Transport {
|
|
|
131
165
|
}
|
|
132
166
|
this.#noteOutage();
|
|
133
167
|
this.#noteInitialFailure();
|
|
134
|
-
|
|
135
|
-
nativeSetTimeout(() => this.#reopen(), RECONNECT_DELAY_MS);
|
|
168
|
+
this.#scheduleReopen();
|
|
136
169
|
};
|
|
137
170
|
ws.onerror = () => {
|
|
138
171
|
ws.close();
|
|
@@ -142,6 +175,20 @@ export class Transport {
|
|
|
142
175
|
* A scheduled reconnect — skipped if the SDK was torn down, or if a foreground-triggered reconnect
|
|
143
176
|
* already opened a socket (guard against the throttled timer racing #onVisible into a duplicate).
|
|
144
177
|
*/
|
|
178
|
+
/**
|
|
179
|
+
* Schedule the next retry and grow the delay. Nothing is scheduled once the SDK is torn down.
|
|
180
|
+
*
|
|
181
|
+
* The delay grows AFTER scheduling, so the first retry of any outage is prompt (a daemon
|
|
182
|
+
* restarting must not cost 30 seconds) and only a persistent outage backs off.
|
|
183
|
+
*/
|
|
184
|
+
#scheduleReopen() {
|
|
185
|
+
if (this.#closed)
|
|
186
|
+
return;
|
|
187
|
+
const delay = this.#reconnectDelay;
|
|
188
|
+
this.#reconnectDelay = nextReconnectDelay(delay);
|
|
189
|
+
const schedule = this.#deps.schedule ?? nativeSetTimeout;
|
|
190
|
+
schedule(() => this.#reopen(), delay);
|
|
191
|
+
}
|
|
145
192
|
#reopen() {
|
|
146
193
|
if (this.#closed || this.#ws !== undefined)
|
|
147
194
|
return;
|
|
@@ -189,7 +236,7 @@ export class Transport {
|
|
|
189
236
|
// failure mode where the cause is knowable and was being thrown away. Reply if we can recover
|
|
190
237
|
// an id; a message without one is not a command we can answer at all.
|
|
191
238
|
const id = parsed?.id;
|
|
192
|
-
if (typeof id
|
|
239
|
+
if ('string' === typeof id && id.length > 0) {
|
|
193
240
|
this.#sendRaw(safeStringify({
|
|
194
241
|
kind: MessageKind.COMMAND_RESULT,
|
|
195
242
|
id,
|
|
@@ -241,6 +288,7 @@ export class Transport {
|
|
|
241
288
|
this.#sendRaw(safeStringify({ kind: MessageKind.EVENT, event }), {
|
|
242
289
|
seq: event.seq,
|
|
243
290
|
t: event.t,
|
|
291
|
+
type: event.type,
|
|
244
292
|
});
|
|
245
293
|
}
|
|
246
294
|
/**
|
|
@@ -262,19 +310,31 @@ export class Transport {
|
|
|
262
310
|
this.#ws.send(text);
|
|
263
311
|
return;
|
|
264
312
|
}
|
|
265
|
-
if (this.#
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
// Only an evicted EVENT counts: a dropped COMMAND_RESULT is a reply the agent already gave up
|
|
270
|
-
// waiting for, not a hole in what the app was observed to do.
|
|
271
|
-
const evicted = this.#queue.shift();
|
|
313
|
+
if (this.#churnQueue.length + this.#signalQueue.length >= MAX_QUEUE) {
|
|
314
|
+
// Prefer sacrificing churn (high-volume, low-signal) over signal events. Falls back to signal
|
|
315
|
+
// FIFO only when the churn queue is empty — the buffer is bounded either way.
|
|
316
|
+
const evicted = this.#churnQueue.length > 0 ? this.#churnQueue.shift() : this.#signalQueue.shift();
|
|
272
317
|
if (evicted?.event !== undefined) {
|
|
273
318
|
this.#dropped += 1;
|
|
274
|
-
|
|
319
|
+
// Keep the EARLIEST drop, not the latest. Under plain FIFO the two were the same thing, so
|
|
320
|
+
// "latest evicted" was a precise boundary: everything before it was gone. Churn-first
|
|
321
|
+
// eviction breaks that — a churn event newer than a surviving signal event can be the one
|
|
322
|
+
// sacrificed, and stamping the marker with it would claim a hole that swallows events the
|
|
323
|
+
// agent is about to receive. The oldest drop is the honest floor: the hole starts no later
|
|
324
|
+
// than here. A truncation marker may over-state its reach; it must never under-state it.
|
|
325
|
+
const previous = this.#gap;
|
|
326
|
+
if (previous === undefined || evicted.event.t < previous.t)
|
|
327
|
+
this.#gap = evicted.event;
|
|
275
328
|
}
|
|
276
329
|
}
|
|
277
|
-
|
|
330
|
+
const isChurn = event !== undefined && CHURN_TYPES.has(event.type);
|
|
331
|
+
const order = this.#insertOrder++;
|
|
332
|
+
if (isChurn) {
|
|
333
|
+
this.#churnQueue.push({ text, event, order });
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
this.#signalQueue.push({ text, event, order });
|
|
337
|
+
}
|
|
278
338
|
}
|
|
279
339
|
/**
|
|
280
340
|
* Tell the bridge the offline queue swallowed events, then consider the gap declared.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticlehq/browser",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
-
}
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
15
16
|
"@testing-library/dom": "^10.4.1",
|
|
16
|
-
"@reticlehq/core": "2.
|
|
17
|
+
"@reticlehq/core": "2.6.0"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
20
|
"@tanstack/query-core": "^5.101.4",
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
"build": "tsc -b",
|
|
62
63
|
"typecheck": "tsc -b",
|
|
63
64
|
"lint": "eslint src",
|
|
64
|
-
"test:unit": "vitest run src --passWithNoTests"
|
|
65
|
+
"test:unit": "vitest run src --passWithNoTests",
|
|
66
|
+
"test:coverage": "vitest run src --passWithNoTests --coverage.enabled --coverage.provider=v8 --coverage.reporter=text-summary --coverage.include='src/**/*.ts' --coverage.exclude='src/**/*.test.ts'"
|
|
65
67
|
}
|
|
66
68
|
}
|