@linxin666/dsh-remote-web-ui 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +346 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +2817 -0
- package/lib/index.js +1455 -0
- package/lib/invariant.js +21 -0
- package/lib/types/client/PairFailedNotice.d.ts +12 -0
- package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
- package/lib/types/client/PhoneIcon.d.ts +19 -0
- package/lib/types/client/PhoneIcon.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/RemoteEntry.d.ts +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -0
- package/lib/types/client/RemotePanel.d.ts +50 -0
- package/lib/types/client/RemotePanel.d.ts.map +1 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
- package/lib/types/client/deep-link.d.ts +40 -0
- package/lib/types/client/deep-link.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +54 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +144 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pair-api.d.ts +100 -0
- package/lib/types/client/pair-api.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/gate.d.ts +58 -0
- package/lib/types/gate.d.ts.map +1 -0
- package/lib/types/index.d.ts +78 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/lan.d.ts +15 -0
- package/lib/types/lan.d.ts.map +1 -0
- package/lib/types/mobile/api.d.ts +64 -0
- package/lib/types/mobile/api.d.ts.map +1 -0
- package/lib/types/mobile/index.d.ts +10 -0
- package/lib/types/mobile/index.d.ts.map +1 -0
- package/lib/types/mobile/messages.d.ts +103 -0
- package/lib/types/mobile/messages.d.ts.map +1 -0
- package/lib/types/mobile/messages.test.d.ts +2 -0
- package/lib/types/mobile/messages.test.d.ts.map +1 -0
- package/lib/types/mobile/mobile-styles.d.ts +6 -0
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.d.ts +22 -0
- package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
- package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.d.ts +52 -0
- package/lib/types/mobile/mux.d.ts.map +1 -0
- package/lib/types/mobile/rpc.d.ts +38 -0
- package/lib/types/mobile/rpc.d.ts.map +1 -0
- package/lib/types/mobile/theme-toggle.d.ts +12 -0
- package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
- package/lib/types/mobile/views/App.d.ts +59 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.d.ts +39 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.d.ts +27 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
- package/lib/types/mobile-api.d.ts +41 -0
- package/lib/types/mobile-api.d.ts.map +1 -0
- package/lib/types/mobile-routes.d.ts +16 -0
- package/lib/types/mobile-routes.d.ts.map +1 -0
- package/lib/types/pairing.d.ts +215 -0
- package/lib/types/pairing.d.ts.map +1 -0
- package/lib/types/routes.d.ts +58 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/tunnel.d.ts +99 -0
- package/lib/types/tunnel.d.ts.map +1 -0
- package/package.json +104 -0
- package/src/client/PairFailedNotice.tsx +33 -0
- package/src/client/PhoneIcon.tsx +31 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/RemoteEntry.tsx +202 -0
- package/src/client/RemotePanel.tsx +186 -0
- package/src/client/RemoteSettingsCard.tsx +220 -0
- package/src/client/deep-link.ts +137 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +149 -0
- package/src/client/pair-api.ts +176 -0
- package/src/client/remote.module.css +422 -0
- package/src/client/settings-card.module.css +296 -0
- package/src/client/settings-form.ts +294 -0
- package/src/css-modules.d.ts +6 -0
- package/src/gate.ts +121 -0
- package/src/index.ts +299 -0
- package/src/invariant.ts +32 -0
- package/src/lan.ts +21 -0
- package/src/mobile/api.ts +106 -0
- package/src/mobile/index.tsx +27 -0
- package/src/mobile/messages.test.ts +222 -0
- package/src/mobile/messages.ts +555 -0
- package/src/mobile/mobile-styles.ts +853 -0
- package/src/mobile/mobile-theme.test.ts +84 -0
- package/src/mobile/mobile-theme.ts +76 -0
- package/src/mobile/mux.ts +126 -0
- package/src/mobile/rpc.ts +90 -0
- package/src/mobile/theme-toggle.tsx +47 -0
- package/src/mobile/views/App.tsx +152 -0
- package/src/mobile/views/ChatView.test.tsx +243 -0
- package/src/mobile/views/ChatView.tsx +650 -0
- package/src/mobile/views/SessionListView.test.tsx +139 -0
- package/src/mobile/views/SessionListView.tsx +186 -0
- package/src/mobile/views/WorkspaceView.tsx +108 -0
- package/src/mobile-api.ts +271 -0
- package/src/mobile-routes.ts +77 -0
- package/src/pairing.ts +390 -0
- package/src/routes.ts +346 -0
- package/src/tunnel.ts +264 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /api/pair route family + the desktop status stream. Exact routes
|
|
3
|
+
* under /api: the webserver matches exact paths before the connection
|
|
4
|
+
* plugin's /api prefix, so these handlers own the full response lifecycle
|
|
5
|
+
* and apply their own trust fence (loopback-only for control endpoints;
|
|
6
|
+
* loopback-or-LAN for the phone-facing accept/heartbeat/status). The
|
|
7
|
+
* cookie set on accept is the device identity the api/gate listener checks
|
|
8
|
+
* on every other /api request.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
12
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
13
|
+
import { UnknownLanAddressError, type PairingService, type PairingSnapshot } from './pairing.ts'
|
|
14
|
+
import { isLoopbackClient, readCookie } from './gate.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Browser-trust fence for the /api/pair routes, mirroring the connection
|
|
18
|
+
* package's internal fence semantics (Host/Origin based, DNS-rebinding and
|
|
19
|
+
* cross-site defense). The connection package no longer exports its trust
|
|
20
|
+
* predicate — the fence for the /api prefix lives inside the connection
|
|
21
|
+
* plugin — so the pairing routes, which must stay reachable from LAN phones
|
|
22
|
+
* ahead of the connection prefix route (exact routes match first), carry
|
|
23
|
+
* their own copy scoped to the literals the QR links advertise.
|
|
24
|
+
* @param request - the node HTTP request.
|
|
25
|
+
* @param trustedHosts - non-loopback authorities this surface serves: exact
|
|
26
|
+
* `host:port`, or port-less `host` matching any port.
|
|
27
|
+
* @returns true when the Host is ours (loopback or trusted) and any attached
|
|
28
|
+
* browser markers are same-origin.
|
|
29
|
+
*/
|
|
30
|
+
function isTrustedApiRequest(request: IncomingMessage, trustedHosts: readonly string[]): boolean {
|
|
31
|
+
const host = request.headers.host
|
|
32
|
+
if (typeof host !== 'string') return false
|
|
33
|
+
let hostUrl: URL
|
|
34
|
+
try {
|
|
35
|
+
hostUrl = new URL(`http://${host}`)
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
const hostname = hostUrl.hostname
|
|
40
|
+
const trusted = isLoopbackClient(request) || trustedHosts.some(entry => {
|
|
41
|
+
// A port-less entry matches the hostname on any port; an exact host:port
|
|
42
|
+
// entry matches that authority verbatim (WHATWG normalization both sides).
|
|
43
|
+
const entryUrl = new URL(`http://${entry}`)
|
|
44
|
+
return entryUrl.port === '' ? entryUrl.hostname === hostname : entryUrl.host === hostUrl.host
|
|
45
|
+
})
|
|
46
|
+
if (!trusted) return false
|
|
47
|
+
// Cross-site fence: an explicit cross-site marker is refused regardless of
|
|
48
|
+
// Origin (modern browsers label the initiator on every fetch).
|
|
49
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
50
|
+
// Origin fence: when a browser attaches an Origin it must be exactly this
|
|
51
|
+
// authority; absent Origin is fine — the Host fence already bound it.
|
|
52
|
+
const origin = request.headers.origin
|
|
53
|
+
if (origin === undefined) return true
|
|
54
|
+
try {
|
|
55
|
+
return new URL(origin).host === hostUrl.host
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Cap on pairing request bodies (tokens and workspace ids are tiny). */
|
|
62
|
+
const MAX_BODY_BYTES = 4096
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
|
|
66
|
+
* from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
|
|
67
|
+
* a malformed config then simply contributes no fence entry (and the panel
|
|
68
|
+
* falls back to LAN-only URLs).
|
|
69
|
+
* @param url - the configured public base URL (or undefined).
|
|
70
|
+
* @returns the `host[:port]` authority the fence should trust.
|
|
71
|
+
*/
|
|
72
|
+
export function publicHostOf(url: string | undefined): string | undefined {
|
|
73
|
+
if (url === undefined) return undefined
|
|
74
|
+
try {
|
|
75
|
+
return new URL(url).host
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Cookie lifetime: one year; revoked sessions die at the gate regardless. */
|
|
82
|
+
const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60
|
|
83
|
+
|
|
84
|
+
/** Route paths (exact matches under /api). */
|
|
85
|
+
export const PAIR_PATHS = {
|
|
86
|
+
issue: '/api/pair/issue',
|
|
87
|
+
accept: '/api/pair/accept',
|
|
88
|
+
stop: '/api/pair/stop',
|
|
89
|
+
heartbeat: '/api/pair/heartbeat',
|
|
90
|
+
status: '/api/pair/status',
|
|
91
|
+
events: '/api/pair/events',
|
|
92
|
+
} as const
|
|
93
|
+
|
|
94
|
+
/** One JSON response. */
|
|
95
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
96
|
+
const payload = JSON.stringify(body)
|
|
97
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
98
|
+
res.end(payload)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Read a request body up to MAX_BODY_BYTES and parse it as JSON. */
|
|
102
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
|
|
103
|
+
const chunks: Buffer[] = []
|
|
104
|
+
let size = 0
|
|
105
|
+
for await (const chunk of req) {
|
|
106
|
+
const buffer = chunk as Buffer
|
|
107
|
+
size += buffer.length
|
|
108
|
+
if (size > MAX_BODY_BYTES) return undefined
|
|
109
|
+
chunks.push(buffer)
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
113
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
114
|
+
} catch {
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One open desktop status stream. */
|
|
120
|
+
interface StatusStream {
|
|
121
|
+
res: ServerResponse
|
|
122
|
+
closed: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The SSE fan-out for desktop panel status. */
|
|
126
|
+
export class PairingEventsStream {
|
|
127
|
+
private readonly streams = new Set<StatusStream>()
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param service - the pairing service whose snapshots are fanned out.
|
|
131
|
+
*/
|
|
132
|
+
constructor(service: PairingService) {
|
|
133
|
+
service.onState((snapshot) => { this.push(snapshot) })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Open one stream; the response is owned to completion. */
|
|
137
|
+
open(req: IncomingMessage, res: ServerResponse): void {
|
|
138
|
+
res.writeHead(200, {
|
|
139
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
140
|
+
'cache-control': 'no-cache',
|
|
141
|
+
connection: 'keep-alive',
|
|
142
|
+
})
|
|
143
|
+
const stream: StatusStream = { res, closed: false }
|
|
144
|
+
this.streams.add(stream)
|
|
145
|
+
const close = (): void => {
|
|
146
|
+
if (stream.closed) return
|
|
147
|
+
stream.closed = true
|
|
148
|
+
this.streams.delete(stream)
|
|
149
|
+
}
|
|
150
|
+
res.on('close', close)
|
|
151
|
+
req.on('close', close)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Push one frame to every open stream (contained per stream). */
|
|
155
|
+
push(snapshot: PairingSnapshot): void {
|
|
156
|
+
const frame = `data: ${JSON.stringify({ type: 'state', ...snapshot })}\n\n`
|
|
157
|
+
for (const stream of this.streams) {
|
|
158
|
+
try {
|
|
159
|
+
stream.res.write(frame)
|
|
160
|
+
} catch {
|
|
161
|
+
stream.closed = true
|
|
162
|
+
this.streams.delete(stream)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Stream count (tests/diagnostics). */
|
|
168
|
+
get size(): number {
|
|
169
|
+
return this.streams.size
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Route-family dependencies (test seam). */
|
|
174
|
+
export interface PairRoutesDeps {
|
|
175
|
+
/** The pairing service. */
|
|
176
|
+
service: PairingService
|
|
177
|
+
/** The LAN IP literals the fence accepts (derived from the bind host). */
|
|
178
|
+
lanAddresses: readonly string[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build the /api/pair route family.
|
|
183
|
+
* @param deps - service + fence inputs.
|
|
184
|
+
* @returns the exact routes to register on webServer.
|
|
185
|
+
*/
|
|
186
|
+
export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
187
|
+
const { service, lanAddresses } = deps
|
|
188
|
+
const events = new PairingEventsStream(service)
|
|
189
|
+
|
|
190
|
+
/** Loopback-only fence: the desktop panel's control endpoints. */
|
|
191
|
+
const loopbackFence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, [])
|
|
192
|
+
/** Phone-facing fence: loopback, the derived LAN literals, or the configured public host. */
|
|
193
|
+
const lanFence = (req: IncomingMessage): boolean => {
|
|
194
|
+
const publicHost = publicHostOf(service.publicBaseUrl)
|
|
195
|
+
return isTrustedApiRequest(req, publicHost === undefined ? lanAddresses : [...lanAddresses, publicHost])
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const requireMethod = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
199
|
+
if (req.method === method) return true
|
|
200
|
+
res.writeHead(405)
|
|
201
|
+
res.end()
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Per-source-IP accept rate limit (brute-force defense in depth). */
|
|
206
|
+
const acceptAttempts = new Map<string, { count: number; windowStart: number }>()
|
|
207
|
+
const ACCEPT_MAX_ATTEMPTS = 10
|
|
208
|
+
const ACCEPT_WINDOW_MS = 30_000
|
|
209
|
+
const rateLimitAccept = (req: IncomingMessage): boolean => {
|
|
210
|
+
const ip = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
|
|
211
|
+
const nowMs = Date.now()
|
|
212
|
+
const entry = acceptAttempts.get(ip)
|
|
213
|
+
if (entry === undefined || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
|
|
214
|
+
acceptAttempts.set(ip, { count: 1, windowStart: nowMs })
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
entry.count += 1
|
|
218
|
+
return entry.count > ACCEPT_MAX_ATTEMPTS
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const handleIssue = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
222
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
223
|
+
if (!loopbackFence(req)) {
|
|
224
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
const body = await readJsonBody(req)
|
|
228
|
+
const workspaceId = body === undefined || typeof body.workspaceId !== 'string' || body.workspaceId === ''
|
|
229
|
+
? undefined
|
|
230
|
+
: body.workspaceId
|
|
231
|
+
const address = body === undefined || typeof body.address !== 'string' || body.address === ''
|
|
232
|
+
? undefined
|
|
233
|
+
: body.address
|
|
234
|
+
try {
|
|
235
|
+
const { token, expiresAt } = service.issue(workspaceId, address)
|
|
236
|
+
// The default base is the public (tunneled) URL when configured — a
|
|
237
|
+
// phone anywhere can reach it — and the first LAN interface otherwise.
|
|
238
|
+
// An explicit address always names a LAN literal.
|
|
239
|
+
const base = address === undefined ? (service.publicBaseUrl ?? service.lanBaseUrl) : service.lanBaseUrlFor(address)
|
|
240
|
+
if (base === undefined) throw new Error('remote-web-ui: base unavailable')
|
|
241
|
+
const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`
|
|
242
|
+
writeJson(res, 200, {
|
|
243
|
+
ok: true,
|
|
244
|
+
url: `${base}/?pair=${token}${workspaceQuery}`,
|
|
245
|
+
token,
|
|
246
|
+
expiresAt,
|
|
247
|
+
// Every constructible base, so a multi-homed panel can switch the
|
|
248
|
+
// advertised network without a second round trip.
|
|
249
|
+
lanAddresses: service.lanAddresses,
|
|
250
|
+
// The configured public base, when present — the panel uses it to
|
|
251
|
+
// label the QR as a public (tunneled) link.
|
|
252
|
+
...(service.publicBaseUrl !== undefined ? { publicBaseUrl: service.publicBaseUrl } : {}),
|
|
253
|
+
})
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// lan-required (no bind) and unknown-address are both configuration
|
|
256
|
+
// mistakes the panel should surface distinctly.
|
|
257
|
+
const unknownAddress = error instanceof UnknownLanAddressError
|
|
258
|
+
writeJson(res, unknownAddress ? 400 : 409, {
|
|
259
|
+
ok: false,
|
|
260
|
+
code: unknownAddress ? 'unknown-address' : 'lan-required',
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const handleAccept = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
266
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
267
|
+
if (!lanFence(req)) {
|
|
268
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (rateLimitAccept(req)) {
|
|
272
|
+
writeJson(res, 429, { ok: false, code: 'rate-limited' })
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const body = await readJsonBody(req)
|
|
276
|
+
const token = typeof body?.token === 'string' ? body.token : ''
|
|
277
|
+
const result = service.accept(token)
|
|
278
|
+
if (!result.ok) {
|
|
279
|
+
writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
res.writeHead(200, {
|
|
283
|
+
'content-type': 'application/json; charset=utf-8',
|
|
284
|
+
'set-cookie': [
|
|
285
|
+
`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
|
|
286
|
+
],
|
|
287
|
+
})
|
|
288
|
+
res.end(JSON.stringify({ ok: true, deviceId: result.deviceId }))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const handleStop = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
292
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
293
|
+
if (!loopbackFence(req)) {
|
|
294
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
await readJsonBody(req)
|
|
298
|
+
service.stop()
|
|
299
|
+
writeJson(res, 200, { ok: true })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const handleHeartbeat = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
303
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
304
|
+
if (!lanFence(req)) {
|
|
305
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
await readJsonBody(req)
|
|
309
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
310
|
+
if (deviceId === undefined || !service.heartbeat(deviceId)) {
|
|
311
|
+
writeJson(res, 401, { ok: false, code: 'unpaired' })
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
writeJson(res, 200, { ok: true })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const handleStatus = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
318
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
319
|
+
if (!lanFence(req)) {
|
|
320
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
324
|
+
writeJson(res, 200, { ok: true, paired: deviceId !== undefined && service.hasDevice(deviceId), ...service.snapshot() })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const handleEvents = (req: IncomingMessage, res: ServerResponse): void => {
|
|
328
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
329
|
+
if (!loopbackFence(req)) {
|
|
330
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
events.open(req, res)
|
|
334
|
+
// Snapshot on open: a late-opening panel converges without history.
|
|
335
|
+
events.push(service.snapshot())
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return [
|
|
339
|
+
{ kind: 'exact', path: PAIR_PATHS.issue, handler: handleIssue },
|
|
340
|
+
{ kind: 'exact', path: PAIR_PATHS.accept, handler: handleAccept },
|
|
341
|
+
{ kind: 'exact', path: PAIR_PATHS.stop, handler: handleStop },
|
|
342
|
+
{ kind: 'exact', path: PAIR_PATHS.heartbeat, handler: handleHeartbeat },
|
|
343
|
+
{ kind: 'exact', path: PAIR_PATHS.status, handler: handleStatus },
|
|
344
|
+
{ kind: 'exact', path: PAIR_PATHS.events, handler: handleEvents },
|
|
345
|
+
]
|
|
346
|
+
}
|
package/src/tunnel.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-tunnel manager: spawns a Cloudflare quick tunnel (`cloudflared
|
|
3
|
+
* tunnel --url <local>`) through the `cloudflared` npm package — its
|
|
4
|
+
* postinstall downloads the platform binary, so no user-side tooling is
|
|
5
|
+
* involved — surfaces the minted `https://xxx.trycloudflare.com` URL, and
|
|
6
|
+
* restarts the process after unexpected exits with exponential backoff.
|
|
7
|
+
*
|
|
8
|
+
* The cloudflared package's Tunnel is a thin spawn wrapper; this manager
|
|
9
|
+
* owns the lifecycle policy (binary readiness, URL timeout, restart
|
|
10
|
+
* backoff) around it. All seams — the tunnel factory, binary readiness,
|
|
11
|
+
* timers — are injectable so the whole lifecycle is unit-testable without
|
|
12
|
+
* a real binary or network.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs'
|
|
16
|
+
import { bin, install, Tunnel } from 'cloudflared'
|
|
17
|
+
|
|
18
|
+
/** The observable tunnel lifecycle the settings/panel surfaces render. */
|
|
19
|
+
export type TunnelPhase = 'stopped' | 'starting' | 'running' | 'failed'
|
|
20
|
+
|
|
21
|
+
/** One tunnel status frame. */
|
|
22
|
+
export interface TunnelInfo {
|
|
23
|
+
phase: TunnelPhase
|
|
24
|
+
/** The minted public URL, once the tunnel reports it. */
|
|
25
|
+
url?: string
|
|
26
|
+
/** Human-readable failure detail (binary install, URL timeout, spawn error). */
|
|
27
|
+
error?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tunnel handle subset this manager uses (the package's Tunnel fits). */
|
|
31
|
+
export interface TunnelHandle {
|
|
32
|
+
on(event: string, listener: (...args: any[]) => void): unknown
|
|
33
|
+
off(event: string, listener: (...args: any[]) => void): unknown
|
|
34
|
+
stop(): boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Injectable seams (defaults are the real cloudflared package + node timers). */
|
|
38
|
+
export interface TunnelManagerOptions {
|
|
39
|
+
/** Spawn one quick tunnel toward the local target URL. */
|
|
40
|
+
factory?: (targetUrl: string) => TunnelHandle
|
|
41
|
+
/** Make sure the cloudflared binary exists, downloading it when absent. */
|
|
42
|
+
ensureBinary?: () => Promise<void>
|
|
43
|
+
/** Wait up to this long for the tunnel URL before failing the attempt. */
|
|
44
|
+
urlTimeoutMs?: number
|
|
45
|
+
/** First restart delay after an unexpected failure (exponential base). */
|
|
46
|
+
restartBaseMs?: number
|
|
47
|
+
/** Cap on the exponential restart delay. */
|
|
48
|
+
restartMaxMs?: number
|
|
49
|
+
/** Timer source (injected in tests). */
|
|
50
|
+
timer?: { setTimeout(fn: () => void, ms: number): unknown; clearTimeout(t: unknown): void }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Default binary readiness: download the platform binary on first use. */
|
|
54
|
+
async function defaultEnsureBinary(): Promise<void> {
|
|
55
|
+
if (existsSync(bin)) return
|
|
56
|
+
await install(bin)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Default factory: the cloudflared package's quick tunnel (no account). */
|
|
60
|
+
function defaultFactory(targetUrl: string): TunnelHandle {
|
|
61
|
+
// `--no-autoupdate`: the binary must never upgrade itself out from under
|
|
62
|
+
// the manager (a self-updated binary would break the pinned lifecycle).
|
|
63
|
+
return Tunnel.quick(targetUrl, { '--no-autoupdate': true })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Node timers. */
|
|
67
|
+
const nodeTimer = { setTimeout, clearTimeout }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Own the lifecycle of one auto-tunnel: start/stop, URL surfacing, and
|
|
71
|
+
* crash-restart backoff.
|
|
72
|
+
*/
|
|
73
|
+
export class TunnelManager {
|
|
74
|
+
private readonly factory: (targetUrl: string) => TunnelHandle
|
|
75
|
+
private readonly ensureBinary: () => Promise<void>
|
|
76
|
+
private readonly urlTimeoutMs: number
|
|
77
|
+
private readonly restartBaseMs: number
|
|
78
|
+
private readonly restartMaxMs: number
|
|
79
|
+
private readonly timer: { setTimeout(fn: () => void, ms: number): unknown; clearTimeout(t: unknown): void }
|
|
80
|
+
|
|
81
|
+
private phase: TunnelPhase = 'stopped'
|
|
82
|
+
private url: string | undefined
|
|
83
|
+
private error: string | undefined
|
|
84
|
+
private targetUrl: string | undefined
|
|
85
|
+
private handle: TunnelHandle | undefined
|
|
86
|
+
private urlTimer: unknown | undefined
|
|
87
|
+
private restartTimer: unknown | undefined
|
|
88
|
+
private attempts = 0
|
|
89
|
+
private stopping = false
|
|
90
|
+
private readonly urlListeners = new Set<(url: string) => void>()
|
|
91
|
+
private readonly phaseListeners = new Set<(info: TunnelInfo) => void>()
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param options - seams; defaults spawn the real quick tunnel.
|
|
95
|
+
*/
|
|
96
|
+
constructor(options: TunnelManagerOptions = {}) {
|
|
97
|
+
this.factory = options.factory ?? defaultFactory
|
|
98
|
+
this.ensureBinary = options.ensureBinary ?? defaultEnsureBinary
|
|
99
|
+
this.urlTimeoutMs = options.urlTimeoutMs ?? 30_000
|
|
100
|
+
this.restartBaseMs = options.restartBaseMs ?? 5_000
|
|
101
|
+
this.restartMaxMs = options.restartMaxMs ?? 60_000
|
|
102
|
+
this.timer = options.timer ?? nodeTimer
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The current status frame. */
|
|
106
|
+
get info(): TunnelInfo {
|
|
107
|
+
return {
|
|
108
|
+
phase: this.phase,
|
|
109
|
+
...(this.url !== undefined ? { url: this.url } : {}),
|
|
110
|
+
...(this.error !== undefined ? { error: this.error } : {}),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Start (or keep) a quick tunnel toward `targetUrl`. Restarting with a
|
|
116
|
+
* different target tears the old tunnel down first; restarting with the
|
|
117
|
+
* same target while running is a no-op.
|
|
118
|
+
* @param targetUrl - the local URL to expose, e.g. `http://127.0.0.1:3080`.
|
|
119
|
+
*/
|
|
120
|
+
start(targetUrl: string): void {
|
|
121
|
+
if (this.targetUrl === targetUrl && (this.phase === 'starting' || this.phase === 'running')) return
|
|
122
|
+
this.teardown()
|
|
123
|
+
this.stopping = false
|
|
124
|
+
this.targetUrl = targetUrl
|
|
125
|
+
this.attempts = 0
|
|
126
|
+
this.attempt()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Stop the tunnel for good: no restarts, no state. */
|
|
130
|
+
stop(): void {
|
|
131
|
+
this.teardown()
|
|
132
|
+
this.stopping = false
|
|
133
|
+
this.targetUrl = undefined
|
|
134
|
+
this.setPhase('stopped')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Alias of {@link stop} for plugin-effect disposal. */
|
|
138
|
+
dispose(): void {
|
|
139
|
+
this.stop()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Subscribe to minted tunnel URLs (fire-and-forget duplicates dropped). */
|
|
143
|
+
onUrl(listener: (url: string) => void): () => void {
|
|
144
|
+
this.urlListeners.add(listener)
|
|
145
|
+
return () => { this.urlListeners.delete(listener) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Subscribe to every phase change. */
|
|
149
|
+
onPhase(listener: (info: TunnelInfo) => void): () => void {
|
|
150
|
+
this.phaseListeners.add(listener)
|
|
151
|
+
return () => { this.phaseListeners.delete(listener) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private attempt(): void {
|
|
155
|
+
if (this.stopping || this.targetUrl === undefined) return
|
|
156
|
+
this.setPhase('starting')
|
|
157
|
+
this.handle = undefined
|
|
158
|
+
this.url = undefined
|
|
159
|
+
this.error = undefined
|
|
160
|
+
void this.ensureBinary().then(() => {
|
|
161
|
+
if (this.stopping || this.targetUrl === undefined) return
|
|
162
|
+
const handle = this.factory(this.targetUrl)
|
|
163
|
+
this.handle = handle
|
|
164
|
+
this.urlTimer = this.timer.setTimeout(() => {
|
|
165
|
+
// The tunnel never reported a URL: kill it and retry with backoff.
|
|
166
|
+
this.fail('timed out waiting for the tunnel URL')
|
|
167
|
+
}, this.urlTimeoutMs)
|
|
168
|
+
handle.on('url', (value: string) => {
|
|
169
|
+
if (this.handle !== handle) return
|
|
170
|
+
this.handleUrl(value)
|
|
171
|
+
})
|
|
172
|
+
handle.on('exit', () => {
|
|
173
|
+
if (this.handle !== handle) return
|
|
174
|
+
this.handleExit()
|
|
175
|
+
})
|
|
176
|
+
handle.on('error', (value: unknown) => {
|
|
177
|
+
// Spawn/connection errors usually precede an exit; the exit path owns
|
|
178
|
+
// restart, this only records diagnostics while the process still lives.
|
|
179
|
+
if (this.handle !== handle || this.phase !== 'starting') return
|
|
180
|
+
this.error = value instanceof Error ? value.message : String(value)
|
|
181
|
+
})
|
|
182
|
+
}).catch((value: unknown) => {
|
|
183
|
+
// Binary install failed (no network, no platform build): report it.
|
|
184
|
+
if (this.stopping || this.targetUrl === undefined) return
|
|
185
|
+
const message = value instanceof Error ? value.message : String(value)
|
|
186
|
+
this.fail(`could not obtain the cloudflared binary: ${message}`)
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private handleUrl(value: string): void {
|
|
191
|
+
if (this.urlTimer !== undefined) {
|
|
192
|
+
this.timer.clearTimeout(this.urlTimer)
|
|
193
|
+
this.urlTimer = undefined
|
|
194
|
+
}
|
|
195
|
+
this.url = value
|
|
196
|
+
this.error = undefined
|
|
197
|
+
this.attempts = 0
|
|
198
|
+
this.setPhase('running')
|
|
199
|
+
for (const listener of this.urlListeners) {
|
|
200
|
+
try {
|
|
201
|
+
listener(value)
|
|
202
|
+
} catch {
|
|
203
|
+
// A throwing subscriber must not break the emit loop.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private handleExit(): void {
|
|
209
|
+
// The exit handler is detached during teardown, so reaching this point
|
|
210
|
+
// means an unexpected death: fail the current phase and schedule a retry.
|
|
211
|
+
if (this.stopping) return
|
|
212
|
+
this.fail('the tunnel process exited unexpectedly')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private fail(message: string): void {
|
|
216
|
+
if (this.stopping) return
|
|
217
|
+
this.url = undefined
|
|
218
|
+
this.error = message
|
|
219
|
+
if (this.handle !== undefined) {
|
|
220
|
+
this.handle.stop()
|
|
221
|
+
this.handle = undefined
|
|
222
|
+
}
|
|
223
|
+
if (this.urlTimer !== undefined) {
|
|
224
|
+
this.timer.clearTimeout(this.urlTimer)
|
|
225
|
+
this.urlTimer = undefined
|
|
226
|
+
}
|
|
227
|
+
this.setPhase('failed')
|
|
228
|
+
this.attempts += 1
|
|
229
|
+
const delay = Math.min(this.restartBaseMs * 2 ** (this.attempts - 1), this.restartMaxMs)
|
|
230
|
+
this.restartTimer = this.timer.setTimeout(() => {
|
|
231
|
+
this.restartTimer = undefined
|
|
232
|
+
this.attempt()
|
|
233
|
+
}, delay)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Stop the current process and cancel every pending timer (no phase change). */
|
|
237
|
+
private teardown(): void {
|
|
238
|
+
this.stopping = true
|
|
239
|
+
if (this.urlTimer !== undefined) {
|
|
240
|
+
this.timer.clearTimeout(this.urlTimer)
|
|
241
|
+
this.urlTimer = undefined
|
|
242
|
+
}
|
|
243
|
+
if (this.restartTimer !== undefined) {
|
|
244
|
+
this.timer.clearTimeout(this.restartTimer)
|
|
245
|
+
this.restartTimer = undefined
|
|
246
|
+
}
|
|
247
|
+
if (this.handle !== undefined) {
|
|
248
|
+
this.handle.stop()
|
|
249
|
+
this.handle = undefined
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private setPhase(phase: TunnelPhase): void {
|
|
254
|
+
this.phase = phase
|
|
255
|
+
const info = this.info
|
|
256
|
+
for (const listener of this.phaseListeners) {
|
|
257
|
+
try {
|
|
258
|
+
listener(info)
|
|
259
|
+
} catch {
|
|
260
|
+
// A throwing subscriber must not break the emit loop.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|