@linxin666/dsh-remote-web-ui 0.2.7 → 0.2.9
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/LICENSE +202 -28
- package/README.i18n.yaml +2 -2
- package/README.md +14 -7
- package/README.zh.md +3 -2
- package/lib/client.js +40 -8
- package/lib/client.js.map +1 -1
- package/lib/index.js +34 -24
- package/lib/mobile.js +39 -21
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/RemoteEntry.js +3 -3
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +28 -14
- package/lib/types/client/pair-api.d.ts +6 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +11 -0
- package/lib/types/client/remote-channel.d.ts +10 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +11 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +16 -5
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +5 -3
- package/lib/types/mobile/messages.test.js +20 -0
- package/lib/types/mobile/mux.d.ts +7 -4
- package/lib/types/mobile/mux.d.ts.map +1 -1
- package/lib/types/mobile/mux.js +43 -15
- package/lib/types/mobile/mux.test.js +57 -0
- package/lib/types/remote-api.d.ts +13 -4
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +27 -17
- package/lib/types/routes.d.ts +2 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +5 -2
- package/package.json +15 -12
- package/src/client/RemoteEntry.tsx +3 -2
- package/src/client/UpdateEntry.tsx +1 -0
- package/src/client/index.ts +30 -13
- package/src/client/pair-api.ts +16 -0
- package/src/client/remote-channel.ts +21 -0
- package/src/index.ts +16 -5
- package/src/mobile/messages.test.ts +23 -0
- package/src/mobile/messages.ts +5 -3
- package/src/mobile/mux.test.ts +64 -0
- package/src/mobile/mux.ts +44 -17
- package/src/remote-api.ts +34 -17
- package/src/routes.ts +7 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAIH,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAclD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,GAC5B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAA;CACjD;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAsBxE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA2CnE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAWzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CA2BjF"}
|
package/lib/types/remote-api.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* and no per-plugin pairing consult.
|
|
8
8
|
*
|
|
9
9
|
* Security model:
|
|
10
|
-
* -
|
|
11
|
-
* any bytes are forwarded and
|
|
10
|
+
* - While `requirePairingForLan` is on (default), every request must carry a
|
|
11
|
+
* live paired-device cookie, enforced before any bytes are forwarded and
|
|
12
|
+
* before any host call. With the policy off, the cookie gate is skipped
|
|
13
|
+
* (the loopback-only denials below still apply).
|
|
12
14
|
* - The SDK's loopback-only privileged methods (native dialogs, the settings
|
|
13
15
|
* plane, credentials — the `PRIVILEGED_METHODS` set of client-connection)
|
|
14
16
|
* are denied here. The set is pinned by tests/remote-contract.spec.ts.
|
|
@@ -95,19 +97,24 @@ export function loopbackOnlyDenial(innerPath) {
|
|
|
95
97
|
}
|
|
96
98
|
/**
|
|
97
99
|
* Build the remote desktop channel HTTP routes.
|
|
98
|
-
* @param deps - pairing service + local port.
|
|
100
|
+
* @param deps - pairing service + local port + live pairing policy.
|
|
99
101
|
* @returns the routes to register on webServer.
|
|
100
102
|
*/
|
|
101
103
|
export function makeRemoteApiRoutes(deps) {
|
|
102
|
-
const { service, port } = deps;
|
|
104
|
+
const { service, port, requirePairingForLan = true } = deps;
|
|
103
105
|
const handler = (req, res) => {
|
|
104
106
|
// Cookie gate first — same order as /m/api. Do not buffer an unpaired body.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
// With the live policy off, untrusted-but-policy-open callers are proxied
|
|
108
|
+
// (a stale client rewrite must not 403); loopback-only denials stay below.
|
|
109
|
+
const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan;
|
|
110
|
+
if (require) {
|
|
111
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
112
|
+
const paired = deviceId !== undefined && service.touchDevice(deviceId);
|
|
113
|
+
if (!paired) {
|
|
114
|
+
req.resume();
|
|
115
|
+
envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
111
118
|
}
|
|
112
119
|
const method = req.method ?? 'GET';
|
|
113
120
|
if (!ALLOWED_METHODS.has(method)) {
|
|
@@ -153,17 +160,20 @@ export function upgradeInnerPath(reqUrl, fallbackPath) {
|
|
|
153
160
|
/**
|
|
154
161
|
* Build the WebSocket upgrade routes for the event streams and known plugin
|
|
155
162
|
* sockets. webServer matches upgrades by exact path.
|
|
156
|
-
* @param deps - pairing service + local port.
|
|
163
|
+
* @param deps - pairing service + local port + live pairing policy.
|
|
157
164
|
* @returns the upgrade routes to register on webServer.
|
|
158
165
|
*/
|
|
159
166
|
export function makeRemoteApiUpgradeRoutes(deps) {
|
|
160
|
-
const { service, port } = deps;
|
|
167
|
+
const { service, port, requirePairingForLan = true } = deps;
|
|
161
168
|
const handlerFor = (fallbackPath) => (req, socket, head) => {
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
169
|
+
const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan;
|
|
170
|
+
if (require) {
|
|
171
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
172
|
+
if (deviceId === undefined || !service.touchDevice(deviceId)) {
|
|
173
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
174
|
+
socket.destroy();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
167
177
|
}
|
|
168
178
|
const inner = upgradeInnerPath(req.url, fallbackPath);
|
|
169
179
|
const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner);
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -84,6 +84,8 @@ export interface PairRoutesDeps {
|
|
|
84
84
|
service: PairingService;
|
|
85
85
|
/** The LAN IP literals the fence accepts (derived from the bind host). */
|
|
86
86
|
lanAddresses: readonly string[];
|
|
87
|
+
/** Current desktop gate policy, re-read for every status response. */
|
|
88
|
+
requirePairingForLan?: boolean | (() => boolean);
|
|
87
89
|
}
|
|
88
90
|
/**
|
|
89
91
|
* Build the /api/pair route family.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;;CAQb,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;gBACS,OAAO,EAAE,cAAc;IAInC,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;IAiBrD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAYrC,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;;CAQb,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;gBACS,OAAO,EAAE,cAAc;IAInC,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;IAiBrD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAYrC,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,sEAAsE;IACtE,oBAAoB,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAA;CACjD;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAoO3D"}
|
package/lib/types/routes.js
CHANGED
|
@@ -183,7 +183,10 @@ export class PairingEventsStream {
|
|
|
183
183
|
* @returns the exact routes to register on webServer.
|
|
184
184
|
*/
|
|
185
185
|
export function makeRoutes(deps) {
|
|
186
|
-
const { service, lanAddresses } = deps;
|
|
186
|
+
const { service, lanAddresses, requirePairingForLan = true } = deps;
|
|
187
|
+
const pairingRequired = () => typeof requirePairingForLan === 'function'
|
|
188
|
+
? requirePairingForLan()
|
|
189
|
+
: requirePairingForLan;
|
|
187
190
|
const events = new PairingEventsStream(service);
|
|
188
191
|
/** Loopback-only fence: the desktop panel's control endpoints. */
|
|
189
192
|
const loopbackFence = (req) => isTrustedApiRequest(req, []);
|
|
@@ -385,7 +388,7 @@ export function makeRoutes(deps) {
|
|
|
385
388
|
const visible = paired
|
|
386
389
|
? rest
|
|
387
390
|
: { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses };
|
|
388
|
-
writeJson(res, 200, { ok: true, paired, ...visible });
|
|
391
|
+
writeJson(res, 200, { ok: true, paired, requirePairingForLan: pairingRequired(), ...visible });
|
|
389
392
|
};
|
|
390
393
|
const handleEvents = (req, res) => {
|
|
391
394
|
if (!requireMethod(req, res, 'GET'))
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
],
|
|
36
36
|
"license": "Apache-2.0",
|
|
37
37
|
"dsh": {
|
|
38
|
+
"engines": {
|
|
39
|
+
"dsh": ">=0.1.1-rc.1"
|
|
40
|
+
},
|
|
38
41
|
"bundle": {
|
|
39
42
|
"patch": "./cordis.patch.yml"
|
|
40
43
|
},
|
|
@@ -61,17 +64,17 @@
|
|
|
61
64
|
},
|
|
62
65
|
"devDependencies": {
|
|
63
66
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
64
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.
|
|
65
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.
|
|
66
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.
|
|
68
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.
|
|
69
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.1-rc.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.
|
|
71
|
-
"@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.
|
|
72
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.
|
|
73
|
-
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.
|
|
74
|
-
"@deepseek-ai/dsh-settings": "^0.1.1-rc.
|
|
67
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.1-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
75
78
|
"@testing-library/dom": "^10.4.1",
|
|
76
79
|
"@testing-library/react": "^16.3.2",
|
|
77
80
|
"@types/node": "^22.20.0",
|
|
@@ -219,7 +219,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
|
|
|
219
219
|
<>
|
|
220
220
|
<div className={css.entryRow} data-rail={wide ? undefined : 'rail'}>
|
|
221
221
|
<UpdateEntry wide={wide} t={t} />
|
|
222
|
-
<TooltipAnchor wide={wide} label={t('entry.label')} onClick={openPanel} />
|
|
222
|
+
<TooltipAnchor wide={wide} label={t('entry.label')} onClick={openPanel} expanded={open} />
|
|
223
223
|
</div>
|
|
224
224
|
{open && createPortal((
|
|
225
225
|
<div className={css.overlay} role="presentation">
|
|
@@ -243,13 +243,14 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
/** The trigger: an icon-only control with a persistent accessible label. */
|
|
246
|
-
function TooltipAnchor({ wide, label, onClick }: { wide: boolean; label: string; onClick: () => void }) {
|
|
246
|
+
function TooltipAnchor({ wide, label, onClick, expanded }: { wide: boolean; label: string; onClick: () => void; expanded: boolean }) {
|
|
247
247
|
return (
|
|
248
248
|
<button
|
|
249
249
|
type="button"
|
|
250
250
|
className={css.trigger}
|
|
251
251
|
data-wide={wide ? 'wide' : 'rail'}
|
|
252
252
|
aria-label={label}
|
|
253
|
+
aria-expanded={expanded}
|
|
253
254
|
title={label}
|
|
254
255
|
onClick={onClick}
|
|
255
256
|
>
|
|
@@ -124,6 +124,7 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
|
124
124
|
data-wide={wide ? undefined : "rail"}
|
|
125
125
|
data-update-available={updateAvailable ? "true" : undefined}
|
|
126
126
|
aria-label={updateLabel}
|
|
127
|
+
aria-expanded={open}
|
|
127
128
|
title={updateLabel}
|
|
128
129
|
onClick={openPanel}
|
|
129
130
|
>
|
package/src/client/index.ts
CHANGED
|
@@ -23,8 +23,8 @@ import { PairFailedNotice } from './PairFailedNotice.tsx'
|
|
|
23
23
|
import { RemoteSettingsCard, RemoteSettingsCardController, type RemoteSettings } from './RemoteSettingsCard.tsx'
|
|
24
24
|
import { en, zh, type RemoteKey } from './locales.ts'
|
|
25
25
|
import { PAIR_FAILED_MARKER, runPairBootFlow } from './deep-link.ts'
|
|
26
|
-
import { sendHeartbeat } from './pair-api.ts'
|
|
27
|
-
import { channelTransition, installRemoteChannel, isLoopbackHostname } from './remote-channel.ts'
|
|
26
|
+
import { readPairGatePolicy, sendHeartbeat } from './pair-api.ts'
|
|
27
|
+
import { channelTransition, installRemoteChannel, isLoopbackHostname, remoteChannelRequired } from './remote-channel.ts'
|
|
28
28
|
import { FenceNotice } from './FenceNotice.tsx'
|
|
29
29
|
|
|
30
30
|
export type { RemoteEntryProps } from './RemoteEntry.tsx'
|
|
@@ -201,6 +201,8 @@ export function apply(ctx: ClientContext): void {
|
|
|
201
201
|
// rewritten onto this plugin's gated /remote/api prefix (remote-channel.ts)
|
|
202
202
|
// while the fence setting demands it. Loopback origins are untouched.
|
|
203
203
|
let disposeChannel: (() => void) | undefined
|
|
204
|
+
let hostPairingPolicy: boolean | undefined
|
|
205
|
+
let unpairedWhilePolicyPending = false
|
|
204
206
|
let fenceNotice: { unmount: () => void; node: HTMLElement } | undefined
|
|
205
207
|
const showFenceNotice = (): void => {
|
|
206
208
|
if (fenceNotice !== undefined) return
|
|
@@ -214,22 +216,23 @@ export function apply(ctx: ClientContext): void {
|
|
|
214
216
|
fenceNotice?.unmount()
|
|
215
217
|
fenceNotice = undefined
|
|
216
218
|
}
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// the channel off exactly where it is needed most.
|
|
224
|
-
const snapshot = settingsScope.getSnapshot()
|
|
225
|
-
const value = snapshot.status === 'ready' ? snapshot.value : undefined
|
|
226
|
-
return (value?.enabled ?? true) && (value?.requirePairingForLan ?? true)
|
|
219
|
+
const handleUnpaired = (): void => {
|
|
220
|
+
if (settingsScope.getSnapshot().status !== 'ready' && hostPairingPolicy === undefined) {
|
|
221
|
+
unpairedWhilePolicyPending = true
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
showFenceNotice()
|
|
227
225
|
}
|
|
226
|
+
const channelActive = (): boolean => remoteChannelRequired(
|
|
227
|
+
window.location.hostname,
|
|
228
|
+
settingsScope.getSnapshot(),
|
|
229
|
+
hostPairingPolicy,
|
|
230
|
+
)
|
|
228
231
|
const syncChannel = (): void => {
|
|
229
232
|
const transition = channelTransition(channelActive(), disposeChannel !== undefined)
|
|
230
233
|
if (transition === 'install') {
|
|
231
234
|
disposeChannel = ctx.effect(() => {
|
|
232
|
-
const restore = installRemoteChannel(window, { onUnpaired:
|
|
235
|
+
const restore = installRemoteChannel(window, { onUnpaired: handleUnpaired, onPaired: hideFenceNotice })
|
|
233
236
|
return restore
|
|
234
237
|
}, 'remote-web-ui: remote desktop channel')
|
|
235
238
|
} else if (transition === 'retire' && disposeChannel !== undefined) {
|
|
@@ -246,6 +249,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
246
249
|
}
|
|
247
250
|
settingsScope.subscribe(syncChannel)
|
|
248
251
|
syncChannel()
|
|
252
|
+
if (!isLoopbackHostname(window.location.hostname) && settingsScope.getSnapshot().status !== 'ready') {
|
|
253
|
+
void readPairGatePolicy().then((policy) => {
|
|
254
|
+
hostPairingPolicy = policy.requirePairingForLan
|
|
255
|
+
syncChannel()
|
|
256
|
+
if (hostPairingPolicy && unpairedWhilePolicyPending) showFenceNotice()
|
|
257
|
+
unpairedWhilePolicyPending = false
|
|
258
|
+
}).catch(() => {
|
|
259
|
+
// Fail closed when the policy endpoint is unavailable or malformed.
|
|
260
|
+
hostPairingPolicy = true
|
|
261
|
+
syncChannel()
|
|
262
|
+
if (unpairedWhilePolicyPending) showFenceNotice()
|
|
263
|
+
unpairedWhilePolicyPending = false
|
|
264
|
+
})
|
|
265
|
+
}
|
|
249
266
|
|
|
250
267
|
// One-time failed-pair toast. The accept result lands asynchronously, so
|
|
251
268
|
// the marker check is deferred past the accept round trip.
|
package/src/client/pair-api.ts
CHANGED
|
@@ -36,6 +36,22 @@ export interface IssueLoopbackRequired {
|
|
|
36
36
|
|
|
37
37
|
export type IssueResponse = IssueResult | IssueLanRequired | IssueUnknownAddress | IssueLoopbackRequired
|
|
38
38
|
|
|
39
|
+
/** Public policy fields returned by the pairing status endpoint. */
|
|
40
|
+
export interface PairGatePolicy {
|
|
41
|
+
requirePairingForLan: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read the host-authoritative desktop pairing policy. */
|
|
45
|
+
export async function readPairGatePolicy(): Promise<PairGatePolicy> {
|
|
46
|
+
const response = await fetch('/api/pair/status')
|
|
47
|
+
if (!response.ok) throw new Error(`remote-web-ui: status failed with ${String(response.status)}`)
|
|
48
|
+
const value = await response.json() as { requirePairingForLan?: unknown }
|
|
49
|
+
if (typeof value.requirePairingForLan !== 'boolean') {
|
|
50
|
+
throw new Error('remote-web-ui: status omitted requirePairingForLan')
|
|
51
|
+
}
|
|
52
|
+
return { requirePairingForLan: value.requirePairingForLan }
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
/** accept() refusal codes. */
|
|
40
56
|
export type AcceptFailure = { ok: false; code: 'invalid' | 'used' | 'forbidden' }
|
|
41
57
|
|
|
@@ -46,6 +46,27 @@ const WS_PATHS = new Set([
|
|
|
46
46
|
'/api/dsh-ssh/terminal',
|
|
47
47
|
])
|
|
48
48
|
|
|
49
|
+
/** Minimal settings snapshot used by the remote channel decision. */
|
|
50
|
+
export interface RemoteChannelSettingsSnapshot {
|
|
51
|
+
status: 'ready' | 'loading' | 'unavailable' | string
|
|
52
|
+
value?: { enabled?: boolean; requirePairingForLan?: boolean }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Decide whether a remote desktop channel is required from local or host policy. */
|
|
56
|
+
export function remoteChannelRequired(
|
|
57
|
+
hostname: string,
|
|
58
|
+
snapshot: RemoteChannelSettingsSnapshot,
|
|
59
|
+
hostPairingPolicy: boolean | undefined,
|
|
60
|
+
): boolean {
|
|
61
|
+
if (isLoopbackHostname(hostname)) return false
|
|
62
|
+
if (snapshot.status === 'ready') {
|
|
63
|
+
return (snapshot.value?.enabled ?? true) && (snapshot.value?.requirePairingForLan ?? true)
|
|
64
|
+
}
|
|
65
|
+
// Install provisionally while the host probe is pending so early SDK calls
|
|
66
|
+
// cannot escape onto the plain remote origin. A confirmed false retires it.
|
|
67
|
+
return hostPairingPolicy !== false
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
/**
|
|
50
71
|
* Browser-safe loopback classification for the page origin (the SDK client
|
|
51
72
|
* exports its own; this copy keeps the module dependency-free).
|
package/src/index.ts
CHANGED
|
@@ -353,17 +353,28 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
353
353
|
},
|
|
354
354
|
})
|
|
355
355
|
const routes = [
|
|
356
|
-
...makeRoutes({ service, lanAddresses }),
|
|
356
|
+
...makeRoutes({ service, lanAddresses, requirePairingForLan: () => resolve().requirePairingForLan }),
|
|
357
357
|
...makeMobileRoutes(),
|
|
358
358
|
...(apiProxy !== undefined
|
|
359
359
|
? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
|
|
360
360
|
: []),
|
|
361
|
-
// The remote desktop channel:
|
|
362
|
-
// re-issues fenced paths to loopback (see remote-api.ts).
|
|
363
|
-
|
|
361
|
+
// The remote desktop channel: policy-gated `/remote` prefix that
|
|
362
|
+
// re-issues fenced paths to loopback (see remote-api.ts). The live
|
|
363
|
+
// requirePairingForLan is re-read per request, same as the gate listener
|
|
364
|
+
// and routes above, so a stale client rewrite on an open-LAN deployment
|
|
365
|
+
// proxies instead of 403ing.
|
|
366
|
+
...makeRemoteApiRoutes({
|
|
367
|
+
service,
|
|
368
|
+
port: ctx.webServer.port,
|
|
369
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
370
|
+
}),
|
|
364
371
|
...updateRoutes,
|
|
365
372
|
]
|
|
366
|
-
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
373
|
+
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
374
|
+
service,
|
|
375
|
+
port: ctx.webServer.port,
|
|
376
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
377
|
+
})
|
|
367
378
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
|
|
368
379
|
ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
|
|
369
380
|
|
|
@@ -183,6 +183,29 @@ describe('foldEvents', () => {
|
|
|
183
183
|
expect(assistant?.pending).toBeFalsy()
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
+
it('keeps same-turn steps in final-event order after turn/end', () => {
|
|
187
|
+
const result = foldEvents([
|
|
188
|
+
makeEvent('user/message', userMessageData('u-1', 'multi-step'), 0),
|
|
189
|
+
makeEvent('assistant/message', assistantMessageData('step-z', 0, 0, 'first'), 2),
|
|
190
|
+
makeEvent('assistant/message', assistantMessageData('step-a', 0, 1, 'second'), 4),
|
|
191
|
+
makeEvent('assistant/message', assistantMessageData('step-m', 0, 2, 'third'), 6),
|
|
192
|
+
makeEvent('turn/end', { turn: 0, reason: { kind: 'completed' } }, 7),
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
const assistants = result.filter(message => message.kind === 'assistant')
|
|
196
|
+
expect(assistants.map(message => message.id)).toEqual(['step-z', 'step-a', 'step-m'])
|
|
197
|
+
expect(assistants.map(message => message.seq)).toEqual([2, 4, 6])
|
|
198
|
+
expect(assistants.every(message => message.pending !== true)).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('keeps stable insertion order when legacy rows share a seq', () => {
|
|
202
|
+
const folder = new EventFolder([
|
|
203
|
+
{ id: 'z-last-lexically', kind: 'assistant', text: 'first', seq: 5, time: 5_000 },
|
|
204
|
+
{ id: 'a-first-lexically', kind: 'assistant', text: 'second', seq: 5, time: 5_000 },
|
|
205
|
+
])
|
|
206
|
+
expect(folder.snapshot().map(message => message.id)).toEqual(['z-last-lexically', 'a-first-lexically'])
|
|
207
|
+
})
|
|
208
|
+
|
|
186
209
|
it('is idempotent: applying the same batch twice yields an identical list', () => {
|
|
187
210
|
const events: WireEvent[] = [
|
|
188
211
|
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
package/src/mobile/messages.ts
CHANGED
|
@@ -589,7 +589,8 @@ function applyTurnEnd(state: FoldState, event: WireEvent): void {
|
|
|
589
589
|
...message,
|
|
590
590
|
...(wasPending ? { pending: false } : {}),
|
|
591
591
|
...(failed ? { failed: true } : {}),
|
|
592
|
-
|
|
592
|
+
// Preserve each step's own final-event seq. Collapsing every message
|
|
593
|
+
// onto turn/end makes same-turn ordering depend on arbitrary ids.
|
|
593
594
|
time: event.time,
|
|
594
595
|
})
|
|
595
596
|
}
|
|
@@ -666,10 +667,11 @@ function snapshotOf(state: FoldState): RenderMessage[] {
|
|
|
666
667
|
for (let index = 1; index < out.length; index += 1) {
|
|
667
668
|
const prev = out[index - 1]!
|
|
668
669
|
const current = out[index]!
|
|
669
|
-
if (prev.seq > current.seq
|
|
670
|
+
if (prev.seq > current.seq) {
|
|
670
671
|
ordered = false
|
|
671
672
|
break
|
|
672
673
|
}
|
|
673
674
|
}
|
|
674
|
-
|
|
675
|
+
// Array.sort is stable: equal-seq rows keep their event insertion order.
|
|
676
|
+
return ordered ? out : out.sort((a, b) => a.seq - b.seq)
|
|
675
677
|
}
|
package/src/mobile/mux.test.ts
CHANGED
|
@@ -95,6 +95,22 @@ describe('MuxClient polling fallback', () => {
|
|
|
95
95
|
client.stop()
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
+
it('sorts an out-of-order history page before advancing the watermark', async () => {
|
|
99
|
+
const { factory } = makeSources()
|
|
100
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([2, 1, 3]))
|
|
101
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
102
|
+
const seqs: number[] = []
|
|
103
|
+
client.onFrame((frame) => {
|
|
104
|
+
if (frame.type === 'session/event' && typeof frame.event.seq === 'number') seqs.push(frame.event.seq)
|
|
105
|
+
})
|
|
106
|
+
client.start()
|
|
107
|
+
client.observe('s1')
|
|
108
|
+
|
|
109
|
+
await vi.advanceTimersByTimeAsync(1200)
|
|
110
|
+
expect(seqs).toEqual([1, 2, 3])
|
|
111
|
+
client.stop()
|
|
112
|
+
})
|
|
113
|
+
|
|
98
114
|
it('keeps the watermark so a repeated page never re-emits old events', async () => {
|
|
99
115
|
const { factory } = makeSources()
|
|
100
116
|
// Two calls return the same page: the second must emit nothing.
|
|
@@ -171,6 +187,54 @@ describe('MuxClient polling fallback', () => {
|
|
|
171
187
|
client.stop()
|
|
172
188
|
})
|
|
173
189
|
|
|
190
|
+
it('recovers when a previously-live SSE stream becomes silently stalled', async () => {
|
|
191
|
+
const { factory, sources } = makeSources()
|
|
192
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([5]))
|
|
193
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
194
|
+
const frames: Array<{ type: string; event?: { seq: number } }> = []
|
|
195
|
+
client.onFrame(frame => { frames.push(frame as never) })
|
|
196
|
+
client.start()
|
|
197
|
+
client.observe('s1')
|
|
198
|
+
|
|
199
|
+
sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) })
|
|
200
|
+
await vi.advanceTimersByTimeAsync(2400)
|
|
201
|
+
expect(pollLatest).not.toHaveBeenCalled()
|
|
202
|
+
|
|
203
|
+
// A once-live stream gets three stall windows; the next scheduler tick
|
|
204
|
+
// crosses that boundary and starts the ordinary-HTTP recovery path.
|
|
205
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
206
|
+
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
207
|
+
expect(frames.at(-1)?.event).toMatchObject({ seq: 5 })
|
|
208
|
+
client.stop()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('backs empty polls off and resets to the base cadence after progress', async () => {
|
|
212
|
+
const { factory } = makeSources()
|
|
213
|
+
const pages = [pageOf([]), pageOf([1]), pageOf([1, 2])]
|
|
214
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
|
|
215
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
216
|
+
client.start()
|
|
217
|
+
client.observe('s1')
|
|
218
|
+
|
|
219
|
+
await vi.advanceTimersByTimeAsync(1200)
|
|
220
|
+
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
221
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
222
|
+
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
223
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
224
|
+
expect(pollLatest).toHaveBeenCalledTimes(2)
|
|
225
|
+
|
|
226
|
+
// The productive second poll resets the next delay from 800 ms to 400 ms.
|
|
227
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
228
|
+
expect(pollLatest).toHaveBeenCalledTimes(3)
|
|
229
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
230
|
+
expect(pollLatest).toHaveBeenCalledTimes(4)
|
|
231
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
232
|
+
expect(pollLatest).toHaveBeenCalledTimes(4)
|
|
233
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
234
|
+
expect(pollLatest).toHaveBeenCalledTimes(5)
|
|
235
|
+
client.stop()
|
|
236
|
+
})
|
|
237
|
+
|
|
174
238
|
it('drives the whole lifecycle on a single scheduler tick (one interval)', async () => {
|
|
175
239
|
const { factory } = makeSources()
|
|
176
240
|
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
|
package/src/mobile/mux.ts
CHANGED
|
@@ -39,9 +39,9 @@ export interface MuxClientOptions {
|
|
|
39
39
|
* the ordinary HTTP channel (unaffected by SSE-impairing tunnels).
|
|
40
40
|
*/
|
|
41
41
|
pollLatest?: (sessionId: string) => Promise<HistoryPage>
|
|
42
|
-
/**
|
|
42
|
+
/** Initial poll cadence while SSE is stalled (default 3000 ms). Empty polls back off to 60000 ms. */
|
|
43
43
|
pollIntervalMs?: number
|
|
44
|
-
/**
|
|
44
|
+
/** Initial SSE stall window (default 12000 ms); a previously-live stream gets three windows before fallback. */
|
|
45
45
|
stallThresholdMs?: number
|
|
46
46
|
/** Clock seam for tests (defaults to Date.now). */
|
|
47
47
|
now?: () => number
|
|
@@ -66,6 +66,8 @@ type SessionEventFrame = Extract<MuxFrame, { type: 'session/event' }>
|
|
|
66
66
|
|
|
67
67
|
const DEFAULT_POLL_INTERVAL_MS = 3000
|
|
68
68
|
const DEFAULT_STALL_THRESHOLD_MS = 12000
|
|
69
|
+
const LIVE_SSE_STALL_MULTIPLIER = 3
|
|
70
|
+
const MAX_POLL_BACKOFF_MS = 60000
|
|
69
71
|
/**
|
|
70
72
|
* Stall-check granularity: the single scheduler tick runs at least this
|
|
71
73
|
* often so fallback arms within a second of the stall threshold passing,
|
|
@@ -98,8 +100,8 @@ export class MuxClient {
|
|
|
98
100
|
private lastDataAt = 0
|
|
99
101
|
/**
|
|
100
102
|
* Whether the SSE channel has ever delivered a frame in this stream (a
|
|
101
|
-
* delivered frame proves the tunnel
|
|
102
|
-
*
|
|
103
|
+
* delivered frame proves the tunnel can forward SSE; sustained silence may
|
|
104
|
+
* still mean a suspended mobile tunnel, so polling re-arms after 3 windows).
|
|
103
105
|
*/
|
|
104
106
|
private sseAlive = false
|
|
105
107
|
/** Per-session highest event seq already emitted, for poll dedup. */
|
|
@@ -109,6 +111,8 @@ export class MuxClient {
|
|
|
109
111
|
private polling = false
|
|
110
112
|
/** Epoch ms of the next due poll while polling (kept on the same tick timer). */
|
|
111
113
|
private nextPollAt = 0
|
|
114
|
+
/** Adaptive delay: productive polls reset it; empty/error polls add one base interval up to one minute. */
|
|
115
|
+
private pollDelayMs: number
|
|
112
116
|
|
|
113
117
|
/**
|
|
114
118
|
* @param url - the mobile events endpoint (browser-relative).
|
|
@@ -119,6 +123,7 @@ export class MuxClient {
|
|
|
119
123
|
this.sourceFactory = options.sourceFactory ?? browserSource
|
|
120
124
|
this.pollLatest = options.pollLatest ?? ((sessionId) => fetchHistory(sessionId, undefined, DEFAULT_POLL_PAGE_SIZE))
|
|
121
125
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
126
|
+
this.pollDelayMs = this.pollIntervalMs
|
|
122
127
|
this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS
|
|
123
128
|
this.now = options.now ?? (() => Date.now())
|
|
124
129
|
}
|
|
@@ -159,9 +164,7 @@ export class MuxClient {
|
|
|
159
164
|
return
|
|
160
165
|
}
|
|
161
166
|
// If SSE is already stalled for this session, start patching right away.
|
|
162
|
-
if (!this.polling && !this.stopped &&
|
|
163
|
-
this.startPolling()
|
|
164
|
-
}
|
|
167
|
+
if (!this.polling && !this.stopped && this.isSseStalled()) this.startPolling()
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
private connect(): void {
|
|
@@ -208,29 +211,37 @@ export class MuxClient {
|
|
|
208
211
|
if (this.stopped) return
|
|
209
212
|
if (this.observeSessionId === undefined) return
|
|
210
213
|
if (this.polling) {
|
|
211
|
-
// The stall phase has ended; the same tick now paces the polls.
|
|
214
|
+
// The stall phase has ended; the same tick now paces the adaptive polls.
|
|
212
215
|
if (this.now() >= this.nextPollAt) {
|
|
213
|
-
|
|
216
|
+
// pollTick schedules the next run after it settles, so slow requests
|
|
217
|
+
// cannot overlap with another scheduler tick.
|
|
218
|
+
this.nextPollAt = Number.POSITIVE_INFINITY
|
|
214
219
|
void this.pollTick()
|
|
215
220
|
}
|
|
216
221
|
return
|
|
217
222
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
+
if (this.isSseStalled()) this.startPolling()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private isSseStalled(): boolean {
|
|
227
|
+
const windowMs = this.sseAlive
|
|
228
|
+
? this.stallThresholdMs * LIVE_SSE_STALL_MULTIPLIER
|
|
229
|
+
: this.stallThresholdMs
|
|
230
|
+
return (this.now() - this.lastDataAt) > windowMs
|
|
223
231
|
}
|
|
224
232
|
|
|
225
233
|
private startPolling(): void {
|
|
226
234
|
if (this.polling || this.stopped) return
|
|
227
235
|
this.polling = true
|
|
228
|
-
this.
|
|
236
|
+
this.pollDelayMs = this.pollIntervalMs
|
|
237
|
+
this.nextPollAt = Number.POSITIVE_INFINITY
|
|
229
238
|
void this.pollTick()
|
|
230
239
|
}
|
|
231
240
|
|
|
232
241
|
private stopPolling(): void {
|
|
233
242
|
this.polling = false
|
|
243
|
+
this.pollDelayMs = this.pollIntervalMs
|
|
244
|
+
this.nextPollAt = 0
|
|
234
245
|
}
|
|
235
246
|
|
|
236
247
|
/**
|
|
@@ -244,19 +255,35 @@ export class MuxClient {
|
|
|
244
255
|
this.stopPolling()
|
|
245
256
|
return
|
|
246
257
|
}
|
|
258
|
+
let emitted = 0
|
|
247
259
|
try {
|
|
248
260
|
const page = await this.pollLatest(sessionId)
|
|
249
261
|
let maxSeq = this.pollWatermark.get(sessionId) ?? -1
|
|
250
|
-
|
|
262
|
+
const ordered = [...page.events].sort((left, right) => {
|
|
263
|
+
const leftSeq = typeof left.event?.seq === 'number' ? left.event.seq : -1
|
|
264
|
+
const rightSeq = typeof right.event?.seq === 'number' ? right.event.seq : -1
|
|
265
|
+
return leftSeq - rightSeq
|
|
266
|
+
})
|
|
267
|
+
for (const entry of ordered) {
|
|
251
268
|
const event = entry.event
|
|
252
269
|
const seq = typeof event?.seq === 'number' ? event.seq : -1
|
|
253
270
|
if (seq <= maxSeq) continue
|
|
254
271
|
maxSeq = seq
|
|
272
|
+
emitted += 1
|
|
255
273
|
this.emit({ type: 'session/event', sessionId: sessionId as SessionEventFrame['sessionId'], event } as SessionEventFrame)
|
|
256
274
|
}
|
|
257
275
|
this.pollWatermark.set(sessionId, maxSeq)
|
|
258
276
|
} catch {
|
|
259
|
-
// Transient (network, pairing, history paging);
|
|
277
|
+
// Transient (network, pairing, history paging); retry with backoff.
|
|
278
|
+
} finally {
|
|
279
|
+
if (emitted > 0) {
|
|
280
|
+
this.pollDelayMs = this.pollIntervalMs
|
|
281
|
+
} else {
|
|
282
|
+
this.pollDelayMs = Math.min(MAX_POLL_BACKOFF_MS, this.pollDelayMs + this.pollIntervalMs)
|
|
283
|
+
}
|
|
284
|
+
if (this.polling && this.observeSessionId === sessionId) {
|
|
285
|
+
this.nextPollAt = this.now() + this.pollDelayMs
|
|
286
|
+
}
|
|
260
287
|
}
|
|
261
288
|
}
|
|
262
289
|
|