@linxin666/dsh-remote-web-ui 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 +14 -5
- package/lib/mobile.js +133 -9
- package/lib/mobile.js.map +1 -1
- package/lib/types/mobile/mux.d.ts +66 -2
- package/lib/types/mobile/mux.d.ts.map +1 -1
- package/lib/types/mobile/mux.js +155 -8
- package/lib/types/mobile/mux.test.d.ts +2 -0
- package/lib/types/mobile/mux.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.test.js +141 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +6 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +20 -6
- package/lib/types/mobile/views/ChatView.test.js +92 -2
- package/package.json +1 -1
- package/src/mobile/mux.test.ts +172 -0
- package/src/mobile/mux.ts +170 -8
- package/src/mobile/views/App.tsx +7 -0
- package/src/mobile/views/ChatView.test.tsx +98 -3
- package/src/mobile/views/ChatView.tsx +20 -6
|
@@ -7,13 +7,40 @@
|
|
|
7
7
|
* opens — no subscription handshake is needed. Frames arrive as
|
|
8
8
|
* server-request envelopes whose payload is the mux frame; unknown frame
|
|
9
9
|
* types are dropped so a newer host never breaks this client.
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* EventSource reconnects automatically — but only over a tunnel that
|
|
12
|
+
* actually forwards frames. Public quick tunnels (Cloudflare quick tunnel /
|
|
13
|
+
* Tailscale Serve) do not transparently pass Server-Sent Events: ordinary
|
|
14
|
+
* HTTP works, yet the SSE connection stays open or reconnects with zero
|
|
15
|
+
* bytes, so no live frame ever arrives. That is a transport-layer limit of
|
|
16
|
+
* the tunnel, not something the host can fix. This client therefore
|
|
17
|
+
* degrades gracefully: once the SSE channel has silently stalled (no frame
|
|
18
|
+
* for {@link MuxClientOptions.stallThresholdMs}, or the EventSource reports
|
|
19
|
+
* an error), it starts polling the open session's history over plain HTTP
|
|
20
|
+
* (the `/m/api/session.history` RPC — unaffected by the SSE limitation),
|
|
21
|
+
* and re-emits freshly appended events as `session/event` frames through
|
|
22
|
+
* the same subscriber contract, so listeners (and the message fold) behave
|
|
23
|
+
* exactly as if the frames had arrived over SSE. When the SSE channel
|
|
24
|
+
* delivers again, fallback polling stops and the live stream takes over.
|
|
11
25
|
*/
|
|
12
26
|
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events';
|
|
27
|
+
import { type HistoryPage } from './api.ts';
|
|
13
28
|
/** Injectable seams for tests. */
|
|
14
29
|
export interface MuxClientOptions {
|
|
15
30
|
/** EventSource factory (defaults to the browser EventSource). */
|
|
16
31
|
sourceFactory?: (url: string) => EventSourceLike;
|
|
32
|
+
/**
|
|
33
|
+
* Fetch one history page (tail) for a session — the polling fallback's
|
|
34
|
+
* data source. Defaults to the mobile `session.history` RPC, which rides
|
|
35
|
+
* the ordinary HTTP channel (unaffected by SSE-impairing tunnels).
|
|
36
|
+
*/
|
|
37
|
+
pollLatest?: (sessionId: string) => Promise<HistoryPage>;
|
|
38
|
+
/** Poll cadence while SSE is stalled (default 3000 ms). */
|
|
39
|
+
pollIntervalMs?: number;
|
|
40
|
+
/** How long SSE must go without a frame before fallback kicks in (default 12000 ms). */
|
|
41
|
+
stallThresholdMs?: number;
|
|
42
|
+
/** Clock seam for tests (defaults to Date.now). */
|
|
43
|
+
now?: () => number;
|
|
17
44
|
}
|
|
18
45
|
/** The EventSource subset this client uses (browser EventSource fits). */
|
|
19
46
|
export interface EventSourceLike {
|
|
@@ -26,14 +53,34 @@ export interface EventSourceLike {
|
|
|
26
53
|
/**
|
|
27
54
|
* Keep one SSE subscription open, fanning validated frames out to
|
|
28
55
|
* subscribers. EventSource owns reconnection (with its own backoff); this
|
|
29
|
-
* class only manages the subscription lifecycle
|
|
56
|
+
* class only manages the subscription lifecycle, plus a polling fallback
|
|
57
|
+
* that keeps the open session live when the SSE channel cannot deliver.
|
|
30
58
|
*/
|
|
31
59
|
export declare class MuxClient {
|
|
32
60
|
private readonly sourceFactory;
|
|
61
|
+
private readonly pollLatest;
|
|
62
|
+
private readonly pollIntervalMs;
|
|
63
|
+
private readonly stallThresholdMs;
|
|
64
|
+
private readonly now;
|
|
33
65
|
private readonly listeners;
|
|
34
66
|
private source;
|
|
35
67
|
private stopped;
|
|
36
68
|
private readonly url;
|
|
69
|
+
/** The session to keep live via fallback polling (undefined = none). */
|
|
70
|
+
private observeSessionId;
|
|
71
|
+
/** Last epoch ms the SSE channel produced a frame (or the stream opened). */
|
|
72
|
+
private lastDataAt;
|
|
73
|
+
/**
|
|
74
|
+
* Whether the SSE channel has ever delivered a frame in this stream (a
|
|
75
|
+
* delivered frame proves the tunnel forwards SSE; silence alone then means
|
|
76
|
+
* the agent idle, not a dead channel — only an onerror re-arms fallback).
|
|
77
|
+
*/
|
|
78
|
+
private sseAlive;
|
|
79
|
+
/** Per-session highest event seq already emitted, for poll dedup. */
|
|
80
|
+
private readonly pollWatermark;
|
|
81
|
+
private stallTimer;
|
|
82
|
+
private pollTimer;
|
|
83
|
+
private polling;
|
|
37
84
|
/**
|
|
38
85
|
* @param url - the mobile events endpoint (browser-relative).
|
|
39
86
|
* @param options - seams.
|
|
@@ -45,8 +92,25 @@ export declare class MuxClient {
|
|
|
45
92
|
stop(): void;
|
|
46
93
|
/** Subscribe to validated frames; returns an unsubscribe function. */
|
|
47
94
|
onFrame(listener: (frame: MuxFrame) => void): () => void;
|
|
95
|
+
/**
|
|
96
|
+
* Point the fallback at one open session (or `undefined` to stop it).
|
|
97
|
+
* While the SSE channel is stalled this client polls that session's
|
|
98
|
+
* history and re-emits new events as `session/event` frames.
|
|
99
|
+
*/
|
|
100
|
+
observe(sessionId: string | undefined): void;
|
|
48
101
|
private connect;
|
|
102
|
+
private startStallChecker;
|
|
103
|
+
private stopStallChecker;
|
|
104
|
+
private startPolling;
|
|
105
|
+
private stopPolling;
|
|
106
|
+
/**
|
|
107
|
+
* Fetch the latest history page for the observed session and re-emit any
|
|
108
|
+
* event above the per-session watermark as a `session/event` frame.
|
|
109
|
+
* Idempotent by seq: listeners (and the fold) never see a duplicate.
|
|
110
|
+
*/
|
|
111
|
+
private pollTick;
|
|
49
112
|
private handleMessage;
|
|
113
|
+
private emit;
|
|
50
114
|
private closeSource;
|
|
51
115
|
}
|
|
52
116
|
//# sourceMappingURL=mux.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mux.d.ts","sourceRoot":"","sources":["../../../src/mobile/mux.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"mux.d.ts","sourceRoot":"","sources":["../../../src/mobile/mux.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2CAA2C,CAAA;AAGzE,OAAO,EAA2B,KAAK,WAAW,EAAE,MAAM,UAAU,CAAA;AAEpE,kCAAkC;AAClC,MAAM,WAAW,gBAAgB;IAC/B,iEAAiE;IACjE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,eAAe,CAAA;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAA;IACxD,2DAA2D;IAC3D,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,wFAAwF;IACxF,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,mDAAmD;IACnD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,0EAA0E;AAC1E,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IACrD,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAA;IAC1C,KAAK,IAAI,IAAI,CAAA;CACd;AAiBD;;;;;GAKG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAkC;IAChE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6C;IACxE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuC;IACjE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAE5B,wEAAwE;IACxE,OAAO,CAAC,gBAAgB,CAAoB;IAC5C,6EAA6E;IAC7E,OAAO,CAAC,UAAU,CAAI;IACtB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAQ;IACxB,qEAAqE;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4B;IAC1D,OAAO,CAAC,UAAU,CAA4C;IAC9D,OAAO,CAAC,SAAS,CAA4C;IAC7D,OAAO,CAAC,OAAO,CAAQ;IAEvB;;;OAGG;gBACS,GAAG,SAAsB,EAAE,OAAO,GAAE,gBAAqB;IASrE,+EAA+E;IAC/E,KAAK,IAAI,IAAI;IAOb,sBAAsB;IACtB,IAAI,IAAI,IAAI;IAQZ,sEAAsE;IACtE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAY5C,OAAO,CAAC,OAAO;IAqBf,OAAO,CAAC,iBAAiB;IAczB,OAAO,CAAC,gBAAgB;IAOxB,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,WAAW;IAQnB;;;;OAIG;YACW,QAAQ;IAsBtB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,IAAI;IAUZ,OAAO,CAAC,WAAW;CAapB"}
|
package/lib/types/mobile/mux.js
CHANGED
|
@@ -7,27 +7,66 @@
|
|
|
7
7
|
* opens — no subscription handshake is needed. Frames arrive as
|
|
8
8
|
* server-request envelopes whose payload is the mux frame; unknown frame
|
|
9
9
|
* types are dropped so a newer host never breaks this client.
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* EventSource reconnects automatically — but only over a tunnel that
|
|
12
|
+
* actually forwards frames. Public quick tunnels (Cloudflare quick tunnel /
|
|
13
|
+
* Tailscale Serve) do not transparently pass Server-Sent Events: ordinary
|
|
14
|
+
* HTTP works, yet the SSE connection stays open or reconnects with zero
|
|
15
|
+
* bytes, so no live frame ever arrives. That is a transport-layer limit of
|
|
16
|
+
* the tunnel, not something the host can fix. This client therefore
|
|
17
|
+
* degrades gracefully: once the SSE channel has silently stalled (no frame
|
|
18
|
+
* for {@link MuxClientOptions.stallThresholdMs}, or the EventSource reports
|
|
19
|
+
* an error), it starts polling the open session's history over plain HTTP
|
|
20
|
+
* (the `/m/api/session.history` RPC — unaffected by the SSE limitation),
|
|
21
|
+
* and re-emits freshly appended events as `session/event` frames through
|
|
22
|
+
* the same subscriber contract, so listeners (and the message fold) behave
|
|
23
|
+
* exactly as if the frames had arrived over SSE. When the SSE channel
|
|
24
|
+
* delivers again, fallback polling stops and the live stream takes over.
|
|
11
25
|
*/
|
|
12
26
|
import { muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema';
|
|
13
27
|
import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema';
|
|
28
|
+
import { history as fetchHistory } from "./api.js";
|
|
14
29
|
/** Browser default source factory. */
|
|
15
30
|
function browserSource(url) {
|
|
16
31
|
// The DOM EventSource is structurally compatible; the `this`-typed handler
|
|
17
32
|
// signatures differ, so the narrow face takes it through an adapter cast.
|
|
18
33
|
return new EventSource(url);
|
|
19
34
|
}
|
|
35
|
+
const DEFAULT_POLL_INTERVAL_MS = 3000;
|
|
36
|
+
const DEFAULT_STALL_THRESHOLD_MS = 12000;
|
|
37
|
+
/** Poll window: enough recent events to cover a few seconds of agent output. */
|
|
38
|
+
const DEFAULT_POLL_PAGE_SIZE = 50;
|
|
20
39
|
/**
|
|
21
40
|
* Keep one SSE subscription open, fanning validated frames out to
|
|
22
41
|
* subscribers. EventSource owns reconnection (with its own backoff); this
|
|
23
|
-
* class only manages the subscription lifecycle
|
|
42
|
+
* class only manages the subscription lifecycle, plus a polling fallback
|
|
43
|
+
* that keeps the open session live when the SSE channel cannot deliver.
|
|
24
44
|
*/
|
|
25
45
|
export class MuxClient {
|
|
26
46
|
sourceFactory;
|
|
47
|
+
pollLatest;
|
|
48
|
+
pollIntervalMs;
|
|
49
|
+
stallThresholdMs;
|
|
50
|
+
now;
|
|
27
51
|
listeners = new Set();
|
|
28
52
|
source;
|
|
29
53
|
stopped = false;
|
|
30
54
|
url;
|
|
55
|
+
/** The session to keep live via fallback polling (undefined = none). */
|
|
56
|
+
observeSessionId;
|
|
57
|
+
/** Last epoch ms the SSE channel produced a frame (or the stream opened). */
|
|
58
|
+
lastDataAt = 0;
|
|
59
|
+
/**
|
|
60
|
+
* Whether the SSE channel has ever delivered a frame in this stream (a
|
|
61
|
+
* delivered frame proves the tunnel forwards SSE; silence alone then means
|
|
62
|
+
* the agent idle, not a dead channel — only an onerror re-arms fallback).
|
|
63
|
+
*/
|
|
64
|
+
sseAlive = false;
|
|
65
|
+
/** Per-session highest event seq already emitted, for poll dedup. */
|
|
66
|
+
pollWatermark = new Map();
|
|
67
|
+
stallTimer;
|
|
68
|
+
pollTimer;
|
|
69
|
+
polling = false;
|
|
31
70
|
/**
|
|
32
71
|
* @param url - the mobile events endpoint (browser-relative).
|
|
33
72
|
* @param options - seams.
|
|
@@ -35,25 +74,51 @@ export class MuxClient {
|
|
|
35
74
|
constructor(url = '/m/api/events.mux', options = {}) {
|
|
36
75
|
this.url = url;
|
|
37
76
|
this.sourceFactory = options.sourceFactory ?? browserSource;
|
|
77
|
+
this.pollLatest = options.pollLatest ?? ((sessionId) => fetchHistory(sessionId, undefined, DEFAULT_POLL_PAGE_SIZE));
|
|
78
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
79
|
+
this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
|
|
80
|
+
this.now = options.now ?? (() => Date.now());
|
|
38
81
|
}
|
|
39
82
|
/** Open the stream (idempotent; EventSource reconnects until {@link stop}). */
|
|
40
83
|
start() {
|
|
41
84
|
this.stopped = false;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
85
|
+
this.lastDataAt = this.now();
|
|
86
|
+
if (this.source === undefined)
|
|
87
|
+
this.connect();
|
|
88
|
+
this.startStallChecker();
|
|
45
89
|
}
|
|
46
90
|
/** Close for good. */
|
|
47
91
|
stop() {
|
|
48
92
|
this.stopped = true;
|
|
93
|
+
this.stopStallChecker();
|
|
94
|
+
this.stopPolling();
|
|
49
95
|
this.closeSource();
|
|
96
|
+
this.observeSessionId = undefined;
|
|
50
97
|
}
|
|
51
98
|
/** Subscribe to validated frames; returns an unsubscribe function. */
|
|
52
99
|
onFrame(listener) {
|
|
53
100
|
this.listeners.add(listener);
|
|
54
101
|
return () => { this.listeners.delete(listener); };
|
|
55
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Point the fallback at one open session (or `undefined` to stop it).
|
|
105
|
+
* While the SSE channel is stalled this client polls that session's
|
|
106
|
+
* history and re-emits new events as `session/event` frames.
|
|
107
|
+
*/
|
|
108
|
+
observe(sessionId) {
|
|
109
|
+
this.observeSessionId = sessionId;
|
|
110
|
+
if (sessionId === undefined) {
|
|
111
|
+
this.stopPolling();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// If SSE is already stalled for this session, start patching right away.
|
|
115
|
+
if (!this.polling && !this.stopped && !this.sseAlive && (this.now() - this.lastDataAt) > this.stallThresholdMs) {
|
|
116
|
+
this.startPolling();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
56
119
|
connect() {
|
|
120
|
+
// A fresh stream starts unknown; only a delivered frame proves it works.
|
|
121
|
+
this.sseAlive = false;
|
|
57
122
|
const source = this.sourceFactory(this.url);
|
|
58
123
|
this.source = source;
|
|
59
124
|
source.onmessage = (event) => {
|
|
@@ -61,11 +126,83 @@ export class MuxClient {
|
|
|
61
126
|
};
|
|
62
127
|
source.onerror = () => {
|
|
63
128
|
// EventSource reconnects by itself; when we are closing, detach first
|
|
64
|
-
// so the native reconnect cannot outlive stop().
|
|
65
|
-
|
|
129
|
+
// so the native reconnect cannot outlive stop(). Otherwise an error is
|
|
130
|
+
// a strong signal the transport is not delivering — degrade to polling.
|
|
131
|
+
if (this.stopped && this.source === source) {
|
|
66
132
|
this.closeSource();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.sseAlive = false;
|
|
136
|
+
if (this.observeSessionId !== undefined)
|
|
137
|
+
this.startPolling();
|
|
67
138
|
};
|
|
68
139
|
}
|
|
140
|
+
startStallChecker() {
|
|
141
|
+
this.stopStallChecker();
|
|
142
|
+
this.stallTimer = setInterval(() => {
|
|
143
|
+
if (this.stopped)
|
|
144
|
+
return;
|
|
145
|
+
if (this.observeSessionId === undefined)
|
|
146
|
+
return;
|
|
147
|
+
if (this.polling)
|
|
148
|
+
return;
|
|
149
|
+
// A live SSE channel only goes silent while the agent idles; never
|
|
150
|
+
// poll against it. Fallback arms again only via onerror or a stream
|
|
151
|
+
// that has never delivered.
|
|
152
|
+
if (this.sseAlive)
|
|
153
|
+
return;
|
|
154
|
+
if ((this.now() - this.lastDataAt) > this.stallThresholdMs)
|
|
155
|
+
this.startPolling();
|
|
156
|
+
}, 1000);
|
|
157
|
+
}
|
|
158
|
+
stopStallChecker() {
|
|
159
|
+
if (this.stallTimer !== undefined) {
|
|
160
|
+
clearInterval(this.stallTimer);
|
|
161
|
+
this.stallTimer = undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
startPolling() {
|
|
165
|
+
if (this.polling || this.stopped)
|
|
166
|
+
return;
|
|
167
|
+
this.polling = true;
|
|
168
|
+
void this.pollTick();
|
|
169
|
+
this.pollTimer = setInterval(() => { void this.pollTick(); }, this.pollIntervalMs);
|
|
170
|
+
}
|
|
171
|
+
stopPolling() {
|
|
172
|
+
this.polling = false;
|
|
173
|
+
if (this.pollTimer !== undefined) {
|
|
174
|
+
clearInterval(this.pollTimer);
|
|
175
|
+
this.pollTimer = undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Fetch the latest history page for the observed session and re-emit any
|
|
180
|
+
* event above the per-session watermark as a `session/event` frame.
|
|
181
|
+
* Idempotent by seq: listeners (and the fold) never see a duplicate.
|
|
182
|
+
*/
|
|
183
|
+
async pollTick() {
|
|
184
|
+
const sessionId = this.observeSessionId;
|
|
185
|
+
if (sessionId === undefined) {
|
|
186
|
+
this.stopPolling();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const page = await this.pollLatest(sessionId);
|
|
191
|
+
let maxSeq = this.pollWatermark.get(sessionId) ?? -1;
|
|
192
|
+
for (const entry of page.events) {
|
|
193
|
+
const event = entry.event;
|
|
194
|
+
const seq = typeof event?.seq === 'number' ? event.seq : -1;
|
|
195
|
+
if (seq <= maxSeq)
|
|
196
|
+
continue;
|
|
197
|
+
maxSeq = seq;
|
|
198
|
+
this.emit({ type: 'session/event', sessionId: sessionId, event });
|
|
199
|
+
}
|
|
200
|
+
this.pollWatermark.set(sessionId, maxSeq);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Transient (network, pairing, history paging); the next tick retries.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
69
206
|
handleMessage(data) {
|
|
70
207
|
if (typeof data !== 'string' || data === '')
|
|
71
208
|
return;
|
|
@@ -84,9 +221,19 @@ export class MuxClient {
|
|
|
84
221
|
const frame = muxFrameSchema.safeParse(envelope.data.payload);
|
|
85
222
|
if (!frame.success)
|
|
86
223
|
return;
|
|
224
|
+
// A delivered frame proves the SSE channel is live (the tunnel forwards
|
|
225
|
+
// it) and delivers again — drop any fallback polling so the live stream
|
|
226
|
+
// takes over without double delivery.
|
|
227
|
+
this.sseAlive = true;
|
|
228
|
+
this.lastDataAt = this.now();
|
|
229
|
+
if (this.polling)
|
|
230
|
+
this.stopPolling();
|
|
231
|
+
this.emit(frame.data);
|
|
232
|
+
}
|
|
233
|
+
emit(frame) {
|
|
87
234
|
for (const listener of this.listeners) {
|
|
88
235
|
try {
|
|
89
|
-
listener(frame
|
|
236
|
+
listener(frame);
|
|
90
237
|
}
|
|
91
238
|
catch {
|
|
92
239
|
// A throwing subscriber must not break the emit loop.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mux.test.d.ts","sourceRoot":"","sources":["../../../src/mobile/mux.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** mux: live-event client, SSE delivery + stall-driven polling fallback. */
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { MuxClient } from "./mux.js";
|
|
4
|
+
/** Create an EventSource factory that records every opened source. */
|
|
5
|
+
function makeSources() {
|
|
6
|
+
const sources = [];
|
|
7
|
+
const factory = (url) => {
|
|
8
|
+
const source = {
|
|
9
|
+
url,
|
|
10
|
+
onmessage: null,
|
|
11
|
+
onerror: null,
|
|
12
|
+
closed: false,
|
|
13
|
+
close: () => { source.closed = true; },
|
|
14
|
+
};
|
|
15
|
+
sources.push(source);
|
|
16
|
+
return source;
|
|
17
|
+
};
|
|
18
|
+
return { factory, sources };
|
|
19
|
+
}
|
|
20
|
+
/** One history page whose events carry sequential ids. */
|
|
21
|
+
function pageOf(seqs) {
|
|
22
|
+
return {
|
|
23
|
+
hasMore: false,
|
|
24
|
+
events: seqs.map(seq => ({
|
|
25
|
+
event: { type: 'user/message', seq, time: seq * 1_000, data: { text: String(seq) } },
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** A server-request envelope carrying one mux frame (the SSE wire shape). */
|
|
30
|
+
function envelopeWith(payload) {
|
|
31
|
+
return JSON.stringify({ type: 'server-request', rpcId: 'r1', method: 'events.mux', payload });
|
|
32
|
+
}
|
|
33
|
+
/** Options common to every test: tight clocks, injected data source. */
|
|
34
|
+
function baseOptions(pollLatest, factory) {
|
|
35
|
+
return { sourceFactory: factory, pollLatest, stallThresholdMs: 800, pollIntervalMs: 400 };
|
|
36
|
+
}
|
|
37
|
+
describe('MuxClient polling fallback', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
});
|
|
41
|
+
it('does not poll while the SSE channel is fresh', async () => {
|
|
42
|
+
const { factory } = makeSources();
|
|
43
|
+
const pollLatest = vi.fn(async (_sessionId) => pageOf([0]));
|
|
44
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
|
|
45
|
+
const frames = [];
|
|
46
|
+
client.onFrame(frame => { frames.push(frame); });
|
|
47
|
+
client.start();
|
|
48
|
+
client.observe('s1');
|
|
49
|
+
await vi.advanceTimersByTimeAsync(400); // well under the stall threshold
|
|
50
|
+
expect(pollLatest).not.toHaveBeenCalled();
|
|
51
|
+
expect(frames).toHaveLength(0);
|
|
52
|
+
client.stop();
|
|
53
|
+
});
|
|
54
|
+
it('starts polling after silence and emits appended events as session/event frames', async () => {
|
|
55
|
+
const { factory } = makeSources();
|
|
56
|
+
const pages = [pageOf([0]), pageOf([0, 1])];
|
|
57
|
+
const pollLatest = vi.fn(async (_sessionId) => pages.shift() ?? pageOf([]));
|
|
58
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
|
|
59
|
+
const frames = [];
|
|
60
|
+
client.onFrame(frame => { frames.push(frame); });
|
|
61
|
+
client.start();
|
|
62
|
+
client.observe('s1');
|
|
63
|
+
// Nothing live within a poll interval until the stall window passes.
|
|
64
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
65
|
+
expect(pollLatest).not.toHaveBeenCalled();
|
|
66
|
+
// Past the stall threshold the first poll runs and emits seq 0.
|
|
67
|
+
await vi.advanceTimersByTimeAsync(700); // 1100ms total -> first stall-checker tick
|
|
68
|
+
expect(pollLatest).toHaveBeenCalledWith('s1');
|
|
69
|
+
expect(frames).toHaveLength(1);
|
|
70
|
+
expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' });
|
|
71
|
+
expect(frames[0]?.event).toMatchObject({ seq: 0 });
|
|
72
|
+
// The next poll emits only the appended event (seq 1), not seq 0 again.
|
|
73
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
74
|
+
expect(frames).toHaveLength(2);
|
|
75
|
+
expect(frames[1]?.event).toMatchObject({ seq: 1 });
|
|
76
|
+
client.stop();
|
|
77
|
+
});
|
|
78
|
+
it('keeps the watermark so a repeated page never re-emits old events', async () => {
|
|
79
|
+
const { factory } = makeSources();
|
|
80
|
+
// Two calls return the same page: the second must emit nothing.
|
|
81
|
+
const pollLatest = vi.fn(async (_sessionId) => pageOf([0, 1, 2]));
|
|
82
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
|
|
83
|
+
const frames = [];
|
|
84
|
+
client.onFrame(frame => { frames.push(frame); });
|
|
85
|
+
client.start();
|
|
86
|
+
client.observe('s1');
|
|
87
|
+
await vi.advanceTimersByTimeAsync(1100); // first poll -> 3 events
|
|
88
|
+
expect(frames).toHaveLength(3);
|
|
89
|
+
await vi.advanceTimersByTimeAsync(400); // second poll -> same page, nothing new
|
|
90
|
+
expect(frames).toHaveLength(3);
|
|
91
|
+
client.stop();
|
|
92
|
+
});
|
|
93
|
+
it('stops polling when observe is cleared and keeps it stopped', async () => {
|
|
94
|
+
const { factory } = makeSources();
|
|
95
|
+
const pollLatest = vi.fn(async (_sessionId) => pageOf([0]));
|
|
96
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
|
|
97
|
+
client.start();
|
|
98
|
+
client.observe('s1');
|
|
99
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
100
|
+
expect(pollLatest).toHaveBeenCalled();
|
|
101
|
+
client.observe(undefined);
|
|
102
|
+
const callsAfterClear = pollLatest.mock.calls.length;
|
|
103
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
104
|
+
expect(pollLatest.mock.calls.length).toBe(callsAfterClear);
|
|
105
|
+
client.stop();
|
|
106
|
+
});
|
|
107
|
+
it('stops polling on stop(), closing any live source', async () => {
|
|
108
|
+
const { factory, sources } = makeSources();
|
|
109
|
+
const pollLatest = vi.fn(async (_sessionId) => pageOf([0]));
|
|
110
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
|
|
111
|
+
client.start();
|
|
112
|
+
client.observe('s1');
|
|
113
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
114
|
+
expect(pollLatest).toHaveBeenCalled();
|
|
115
|
+
client.stop();
|
|
116
|
+
const callsAfterStop = pollLatest.mock.calls.length;
|
|
117
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
118
|
+
expect(pollLatest.mock.calls.length).toBe(callsAfterStop);
|
|
119
|
+
expect(sources[0]?.closed).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it('returns to SSE when a frame arrives, dropping the fallback poller', async () => {
|
|
122
|
+
const { factory, sources } = makeSources();
|
|
123
|
+
const pollLatest = vi.fn(async (_sessionId) => pageOf([0]));
|
|
124
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory));
|
|
125
|
+
const frames = [];
|
|
126
|
+
client.onFrame(frame => { frames.push(frame); });
|
|
127
|
+
client.start();
|
|
128
|
+
client.observe('s1');
|
|
129
|
+
// Stall into polling.
|
|
130
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
131
|
+
expect(pollLatest).toHaveBeenCalledTimes(1);
|
|
132
|
+
// A live mux frame proves SSE delivers again -> fallback stops.
|
|
133
|
+
sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) });
|
|
134
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
135
|
+
expect(pollLatest.mock.calls.length).toBe(1); // polling stopped after the live frame
|
|
136
|
+
const live = frames.filter(frame => frame?.type === 'session/subscribed');
|
|
137
|
+
expect(live).toHaveLength(1);
|
|
138
|
+
expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' });
|
|
139
|
+
client.stop();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/App.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAA2B,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAazF,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;CACf;AAED,wDAAwD;AACxD,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CACnD,GAAG,WAAW,CAad;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CASlD;AAED;;;GAGG;AACH,wBAAgB,GAAG,
|
|
1
|
+
{"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/App.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAA2B,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAazF,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;CACf;AAED,wDAAwD;AACxD,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CACnD,GAAG,WAAW,CAad;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CASlD;AAED;;;GAGG;AACH,wBAAgB,GAAG,gCA+ClB;AAED,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAKhD;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAIjE;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,4CAEhE;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,CAAA"}
|
|
@@ -55,6 +55,12 @@ export function App() {
|
|
|
55
55
|
mux.start();
|
|
56
56
|
return () => { mux.stop(); };
|
|
57
57
|
}, []);
|
|
58
|
+
// Keep the live-event client pointed at the session currently on screen so
|
|
59
|
+
// its polling fallback can keep that chat fresh over SSE-impairing tunnels
|
|
60
|
+
// (quick tunnel / Tailscale Serve do not forward Server-Sent Events).
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
muxRef.current?.observe(route.kind === 'chat' ? route.session.sessionId : undefined);
|
|
63
|
+
}, [route]);
|
|
58
64
|
const back = useCallback(() => {
|
|
59
65
|
setRoute(previous => {
|
|
60
66
|
if (previous.kind === 'chat')
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAIjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAYD,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+
|
|
1
|
+
{"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAIjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAYD,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+BA2N/D"}
|
|
@@ -100,7 +100,6 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
100
100
|
// plugin (augmentation), so the base SDK map is indexed loosely.
|
|
101
101
|
const projections = page.projections?.values;
|
|
102
102
|
setPermissions(parsePermissionSelect(projections?.['permissions']));
|
|
103
|
-
scrollToBottom();
|
|
104
103
|
}, (reason) => {
|
|
105
104
|
if (cancelled)
|
|
106
105
|
return;
|
|
@@ -134,17 +133,32 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
134
133
|
}
|
|
135
134
|
});
|
|
136
135
|
}, [mux, session.sessionId]);
|
|
137
|
-
// Keep the newest content visible while streaming.
|
|
138
|
-
useEffect(() => {
|
|
139
|
-
if (messages.some(message => message.pending === true))
|
|
140
|
-
scrollToBottom();
|
|
141
|
-
}, [messages]);
|
|
142
136
|
const scrollToBottom = useCallback(() => {
|
|
143
137
|
const el = scrollRef.current;
|
|
144
138
|
if (el === undefined)
|
|
145
139
|
return;
|
|
146
140
|
el.scrollTop = el.scrollHeight;
|
|
147
141
|
}, []);
|
|
142
|
+
// Track the last message's fold key so scrolling only fires when the
|
|
143
|
+
// newest message actually changes (seq bump and/or pending flip). Runs
|
|
144
|
+
// after React has committed the render, so scrollHeight reflects the
|
|
145
|
+
// freshly appended content.
|
|
146
|
+
const lastMessageKeyRef = useRef(undefined);
|
|
147
|
+
// Keep the newest content visible. This covers the initial tail page (the
|
|
148
|
+
// effect runs after commit, fixing the stale scrollHeight from the old
|
|
149
|
+
// open-time scrollToBottom), live streaming chunks on the pending message,
|
|
150
|
+
// and finalized/appended messages. Prepending older pages via loadOlder
|
|
151
|
+
// leaves the last message untouched, so it never disturbs the scroll position.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const last = messages[messages.length - 1];
|
|
154
|
+
if (last === undefined)
|
|
155
|
+
return;
|
|
156
|
+
const key = last.seq + ':' + (last.pending === true ? 'p' : 'f');
|
|
157
|
+
if (key === lastMessageKeyRef.current)
|
|
158
|
+
return;
|
|
159
|
+
lastMessageKeyRef.current = key;
|
|
160
|
+
scrollToBottom();
|
|
161
|
+
}, [messages, scrollToBottom]);
|
|
148
162
|
/** Load one older page and prepend it. The fold is directional (incremental
|
|
149
163
|
* tails only), so the older page folds standalone and concatenates ahead —
|
|
150
164
|
* host page boundaries never cut a message, so the seam is exact. */
|