@linxin666/dsh-remote-web-ui 0.2.6 → 0.2.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.i18n.yaml +2 -2
- package/README.md +8 -1
- package/README.zh.md +2 -1
- package/lib/client.js +55 -10
- package/lib/client.js.map +1 -1
- package/lib/index.js +12 -8
- package/lib/mobile.js +37 -18
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FenceNotice.d.ts +3 -2
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +8 -8
- package/lib/types/client/RemotePanel.js +3 -3
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +2 -2
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +1 -1
- package/lib/types/client/deep-link.js +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +45 -23
- 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 +25 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +24 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/update-api.d.ts.map +1 -1
- package/lib/types/gate.js +1 -1
- package/lib/types/index.js +15 -15
- package/lib/types/invariant.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +1 -1
- package/lib/types/mobile/PairRequiredView.js +1 -1
- package/lib/types/mobile/PairRequiredView.test.js +1 -1
- package/lib/types/mobile/api.js +1 -1
- package/lib/types/mobile/index.js +5 -5
- package/lib/types/mobile/markdown.test.js +1 -1
- 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 +21 -1
- package/lib/types/mobile/mobile-theme.test.js +2 -2
- 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 +44 -16
- package/lib/types/mobile/mux.test.js +58 -1
- package/lib/types/mobile/rpc.d.ts.map +1 -1
- package/lib/types/mobile/theme-toggle.js +1 -1
- package/lib/types/mobile/views/App.js +7 -7
- package/lib/types/mobile/views/App.test.js +2 -2
- package/lib/types/mobile/views/ChatView.js +6 -6
- package/lib/types/mobile/views/ChatView.test.js +3 -3
- package/lib/types/mobile/views/SessionListView.js +3 -3
- package/lib/types/mobile/views/SessionListView.test.js +2 -2
- package/lib/types/mobile/views/WorkspaceView.js +3 -3
- package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
- package/lib/types/mobile-api.d.ts +1 -1
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +2 -2
- package/lib/types/pairing-access.d.ts.map +1 -1
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.persist.test.js +2 -2
- package/lib/types/remote-api.js +6 -6
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/routes.d.ts +9 -7
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +8 -5
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/update-routes.d.ts +2 -2
- package/lib/types/update-routes.d.ts.map +1 -1
- package/lib/types/update-routes.js +1 -1
- package/package.json +15 -12
- package/src/client/FenceNotice.tsx +3 -2
- package/src/client/RemoteEntry.tsx +3 -2
- package/src/client/UpdateEntry.tsx +1 -0
- package/src/client/index.ts +40 -15
- package/src/client/pair-api.ts +16 -0
- package/src/client/remote-channel.ts +42 -0
- package/src/index.ts +1 -1
- 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/routes.ts +7 -2
package/lib/types/routes.d.ts
CHANGED
|
@@ -37,13 +37,13 @@ export declare function isTrustedApiRequest(request: IncomingMessage, trustedHos
|
|
|
37
37
|
export declare function publicHostOf(url: string | undefined): string | undefined;
|
|
38
38
|
/** Route paths (exact matches under /api). */
|
|
39
39
|
export declare const PAIR_PATHS: {
|
|
40
|
-
readonly issue:
|
|
41
|
-
readonly accept:
|
|
42
|
-
readonly stop:
|
|
43
|
-
readonly revoke:
|
|
44
|
-
readonly heartbeat:
|
|
45
|
-
readonly status:
|
|
46
|
-
readonly events:
|
|
40
|
+
readonly issue: "/api/pair/issue";
|
|
41
|
+
readonly accept: "/api/pair/accept";
|
|
42
|
+
readonly stop: "/api/pair/stop";
|
|
43
|
+
readonly revoke: "/api/pair/revoke";
|
|
44
|
+
readonly heartbeat: "/api/pair/heartbeat";
|
|
45
|
+
readonly status: "/api/pair/status";
|
|
46
|
+
readonly events: "/api/pair/events";
|
|
47
47
|
};
|
|
48
48
|
/**
|
|
49
49
|
* /api/pair request payload contracts. Each POST endpoint validates its body
|
|
@@ -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
|
|
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
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* on every other /api request.
|
|
9
9
|
*/
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
-
import { UnknownLanAddressError } from
|
|
12
|
-
import { isLoopbackClient, readCookie } from
|
|
13
|
-
import { readBoundedJson, writeJson } from
|
|
11
|
+
import { UnknownLanAddressError } from "./pairing.js";
|
|
12
|
+
import { isLoopbackClient, readCookie } from "./gate.js";
|
|
13
|
+
import { readBoundedJson, writeJson } from "./http.js";
|
|
14
14
|
/**
|
|
15
15
|
* Browser-trust fence for the /api/pair routes, mirroring the connection
|
|
16
16
|
* package's internal fence semantics (Host/Origin based, DNS-rebinding and
|
|
@@ -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'))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,0EAA0E;AAC1E,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAA;AAEvE,+BAA+B;AAC/B,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAA;IAClB,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;IAC9D,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;IAC/D,IAAI,IAAI,OAAO,CAAA;CAChB;AAED,kFAAkF;AAClF,MAAM,WAAW,oBAAoB;IACnC,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,YAAY,CAAA;IAC7C,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,wCAAwC;IACxC,KAAK,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QAAC,YAAY,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;KAAE,CAAA;CAC5F;AAkBD;;;GAGG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqC;IAC7D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAClD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqF;IAE3G,OAAO,CAAC,KAAK,CAAyB;IACtC,OAAO,CAAC,GAAG,CAAoB;IAC/B,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,QAAQ,CAAI;IAGpB,OAAO,CAAC,UAAU,CAAI;IACtB,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmC;IAChE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAwC;IAEvE;;OAEG;
|
|
1
|
+
{"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,0EAA0E;AAC1E,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAA;AAEvE,+BAA+B;AAC/B,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAA;IAClB,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;IAC9D,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;IAC/D,IAAI,IAAI,OAAO,CAAA;CAChB;AAED,kFAAkF;AAClF,MAAM,WAAW,oBAAoB;IACnC,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,YAAY,CAAA;IAC7C,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,wCAAwC;IACxC,KAAK,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QAAC,YAAY,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;KAAE,CAAA;CAC5F;AAkBD;;;GAGG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqC;IAC7D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAClD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqF;IAE3G,OAAO,CAAC,KAAK,CAAyB;IACtC,OAAO,CAAC,GAAG,CAAoB;IAC/B,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,QAAQ,CAAI;IAGpB,OAAO,CAAC,UAAU,CAAI;IACtB,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmC;IAChE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAwC;IAEvE;;OAEG;gBACS,OAAO,GAAE,oBAAyB;IAS9C,gCAAgC;IAChC,IAAI,IAAI,IAAI,UAAU,CAMrB;IAED;;;;;OAKG;IACH,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAU9B,uDAAuD;IACvD,IAAI,IAAI,IAAI;IAOZ,wDAAwD;IACxD,OAAO,IAAI,IAAI;IAIf,4EAA4E;IAC5E,KAAK,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI;IAKlD,uCAAuC;IACvC,OAAO,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI;IAKzD,OAAO,CAAC,OAAO;IAqCf,OAAO,CAAC,SAAS;IAkBjB,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,IAAI;IAqBZ,iFAAiF;IACjF,OAAO,CAAC,QAAQ;IAgBhB,OAAO,CAAC,QAAQ;CAWjB"}
|
|
@@ -9,8 +9,8 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
|
9
9
|
import type { UpdateRunResult, UpdateStatus } from './update.ts';
|
|
10
10
|
/** Route paths (exact matches under /api). */
|
|
11
11
|
export declare const UPDATE_PATHS: {
|
|
12
|
-
readonly status:
|
|
13
|
-
readonly run:
|
|
12
|
+
readonly status: "/api/update/status";
|
|
13
|
+
readonly run: "/api/update/run";
|
|
14
14
|
};
|
|
15
15
|
/** Route-family dependencies (test seam). */
|
|
16
16
|
export interface UpdateRoutesDeps {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"update-routes.d.ts","sourceRoot":"","sources":["../../src/update-routes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAGhE,8CAA8C;AAC9C,eAAO,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"update-routes.d.ts","sourceRoot":"","sources":["../../src/update-routes.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAGhE,8CAA8C;AAC9C,eAAO,MAAM,YAAY;;;CAGf,CAAA;AAEV,6CAA6C;AAC7C,MAAM,WAAW,gBAAgB;IAC/B,wEAAwE;IACxE,KAAK,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAA;IACxC,yDAAyD;IACzD,KAAK,IAAI,OAAO,CAAC,YAAY,CAAC,CAAA;IAC9B,sDAAsD;IACtD,GAAG,IAAI,OAAO,CAAC,eAAe,CAAC,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB,GAAG,QAAQ,EAAE,CA+BnE"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* pnpm install inside the owning profile, so it must never be reachable
|
|
5
5
|
* from a LAN/phone origin.
|
|
6
6
|
*/
|
|
7
|
-
import { writeJson } from
|
|
7
|
+
import { writeJson } from "./http.js";
|
|
8
8
|
/** Route paths (exact matches under /api). */
|
|
9
9
|
export const UPDATE_PATHS = {
|
|
10
10
|
status: '/api/update/status',
|
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.8",
|
|
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.
|
|
65
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
66
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.
|
|
68
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.
|
|
69
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
71
|
-
"@deepseek-ai/dsh-host-apiproxy": "^0.1.
|
|
72
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
73
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
74
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
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",
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Unpaired-desktop notice: a full-page blocking surface rendered when the remote channel
|
|
3
3
|
* (see remote-channel.ts) refuses a call because this desktop browser has no
|
|
4
4
|
* live paired-device cookie. Retires automatically once a gated call
|
|
5
|
-
* succeeds (the channel reports pairing)
|
|
6
|
-
*
|
|
5
|
+
* succeeds (the channel reports pairing) or when the channel itself is
|
|
6
|
+
* torn down (requirePairingForLan off / plugin disabled), so it never
|
|
7
|
+
* outlives the unpaired state it describes.
|
|
7
8
|
*/
|
|
8
9
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
9
10
|
import css from './remote.module.css'
|
|
@@ -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 { 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,30 +216,53 @@ 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)
|
|
233
|
+
if (transition === 'install') {
|
|
230
234
|
disposeChannel = ctx.effect(() => {
|
|
231
|
-
const restore = installRemoteChannel(window, { onUnpaired:
|
|
235
|
+
const restore = installRemoteChannel(window, { onUnpaired: handleUnpaired, onPaired: hideFenceNotice })
|
|
232
236
|
return restore
|
|
233
237
|
}, 'remote-web-ui: remote desktop channel')
|
|
234
|
-
} else if (
|
|
238
|
+
} else if (transition === 'retire' && disposeChannel !== undefined) {
|
|
235
239
|
disposeChannel()
|
|
236
240
|
disposeChannel = undefined
|
|
241
|
+
// Retire the notice with the channel: once requirePairingForLan turns
|
|
242
|
+
// off (or the plugin is disabled) the desktop rides plain /api again,
|
|
243
|
+
// so an unpaired notice raised while the channel was briefly active
|
|
244
|
+
// (the settings snapshot loads after boot) must not outlive it. The
|
|
245
|
+
// installed channel is the only path that raises the notice, so with
|
|
246
|
+
// the channel gone nothing can re-raise it (issue #808).
|
|
247
|
+
hideFenceNotice()
|
|
237
248
|
}
|
|
238
249
|
}
|
|
239
250
|
settingsScope.subscribe(syncChannel)
|
|
240
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
|
+
}
|
|
241
266
|
|
|
242
267
|
// One-time failed-pair toast. The accept result lands asynchronously, so
|
|
243
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).
|
|
@@ -267,3 +288,24 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
|
|
|
267
288
|
for (const restore of restoreSrc) restore()
|
|
268
289
|
}
|
|
269
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The remote-channel lifecycle transition between two steady states:
|
|
294
|
+
* running (active + installed) and retired (inactive + not installed).
|
|
295
|
+
* The client apply drives the channel with this decision and retires the
|
|
296
|
+
* unpaired fence notice together with the channel itself — a notice raised
|
|
297
|
+
* while the channel was briefly active must not outlive it (issue #808).
|
|
298
|
+
*/
|
|
299
|
+
export type ChannelTransition = 'install' | 'retire' | 'none'
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Decide what the channel lifecycle must do next.
|
|
303
|
+
* @param active - whether the gated remote channel should be running now.
|
|
304
|
+
* @param installed - whether it currently is (disposer !== undefined).
|
|
305
|
+
* @returns the transition to apply.
|
|
306
|
+
*/
|
|
307
|
+
export function channelTransition(active: boolean, installed: boolean): ChannelTransition {
|
|
308
|
+
if (active && !installed) return 'install'
|
|
309
|
+
if (!active && installed) return 'retire'
|
|
310
|
+
return 'none'
|
|
311
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -353,7 +353,7 @@ 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 })
|
|
@@ -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')
|