@linxin666/dsh-remote-web-ui 0.2.0 → 0.2.2
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 +17 -8
- package/README.zh.md +5 -5
- package/lib/client.js +259 -43
- package/lib/client.js.map +1 -1
- package/lib/index.js +26 -7
- package/lib/mobile.js +209 -39
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +22 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +142 -3
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +11 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +8 -5
- package/lib/types/client/UpdatePanel.d.ts +3 -1
- package/lib/types/client/UpdatePanel.d.ts.map +1 -1
- package/lib/types/client/UpdatePanel.js +3 -3
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +8 -4
- package/lib/types/client/pair-api.d.ts +1 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +1 -10
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +2 -0
- package/lib/types/mobile/api.d.ts +10 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +4 -0
- package/lib/types/mobile/messages.d.ts +24 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +60 -23
- package/lib/types/mobile/messages.test.js +49 -1
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +38 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +34 -11
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +30 -4
- package/lib/types/mobile/views/SessionListView.test.js +58 -1
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +4 -2
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +10 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -2
- package/lib/types/tunnel.d.ts +1 -0
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -2
- package/lib/types/update.d.ts +1 -1
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +1 -1
- package/package.json +1 -2
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/RemoteEntry.tsx +10 -0
- package/src/client/RemoteSettingsCard.tsx +2 -2
- package/src/client/UpdateEntry.tsx +9 -5
- package/src/client/UpdatePanel.tsx +12 -1
- package/src/client/locales.ts +8 -4
- package/src/client/pair-api.ts +1 -10
- package/src/client/settings-card.module.css +90 -1
- package/src/gate.ts +2 -0
- package/src/mobile/api.ts +14 -1
- package/src/mobile/messages.test.ts +52 -1
- package/src/mobile/messages.ts +65 -22
- package/src/mobile/mobile-styles.ts +38 -0
- package/src/mobile/mobile-theme.test.ts +1 -1
- package/src/mobile/views/ChatView.tsx +33 -10
- package/src/mobile/views/SessionListView.test.tsx +69 -1
- package/src/mobile/views/SessionListView.tsx +58 -5
- package/src/mobile/views/WorkspaceView.tsx +4 -2
- package/src/mobile-api.ts +9 -1
- package/src/routes.ts +22 -2
- package/src/tunnel.ts +7 -2
- package/src/update.ts +2 -2
|
@@ -10,12 +10,14 @@ import { type SessionView } from './App.tsx'
|
|
|
10
10
|
vi.mock('../api.ts', () => ({
|
|
11
11
|
listSessions: vi.fn(),
|
|
12
12
|
listWorkspaces: vi.fn(),
|
|
13
|
+
listAgentPresets: vi.fn(),
|
|
13
14
|
createSession: vi.fn(),
|
|
14
15
|
}))
|
|
15
|
-
import { createSession, listSessions, listWorkspaces } from '../api.ts'
|
|
16
|
+
import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
|
|
16
17
|
|
|
17
18
|
const listSessionsMock = vi.mocked(listSessions)
|
|
18
19
|
const listWorkspacesMock = vi.mocked(listWorkspaces)
|
|
20
|
+
const listAgentPresetsMock = vi.mocked(listAgentPresets)
|
|
19
21
|
const createSessionMock = vi.mocked(createSession)
|
|
20
22
|
|
|
21
23
|
const workspace: WorkspaceRow = {
|
|
@@ -49,6 +51,7 @@ function renderList(props: Partial<SessionListViewProps> = {}): void {
|
|
|
49
51
|
beforeEach(() => {
|
|
50
52
|
listSessionsMock.mockResolvedValue({ items: [], hasMore: false })
|
|
51
53
|
listWorkspacesMock.mockResolvedValue([workspace])
|
|
54
|
+
listAgentPresetsMock.mockResolvedValue({ presets: [], authorable: false, hasDocument: false })
|
|
52
55
|
createSessionMock.mockResolvedValue({ sessionId: 's-new' })
|
|
53
56
|
})
|
|
54
57
|
|
|
@@ -111,6 +114,71 @@ describe('SessionListView creation', () => {
|
|
|
111
114
|
expect(await screen.findByText('新会话')).toBeTruthy()
|
|
112
115
|
})
|
|
113
116
|
|
|
117
|
+
it('creates the session with the selected agent preset', async () => {
|
|
118
|
+
listAgentPresetsMock.mockResolvedValue({
|
|
119
|
+
presets: [
|
|
120
|
+
{ id: 'router-standard', name: 'Router Standard', trust: 'system', isDefault: true },
|
|
121
|
+
{ id: 'router-spec', name: 'Router Spec', description: '规格驱动模式', trust: 'system', isDefault: false },
|
|
122
|
+
],
|
|
123
|
+
authorable: false,
|
|
124
|
+
hasDocument: false,
|
|
125
|
+
})
|
|
126
|
+
renderList()
|
|
127
|
+
|
|
128
|
+
const picker = await screen.findByRole('combobox', { name: 'Agent 模式' })
|
|
129
|
+
expect((picker as HTMLSelectElement).value).toBe('router-standard')
|
|
130
|
+
fireEvent.change(picker, { target: { value: 'router-spec' } })
|
|
131
|
+
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1', agentPreset: 'router-spec' })
|
|
135
|
+
})
|
|
136
|
+
expect(await screen.findByText('规格驱动模式')).toBeTruthy()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('skips a broken default preset and disables it in the picker', async () => {
|
|
140
|
+
listAgentPresetsMock.mockResolvedValue({
|
|
141
|
+
presets: [
|
|
142
|
+
{ id: 'broken-default', trust: 'system', isDefault: true, broken: 'missing plugin' },
|
|
143
|
+
{ id: 'local-router', name: 'Local Router', trust: 'user', isDefault: false },
|
|
144
|
+
],
|
|
145
|
+
authorable: false,
|
|
146
|
+
hasDocument: false,
|
|
147
|
+
})
|
|
148
|
+
renderList()
|
|
149
|
+
|
|
150
|
+
const picker = await screen.findByRole('combobox', { name: 'Agent 模式' }) as HTMLSelectElement
|
|
151
|
+
expect(picker.value).toBe('local-router')
|
|
152
|
+
expect(screen.getByRole('option', { name: 'broken-default(默认)(不可用)' }).hasAttribute('disabled')).toBe(true)
|
|
153
|
+
expect(screen.getByRole('option', { name: 'Local Router(本地)' })).toBeTruthy()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('keeps default session creation available when the preset roster fails', async () => {
|
|
157
|
+
listAgentPresetsMock.mockRejectedValue(new Error('preset roster unavailable'))
|
|
158
|
+
renderList()
|
|
159
|
+
await screen.findByText(/还没有会话/)
|
|
160
|
+
|
|
161
|
+
const button = await screen.findByRole('button', { name: '+ 新建会话' })
|
|
162
|
+
await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
|
|
163
|
+
fireEvent.click(button)
|
|
164
|
+
|
|
165
|
+
await waitFor(() => {
|
|
166
|
+
expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' })
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('waits for the preset roster before enabling session creation', async () => {
|
|
171
|
+
let resolvePresets: (value: Awaited<ReturnType<typeof listAgentPresets>>) => void = () => {}
|
|
172
|
+
listAgentPresetsMock.mockReturnValue(new Promise(resolve => { resolvePresets = resolve }))
|
|
173
|
+
renderList()
|
|
174
|
+
|
|
175
|
+
const button = screen.getByRole('button', { name: '+ 新建会话' })
|
|
176
|
+
expect(button.hasAttribute('disabled')).toBe(true)
|
|
177
|
+
|
|
178
|
+
resolvePresets({ presets: [], authorable: false, hasDocument: false })
|
|
179
|
+
await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
|
|
180
|
+
})
|
|
181
|
+
|
|
114
182
|
it('keeps the button disabled while a creation is in flight', async () => {
|
|
115
183
|
let resolveCreate: (value: { sessionId: string }) => void = () => {}
|
|
116
184
|
createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve }))
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
15
15
|
import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
|
|
16
|
+
import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets'
|
|
16
17
|
import type { SessionSummary } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
17
|
-
import { createSession, listSessions, listWorkspaces } from '../api.ts'
|
|
18
|
+
import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
|
|
18
19
|
import { errorText, formatTime, staleHostHint, toSessionView, type SessionView } from './App.tsx'
|
|
19
20
|
import { ThemeToggle } from '../theme-toggle.tsx'
|
|
20
21
|
|
|
@@ -45,6 +46,9 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
45
46
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
46
47
|
const [creating, setCreating] = useState(false)
|
|
47
48
|
const [createError, setCreateError] = useState<string | undefined>(undefined)
|
|
49
|
+
const [presets, setPresets] = useState<readonly AgentPresetEntry[]>([])
|
|
50
|
+
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined)
|
|
51
|
+
const [presetsLoading, setPresetsLoading] = useState(true)
|
|
48
52
|
const cursorRef = useRef<string | undefined>(undefined)
|
|
49
53
|
const busyRef = useRef(false)
|
|
50
54
|
|
|
@@ -74,6 +78,27 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
74
78
|
return () => { cancelled = true }
|
|
75
79
|
}, [workspace])
|
|
76
80
|
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
let cancelled = false
|
|
83
|
+
setPresets([])
|
|
84
|
+
setSelectedPreset(undefined)
|
|
85
|
+
setPresetsLoading(true)
|
|
86
|
+
void listAgentPresets().then(
|
|
87
|
+
(roster) => {
|
|
88
|
+
if (cancelled) return
|
|
89
|
+
const usable = roster.presets.filter(preset => preset.broken === undefined)
|
|
90
|
+
setPresets(roster.presets)
|
|
91
|
+
setSelectedPreset((usable.find(preset => preset.isDefault) ?? usable[0])?.id)
|
|
92
|
+
setPresetsLoading(false)
|
|
93
|
+
},
|
|
94
|
+
() => {
|
|
95
|
+
if (cancelled) return
|
|
96
|
+
setPresetsLoading(false)
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
return () => { cancelled = true }
|
|
100
|
+
}, [workspace])
|
|
101
|
+
|
|
77
102
|
/** Pull the next page and append the rows that belong to this workspace. */
|
|
78
103
|
const loadMore = useCallback(() => {
|
|
79
104
|
if (busyRef.current) return
|
|
@@ -102,7 +127,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
102
127
|
if (creating) return
|
|
103
128
|
setCreating(true)
|
|
104
129
|
setCreateError(undefined)
|
|
105
|
-
void createSession({
|
|
130
|
+
void createSession({
|
|
131
|
+
workspaceId: workspace.workspaceId,
|
|
132
|
+
...(selectedPreset !== undefined ? { agentPreset: selectedPreset } : {}),
|
|
133
|
+
}).then(
|
|
106
134
|
(created) => {
|
|
107
135
|
setCreating(false)
|
|
108
136
|
const view: SessionView = {
|
|
@@ -120,9 +148,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
120
148
|
setCreateError(errorText(reason))
|
|
121
149
|
},
|
|
122
150
|
)
|
|
123
|
-
}, [creating, workspace, onPick])
|
|
151
|
+
}, [creating, workspace, onPick, selectedPreset])
|
|
124
152
|
|
|
125
153
|
const createHint = createError !== undefined ? staleHostHint(createError) : undefined
|
|
154
|
+
const selectedPresetEntry = presets.find(preset => preset.id === selectedPreset)
|
|
126
155
|
|
|
127
156
|
return (
|
|
128
157
|
<div className="mobile">
|
|
@@ -132,11 +161,35 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
132
161
|
<ThemeToggle />
|
|
133
162
|
</header>
|
|
134
163
|
{error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
|
|
135
|
-
<div className="mobile-pad">
|
|
164
|
+
<div className="mobile-create mobile-pad">
|
|
165
|
+
{presets.length > 0 && (
|
|
166
|
+
<label className="mobile-preset">
|
|
167
|
+
<span className="mobile-presetLabel">Agent 模式</span>
|
|
168
|
+
<select
|
|
169
|
+
className="mobile-presetSelect"
|
|
170
|
+
value={selectedPreset ?? ''}
|
|
171
|
+
disabled={selectedPreset === undefined}
|
|
172
|
+
onChange={(event) => { setSelectedPreset(event.target.value) }}
|
|
173
|
+
>
|
|
174
|
+
{selectedPreset === undefined && <option value="">无可用 Agent 模式</option>}
|
|
175
|
+
{presets.map(preset => (
|
|
176
|
+
<option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
|
|
177
|
+
{preset.name ?? preset.id}
|
|
178
|
+
{preset.isDefault ? '(默认)' : ''}
|
|
179
|
+
{preset.trust === 'user' ? '(本地)' : ''}
|
|
180
|
+
{preset.broken !== undefined ? '(不可用)' : ''}
|
|
181
|
+
</option>
|
|
182
|
+
))}
|
|
183
|
+
</select>
|
|
184
|
+
</label>
|
|
185
|
+
)}
|
|
186
|
+
{selectedPresetEntry?.description !== undefined && (
|
|
187
|
+
<p className="mobile-presetDescription">{selectedPresetEntry.description}</p>
|
|
188
|
+
)}
|
|
136
189
|
<button
|
|
137
190
|
type="button"
|
|
138
191
|
className="mobile-new"
|
|
139
|
-
disabled={creating}
|
|
192
|
+
disabled={creating || presetsLoading}
|
|
140
193
|
onClick={() => { void handleCreate() }}
|
|
141
194
|
>
|
|
142
195
|
{creating ? '创建中…' : '+ 新建会话'}
|
|
@@ -27,6 +27,8 @@ export interface WorkspaceViewProps {
|
|
|
27
27
|
export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps) {
|
|
28
28
|
const [items, setItems] = useState<WorkspaceRow[] | undefined>(undefined)
|
|
29
29
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
30
|
+
// Bumped by the retry button to re-run the roster fetch effect.
|
|
31
|
+
const [reload, setReload] = useState(0)
|
|
30
32
|
|
|
31
33
|
useEffect(() => {
|
|
32
34
|
let cancelled = false
|
|
@@ -48,7 +50,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
|
|
|
48
50
|
},
|
|
49
51
|
)
|
|
50
52
|
return () => { cancelled = true }
|
|
51
|
-
}, [initialWorkspaceId, onPick])
|
|
53
|
+
}, [initialWorkspaceId, onPick, reload])
|
|
52
54
|
|
|
53
55
|
if (error !== undefined) {
|
|
54
56
|
return (
|
|
@@ -59,7 +61,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
|
|
|
59
61
|
</header>
|
|
60
62
|
<div className="mobile-empty">
|
|
61
63
|
<p className="mobile-error">加载失败:{error}</p>
|
|
62
|
-
<button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined) }}>
|
|
64
|
+
<button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined); setReload(n => n + 1) }}>
|
|
63
65
|
重试
|
|
64
66
|
</button>
|
|
65
67
|
</div>
|
package/src/mobile-api.ts
CHANGED
|
@@ -28,9 +28,16 @@ import type { PairingService } from './pairing.ts'
|
|
|
28
28
|
import { readBoundedJson, writeJson } from './http.ts'
|
|
29
29
|
import { readCookie } from './gate.ts'
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Methods the phone surface may call. Everything else is refused HERE — but
|
|
33
|
+
* note the paired-device cookie also passes the global api/gate for the full
|
|
34
|
+
* ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
|
|
35
|
+
* the allowlist only constrains this /m/api proxy, not the cookie's reach.
|
|
36
|
+
* stop() revokes every device; there is no per-device revocation yet.
|
|
37
|
+
*/
|
|
32
38
|
const MOBILE_ALLOWLIST = new Set([
|
|
33
39
|
'workspace.list',
|
|
40
|
+
'agentPreset.list',
|
|
34
41
|
'session.create',
|
|
35
42
|
'session.list',
|
|
36
43
|
'session.history',
|
|
@@ -292,6 +299,7 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
|
|
|
292
299
|
result: response.result,
|
|
293
300
|
})
|
|
294
301
|
if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
|
|
302
|
+
if (method === 'agentPreset.list') return wrap(await apiProxy.agentPresets.list(request as never))
|
|
295
303
|
if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
|
|
296
304
|
if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
|
|
297
305
|
if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, signal ?? new AbortController().signal))
|
package/src/routes.ts
CHANGED
|
@@ -222,7 +222,16 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
222
222
|
const ACCEPT_MAX_ATTEMPTS = 10
|
|
223
223
|
const ACCEPT_WINDOW_MS = 30_000
|
|
224
224
|
const rateLimitAccept = (req: IncomingMessage): boolean => {
|
|
225
|
-
const
|
|
225
|
+
const socketIp = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
|
|
226
|
+
// Behind the auto-tunnel every internet client arrives from 127.0.0.1,
|
|
227
|
+
// so a single shared bucket would let one attacker keep the legitimate
|
|
228
|
+
// owner rate-limited. Partition the availability bucket by the first
|
|
229
|
+
// client-visible XFF hop (set by the tunnel edge): XFF is untrusted for
|
|
230
|
+
// authentication and only separates buckets, it never grants access.
|
|
231
|
+
const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
|
|
232
|
+
? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
|
|
233
|
+
: undefined
|
|
234
|
+
const ip = forwarded === undefined || forwarded === '' ? socketIp : socketIp + '|' + forwarded
|
|
226
235
|
const nowMs = Date.now()
|
|
227
236
|
// The map lives as long as the plugin: prune expired windows once the
|
|
228
237
|
// table grows past a modest size so distinct source IPs (LAN clients,
|
|
@@ -306,6 +315,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
306
315
|
writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
|
|
307
316
|
return
|
|
308
317
|
}
|
|
318
|
+
// No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
|
|
319
|
+
// work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
|
|
320
|
+
// top-level navigations working while blocking cross-site subrequests.
|
|
309
321
|
res.writeHead(200, {
|
|
310
322
|
'content-type': 'application/json; charset=utf-8',
|
|
311
323
|
'set-cookie': [
|
|
@@ -356,7 +368,15 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
356
368
|
return
|
|
357
369
|
}
|
|
358
370
|
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
359
|
-
|
|
371
|
+
const paired = deviceId !== undefined && service.hasDevice(deviceId)
|
|
372
|
+
const snapshot = service.snapshot()
|
|
373
|
+
// Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
|
|
374
|
+
// token expiry, device roster, and public tunnel URL are an oracle for
|
|
375
|
+
// targeting and timing and stay behind a live device cookie.
|
|
376
|
+
const visible = paired
|
|
377
|
+
? snapshot
|
|
378
|
+
: { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses }
|
|
379
|
+
writeJson(res, 200, { ok: true, paired, ...visible })
|
|
360
380
|
}
|
|
361
381
|
|
|
362
382
|
const handleEvents = (req: IncomingMessage, res: ServerResponse): void => {
|
package/src/tunnel.ts
CHANGED
|
@@ -86,6 +86,9 @@ export class TunnelManager {
|
|
|
86
86
|
private urlTimer: unknown | undefined
|
|
87
87
|
private restartTimer: unknown | undefined
|
|
88
88
|
private attempts = 0
|
|
89
|
+
// Generation counter: a stale ensureBinary resolution from an earlier
|
|
90
|
+
// start() must not spawn a second handle after a stop/start cycle.
|
|
91
|
+
private generation = 0
|
|
89
92
|
private stopping = false
|
|
90
93
|
private readonly urlListeners = new Set<(url: string) => void>()
|
|
91
94
|
private readonly phaseListeners = new Set<(info: TunnelInfo) => void>()
|
|
@@ -123,6 +126,7 @@ export class TunnelManager {
|
|
|
123
126
|
this.stopping = false
|
|
124
127
|
this.targetUrl = targetUrl
|
|
125
128
|
this.attempts = 0
|
|
129
|
+
this.generation += 1
|
|
126
130
|
this.attempt()
|
|
127
131
|
}
|
|
128
132
|
|
|
@@ -153,12 +157,13 @@ export class TunnelManager {
|
|
|
153
157
|
|
|
154
158
|
private attempt(): void {
|
|
155
159
|
if (this.stopping || this.targetUrl === undefined) return
|
|
160
|
+
const gen = this.generation
|
|
156
161
|
this.setPhase('starting')
|
|
157
162
|
this.handle = undefined
|
|
158
163
|
this.url = undefined
|
|
159
164
|
this.error = undefined
|
|
160
165
|
void this.ensureBinary().then(() => {
|
|
161
|
-
if (this.stopping || this.targetUrl === undefined) return
|
|
166
|
+
if (this.stopping || this.targetUrl === undefined || gen !== this.generation) return
|
|
162
167
|
const handle = this.factory(this.targetUrl)
|
|
163
168
|
this.handle = handle
|
|
164
169
|
this.urlTimer = this.timer.setTimeout(() => {
|
|
@@ -181,7 +186,7 @@ export class TunnelManager {
|
|
|
181
186
|
})
|
|
182
187
|
}).catch((value: unknown) => {
|
|
183
188
|
// Binary install failed (no network, no platform build): report it.
|
|
184
|
-
if (this.stopping || this.targetUrl === undefined) return
|
|
189
|
+
if (this.stopping || this.targetUrl === undefined || gen !== this.generation) return
|
|
185
190
|
const message = value instanceof Error ? value.message : String(value)
|
|
186
191
|
this.fail(`could not obtain the cloudflared binary: ${message}`)
|
|
187
192
|
})
|
package/src/update.ts
CHANGED
|
@@ -280,14 +280,14 @@ function familyUpdatePackages(
|
|
|
280
280
|
*/
|
|
281
281
|
export async function fetchLatestVersion(
|
|
282
282
|
name: string,
|
|
283
|
-
fetchImpl: (url: string) => Promise<{ ok: boolean; json(): Promise<unknown> }>,
|
|
283
|
+
fetchImpl: (url: string, init?: RequestInit) => Promise<{ ok: boolean; json(): Promise<unknown> }>,
|
|
284
284
|
timeoutMs = 10_000,
|
|
285
285
|
): Promise<string | undefined> {
|
|
286
286
|
try {
|
|
287
287
|
const controller = new AbortController()
|
|
288
288
|
const timer = setTimeout(() => { controller.abort() }, timeoutMs)
|
|
289
289
|
try {
|
|
290
|
-
const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest')
|
|
290
|
+
const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', { signal: controller.signal })
|
|
291
291
|
if (!response.ok) return undefined
|
|
292
292
|
const body = await response.json()
|
|
293
293
|
if (typeof body !== 'object' || body === null) return undefined
|