@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.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/README.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +955 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +73 -39
- package/lib/types/client/locales.d.ts +26 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -22
- package/lib/types/client/mobile-adapt.d.ts +40 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +967 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +77 -39
- package/src/client/locales.ts +36 -22
- package/src/client/mobile-adapt.ts +913 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17658
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -149
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -561
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -63
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -821
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -685
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -268
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1342
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/** mobile-theme: light default, persisted dark toggle, document wiring. */
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import {
|
|
5
|
-
getMobileTheme, setMobileTheme, subscribeMobileTheme, toggleMobileTheme,
|
|
6
|
-
} from './mobile-theme.ts'
|
|
7
|
-
|
|
8
|
-
const STORAGE_KEY = 'dsh.remote.theme'
|
|
9
|
-
|
|
10
|
-
/** jsdom in this setup ships a bare localStorage object; install a real fake. */
|
|
11
|
-
function makeStorage(): Storage {
|
|
12
|
-
const map = new Map<string, string>()
|
|
13
|
-
return {
|
|
14
|
-
getItem: (key: string) => map.get(key) ?? null,
|
|
15
|
-
setItem: (key: string, value: string) => { map.set(key, value) },
|
|
16
|
-
removeItem: (key: string) => { map.delete(key) },
|
|
17
|
-
clear: () => { map.clear() },
|
|
18
|
-
key: (index: number) => [...map.keys()][index] ?? null,
|
|
19
|
-
get length() { return map.size },
|
|
20
|
-
} as Storage
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
let storage: Storage
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
storage = makeStorage()
|
|
27
|
-
vi.stubGlobal('localStorage', storage)
|
|
28
|
-
setMobileTheme('light')
|
|
29
|
-
document.documentElement.removeAttribute('data-theme')
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
vi.unstubAllGlobals()
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
describe('mobile-theme', () => {
|
|
37
|
-
it('defaults to light with nothing stored', () => {
|
|
38
|
-
expect(getMobileTheme()).toBe('light')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('toggles to dark, persists it, and wires the document', () => {
|
|
42
|
-
expect(toggleMobileTheme()).toBe('dark')
|
|
43
|
-
expect(getMobileTheme()).toBe('dark')
|
|
44
|
-
expect(document.documentElement.dataset.theme).toBe('dark')
|
|
45
|
-
expect(storage.getItem(STORAGE_KEY)).toBe('dark')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('toggles back to light', () => {
|
|
49
|
-
toggleMobileTheme()
|
|
50
|
-
expect(toggleMobileTheme()).toBe('light')
|
|
51
|
-
expect(document.documentElement.dataset.theme).toBe('light')
|
|
52
|
-
expect(storage.getItem(STORAGE_KEY)).toBe('light')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('updates the browser chrome color along with the theme', () => {
|
|
56
|
-
const meta = document.createElement('meta')
|
|
57
|
-
meta.name = 'theme-color'
|
|
58
|
-
document.head.appendChild(meta)
|
|
59
|
-
setMobileTheme('dark')
|
|
60
|
-
expect(meta.content).toBe('#111418')
|
|
61
|
-
setMobileTheme('light')
|
|
62
|
-
expect(meta.content).toBe('#f3f5f9')
|
|
63
|
-
meta.remove()
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('boot reads the stored theme and initMobileTheme applies it', async () => {
|
|
67
|
-
storage.setItem(STORAGE_KEY, 'dark')
|
|
68
|
-
vi.resetModules()
|
|
69
|
-
const fresh = await import('./mobile-theme.ts')
|
|
70
|
-
expect(fresh.getMobileTheme()).toBe('dark')
|
|
71
|
-
fresh.initMobileTheme()
|
|
72
|
-
expect(document.documentElement.dataset.theme).toBe('dark')
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('notifies subscribers on change', () => {
|
|
76
|
-
const seen: string[] = []
|
|
77
|
-
const unsubscribe = subscribeMobileTheme(() => { seen.push(getMobileTheme()) })
|
|
78
|
-
setMobileTheme('dark')
|
|
79
|
-
setMobileTheme('light')
|
|
80
|
-
unsubscribe()
|
|
81
|
-
setMobileTheme('dark')
|
|
82
|
-
expect(seen).toEqual(['dark', 'light'])
|
|
83
|
-
})
|
|
84
|
-
})
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mobile-surface theme: light by default, dark via an explicit persisted
|
|
3
|
-
* toggle. The standalone page boots without the shell, so there is no theme
|
|
4
|
-
* system to inherit — the choice is stored in localStorage and applied as a
|
|
5
|
-
* `data-theme` attribute on <html>; the stylesheet defines both palettes
|
|
6
|
-
* under `:root` (light) / `:root[data-theme='dark']` (dark).
|
|
7
|
-
*
|
|
8
|
-
* A tiny module store (subscribe/get) keeps the toggle button and the boot
|
|
9
|
-
* path in sync without threading props through the three view levels.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export type MobileTheme = 'light' | 'dark'
|
|
13
|
-
|
|
14
|
-
const STORAGE_KEY = 'dsh.remote.theme'
|
|
15
|
-
const THEME_COLOR_META_NAME = 'theme-color'
|
|
16
|
-
const LIGHT_THEME_COLOR = '#f3f5f9'
|
|
17
|
-
const DARK_THEME_COLOR = '#111418'
|
|
18
|
-
|
|
19
|
-
let current: MobileTheme = readStored() ?? 'light'
|
|
20
|
-
const listeners = new Set<() => void>()
|
|
21
|
-
|
|
22
|
-
function readStored(): MobileTheme | undefined {
|
|
23
|
-
try {
|
|
24
|
-
const value = localStorage.getItem(STORAGE_KEY)
|
|
25
|
-
return value === 'dark' || value === 'light' ? value : undefined
|
|
26
|
-
} catch {
|
|
27
|
-
return undefined
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function persist(theme: MobileTheme): void {
|
|
32
|
-
try {
|
|
33
|
-
localStorage.setItem(STORAGE_KEY, theme)
|
|
34
|
-
} catch {
|
|
35
|
-
// Private-mode storage failures are non-fatal; the session keeps the theme.
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Apply the theme to the document: data-theme attribute + browser chrome color. */
|
|
40
|
-
function applyToDocument(theme: MobileTheme): void {
|
|
41
|
-
if (typeof document === 'undefined') return
|
|
42
|
-
document.documentElement.dataset.theme = theme
|
|
43
|
-
const meta = document.querySelector<HTMLMetaElement>(`meta[name="${THEME_COLOR_META_NAME}"]`)
|
|
44
|
-
if (meta !== null) meta.content = theme === 'dark' ? DARK_THEME_COLOR : LIGHT_THEME_COLOR
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Current theme (light unless the user explicitly toggled to dark). */
|
|
48
|
-
export function getMobileTheme(): MobileTheme {
|
|
49
|
-
return current
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Subscribe to theme changes; returns the unsubscribe function. */
|
|
53
|
-
export function subscribeMobileTheme(listener: () => void): () => void {
|
|
54
|
-
listeners.add(listener)
|
|
55
|
-
return () => { listeners.delete(listener) }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Set the theme explicitly (persisted + applied to the document). */
|
|
59
|
-
export function setMobileTheme(theme: MobileTheme): void {
|
|
60
|
-
if (theme === current) return
|
|
61
|
-
current = theme
|
|
62
|
-
persist(theme)
|
|
63
|
-
applyToDocument(theme)
|
|
64
|
-
for (const listener of [...listeners]) listener()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Flip light/dark and return the new theme. */
|
|
68
|
-
export function toggleMobileTheme(): MobileTheme {
|
|
69
|
-
setMobileTheme(current === 'light' ? 'dark' : 'light')
|
|
70
|
-
return current
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Apply the persisted (or default) theme once at boot, before first paint. */
|
|
74
|
-
export function initMobileTheme(): void {
|
|
75
|
-
applyToDocument(current)
|
|
76
|
-
}
|
package/src/mobile/mux.test.ts
DELETED
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
/** mux: live-event client, SSE delivery + stall-driven polling fallback. */
|
|
2
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { MuxClient, type EventSourceLike } from './mux.ts'
|
|
4
|
-
import type { HistoryPage } from './api.ts'
|
|
5
|
-
|
|
6
|
-
/** A recorded fake EventSource (delivery driven by the test). */
|
|
7
|
-
interface FakeSource extends EventSourceLike {
|
|
8
|
-
url: string
|
|
9
|
-
closed: boolean
|
|
10
|
-
close: () => void
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Create an EventSource factory that records every opened source. */
|
|
14
|
-
function makeSources(): { factory: (url: string) => EventSourceLike; sources: FakeSource[] } {
|
|
15
|
-
const sources: FakeSource[] = []
|
|
16
|
-
const factory = (url: string): EventSourceLike => {
|
|
17
|
-
const source: FakeSource = {
|
|
18
|
-
url,
|
|
19
|
-
onmessage: null,
|
|
20
|
-
onerror: null,
|
|
21
|
-
closed: false,
|
|
22
|
-
close: () => { source.closed = true },
|
|
23
|
-
}
|
|
24
|
-
sources.push(source)
|
|
25
|
-
return source
|
|
26
|
-
}
|
|
27
|
-
return { factory, sources }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** One history page whose events carry sequential ids. */
|
|
31
|
-
function pageOf(seqs: readonly number[]): HistoryPage {
|
|
32
|
-
return {
|
|
33
|
-
hasMore: false,
|
|
34
|
-
events: seqs.map(seq => ({
|
|
35
|
-
event: { type: 'user/message', seq, time: seq * 1_000, data: { text: String(seq) } },
|
|
36
|
-
})),
|
|
37
|
-
} as unknown as HistoryPage
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** A server-request envelope carrying one mux frame (the SSE wire shape). */
|
|
41
|
-
function envelopeWith(payload: unknown): string {
|
|
42
|
-
return JSON.stringify({ type: 'server-request', rpcId: 'r1', method: 'events.mux', payload })
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Options common to every test: tight clocks, injected data source. */
|
|
46
|
-
function baseOptions(pollLatest: (sessionId: string) => Promise<HistoryPage>, factory: (url: string) => EventSourceLike) {
|
|
47
|
-
return { sourceFactory: factory, pollLatest, stallThresholdMs: 800, pollIntervalMs: 400 }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
describe('MuxClient polling fallback', () => {
|
|
51
|
-
beforeEach(() => {
|
|
52
|
-
vi.useFakeTimers()
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('does not poll while the SSE channel is fresh', async () => {
|
|
56
|
-
const { factory } = makeSources()
|
|
57
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
58
|
-
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
59
|
-
const frames: unknown[] = []
|
|
60
|
-
client.onFrame(frame => { frames.push(frame) })
|
|
61
|
-
client.start()
|
|
62
|
-
client.observe('s1')
|
|
63
|
-
await vi.advanceTimersByTimeAsync(400) // well under the stall threshold
|
|
64
|
-
expect(pollLatest).not.toHaveBeenCalled()
|
|
65
|
-
expect(frames).toHaveLength(0)
|
|
66
|
-
client.stop()
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('starts polling after silence and emits appended events as session/event frames', async () => {
|
|
70
|
-
const { factory } = makeSources()
|
|
71
|
-
const pages = [pageOf([0]), pageOf([0, 1])]
|
|
72
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
|
|
73
|
-
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
74
|
-
const frames: Array<{ type: string; sessionId: string; event?: { seq: number } }> = []
|
|
75
|
-
client.onFrame(frame => { frames.push(frame as never) })
|
|
76
|
-
client.start()
|
|
77
|
-
client.observe('s1')
|
|
78
|
-
|
|
79
|
-
// Nothing live within a poll interval until the stall window passes.
|
|
80
|
-
await vi.advanceTimersByTimeAsync(400)
|
|
81
|
-
expect(pollLatest).not.toHaveBeenCalled()
|
|
82
|
-
|
|
83
|
-
// The single tick (400 ms) crosses the 800 ms stall threshold at 1200 ms:
|
|
84
|
-
// the same tick then runs the first poll and emits seq 0.
|
|
85
|
-
await vi.advanceTimersByTimeAsync(800) // 1200ms total -> first stall crossing tick
|
|
86
|
-
expect(pollLatest).toHaveBeenCalledWith('s1')
|
|
87
|
-
expect(frames).toHaveLength(1)
|
|
88
|
-
expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' })
|
|
89
|
-
expect(frames[0]?.event).toMatchObject({ seq: 0 })
|
|
90
|
-
|
|
91
|
-
// The next poll emits only the appended event (seq 1), not seq 0 again.
|
|
92
|
-
await vi.advanceTimersByTimeAsync(400)
|
|
93
|
-
expect(frames).toHaveLength(2)
|
|
94
|
-
expect(frames[1]?.event).toMatchObject({ seq: 1 })
|
|
95
|
-
client.stop()
|
|
96
|
-
})
|
|
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
|
-
|
|
114
|
-
it('keeps the watermark so a repeated page never re-emits old events', async () => {
|
|
115
|
-
const { factory } = makeSources()
|
|
116
|
-
// Two calls return the same page: the second must emit nothing.
|
|
117
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0, 1, 2]))
|
|
118
|
-
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
119
|
-
const frames: Array<{ type: string; event?: { seq: number } }> = []
|
|
120
|
-
client.onFrame(frame => { frames.push(frame as never) })
|
|
121
|
-
client.start()
|
|
122
|
-
client.observe('s1')
|
|
123
|
-
|
|
124
|
-
await vi.advanceTimersByTimeAsync(1200) // first poll -> 3 events
|
|
125
|
-
expect(frames).toHaveLength(3)
|
|
126
|
-
|
|
127
|
-
await vi.advanceTimersByTimeAsync(400) // second poll -> same page, nothing new
|
|
128
|
-
expect(frames).toHaveLength(3)
|
|
129
|
-
client.stop()
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('stops polling when observe is cleared and keeps it stopped', async () => {
|
|
133
|
-
const { factory } = makeSources()
|
|
134
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
135
|
-
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
136
|
-
client.start()
|
|
137
|
-
client.observe('s1')
|
|
138
|
-
|
|
139
|
-
await vi.advanceTimersByTimeAsync(1200)
|
|
140
|
-
expect(pollLatest).toHaveBeenCalled()
|
|
141
|
-
|
|
142
|
-
client.observe(undefined)
|
|
143
|
-
const callsAfterClear = pollLatest.mock.calls.length
|
|
144
|
-
await vi.advanceTimersByTimeAsync(2000)
|
|
145
|
-
expect(pollLatest.mock.calls.length).toBe(callsAfterClear)
|
|
146
|
-
client.stop()
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
it('stops polling on stop(), closing any live source', async () => {
|
|
150
|
-
const { factory, sources } = makeSources()
|
|
151
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
152
|
-
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
153
|
-
client.start()
|
|
154
|
-
client.observe('s1')
|
|
155
|
-
|
|
156
|
-
await vi.advanceTimersByTimeAsync(1200)
|
|
157
|
-
expect(pollLatest).toHaveBeenCalled()
|
|
158
|
-
|
|
159
|
-
client.stop()
|
|
160
|
-
const callsAfterStop = pollLatest.mock.calls.length
|
|
161
|
-
await vi.advanceTimersByTimeAsync(2000)
|
|
162
|
-
expect(pollLatest.mock.calls.length).toBe(callsAfterStop)
|
|
163
|
-
expect(sources[0]?.closed).toBe(true)
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it('returns to SSE when a frame arrives, dropping the fallback poller', async () => {
|
|
167
|
-
const { factory, sources } = makeSources()
|
|
168
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
169
|
-
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
170
|
-
const frames: unknown[] = []
|
|
171
|
-
client.onFrame(frame => { frames.push(frame) })
|
|
172
|
-
client.start()
|
|
173
|
-
client.observe('s1')
|
|
174
|
-
|
|
175
|
-
// Stall into polling.
|
|
176
|
-
await vi.advanceTimersByTimeAsync(1200)
|
|
177
|
-
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
178
|
-
|
|
179
|
-
// A live mux frame proves SSE delivers again -> fallback stops.
|
|
180
|
-
sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) })
|
|
181
|
-
|
|
182
|
-
await vi.advanceTimersByTimeAsync(2000)
|
|
183
|
-
expect(pollLatest.mock.calls.length).toBe(1) // polling stopped after the live frame
|
|
184
|
-
const live = frames.filter(frame => (frame as { type?: string })?.type === 'session/subscribed')
|
|
185
|
-
expect(live).toHaveLength(1)
|
|
186
|
-
expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' })
|
|
187
|
-
client.stop()
|
|
188
|
-
})
|
|
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
|
-
|
|
238
|
-
it('drives the whole lifecycle on a single scheduler tick (one interval)', async () => {
|
|
239
|
-
const { factory } = makeSources()
|
|
240
|
-
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
|
|
241
|
-
try {
|
|
242
|
-
const pages = [pageOf([0]), pageOf([0, 1])]
|
|
243
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
|
|
244
|
-
const client = new MuxClient('/m/api/events.mux', {
|
|
245
|
-
sourceFactory: factory,
|
|
246
|
-
pollLatest,
|
|
247
|
-
stallThresholdMs: 1500,
|
|
248
|
-
pollIntervalMs: 2000,
|
|
249
|
-
})
|
|
250
|
-
client.start()
|
|
251
|
-
client.observe('s1')
|
|
252
|
-
|
|
253
|
-
// Exactly one interval is ever created: the single tick scheduler.
|
|
254
|
-
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
|
255
|
-
|
|
256
|
-
// The tick fires below the stall threshold without polling.
|
|
257
|
-
await vi.advanceTimersByTimeAsync(1000)
|
|
258
|
-
expect(pollLatest).not.toHaveBeenCalled()
|
|
259
|
-
|
|
260
|
-
// The same interval arms the fallback once silence passes the threshold
|
|
261
|
-
// (tick at 2000 crosses 1500) and runs the first poll immediately.
|
|
262
|
-
await vi.advanceTimersByTimeAsync(1100) // 2100ms total
|
|
263
|
-
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
264
|
-
expect(setIntervalSpy).toHaveBeenCalledTimes(1) // still one timer
|
|
265
|
-
|
|
266
|
-
// Subsequent polls ride the same tick at the poll cadence (2000 ms).
|
|
267
|
-
await vi.advanceTimersByTimeAsync(2000)
|
|
268
|
-
expect(pollLatest).toHaveBeenCalledTimes(2)
|
|
269
|
-
client.stop()
|
|
270
|
-
} finally {
|
|
271
|
-
setIntervalSpy.mockRestore()
|
|
272
|
-
}
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
it('paces polls on the tick only after the stall phase ends', async () => {
|
|
276
|
-
const { factory } = makeSources()
|
|
277
|
-
const pages = [pageOf([0]), pageOf([0, 1]), pageOf([0, 1, 2]), pageOf([0, 1, 2, 3])]
|
|
278
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
|
|
279
|
-
const client = new MuxClient('/m/api/events.mux', {
|
|
280
|
-
sourceFactory: factory,
|
|
281
|
-
pollLatest,
|
|
282
|
-
stallThresholdMs: 800,
|
|
283
|
-
pollIntervalMs: 400,
|
|
284
|
-
})
|
|
285
|
-
client.start()
|
|
286
|
-
client.observe('s1')
|
|
287
|
-
|
|
288
|
-
// At 1200 ms the stall threshold is crossed on this single 400 ms tick.
|
|
289
|
-
await vi.advanceTimersByTimeAsync(1200)
|
|
290
|
-
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
291
|
-
|
|
292
|
-
// Each subsequent 400 ms tick is a poll, matching the poll cadence.
|
|
293
|
-
await vi.advanceTimersByTimeAsync(400)
|
|
294
|
-
await vi.advanceTimersByTimeAsync(400)
|
|
295
|
-
await vi.advanceTimersByTimeAsync(400)
|
|
296
|
-
expect(pollLatest).toHaveBeenCalledTimes(4)
|
|
297
|
-
client.stop()
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
it('observe() already in the stall window starts the single-tick poller immediately', async () => {
|
|
301
|
-
const { factory } = makeSources()
|
|
302
|
-
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
303
|
-
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
304
|
-
client.start()
|
|
305
|
-
// Advance well past the stall threshold while nothing is observed.
|
|
306
|
-
await vi.advanceTimersByTimeAsync(2000)
|
|
307
|
-
expect(pollLatest).not.toHaveBeenCalled()
|
|
308
|
-
|
|
309
|
-
// Observing a session now is already in the stall window: poll right away.
|
|
310
|
-
client.observe('s1')
|
|
311
|
-
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
312
|
-
client.stop()
|
|
313
|
-
})
|
|
314
|
-
})
|