@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.
Files changed (118) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +346 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +2817 -0
  5. package/lib/index.js +1455 -0
  6. package/lib/invariant.js +21 -0
  7. package/lib/types/client/PairFailedNotice.d.ts +12 -0
  8. package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
  9. package/lib/types/client/PhoneIcon.d.ts +19 -0
  10. package/lib/types/client/PhoneIcon.d.ts.map +1 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  13. package/lib/types/client/RemoteEntry.d.ts +10 -0
  14. package/lib/types/client/RemoteEntry.d.ts.map +1 -0
  15. package/lib/types/client/RemotePanel.d.ts +50 -0
  16. package/lib/types/client/RemotePanel.d.ts.map +1 -0
  17. package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
  19. package/lib/types/client/deep-link.d.ts +40 -0
  20. package/lib/types/client/deep-link.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +54 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +144 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/pair-api.d.ts +100 -0
  26. package/lib/types/client/pair-api.d.ts.map +1 -0
  27. package/lib/types/client/settings-form.d.ts +119 -0
  28. package/lib/types/client/settings-form.d.ts.map +1 -0
  29. package/lib/types/gate.d.ts +58 -0
  30. package/lib/types/gate.d.ts.map +1 -0
  31. package/lib/types/index.d.ts +78 -0
  32. package/lib/types/index.d.ts.map +1 -0
  33. package/lib/types/invariant.d.ts +16 -0
  34. package/lib/types/invariant.d.ts.map +1 -0
  35. package/lib/types/lan.d.ts +15 -0
  36. package/lib/types/lan.d.ts.map +1 -0
  37. package/lib/types/mobile/api.d.ts +64 -0
  38. package/lib/types/mobile/api.d.ts.map +1 -0
  39. package/lib/types/mobile/index.d.ts +10 -0
  40. package/lib/types/mobile/index.d.ts.map +1 -0
  41. package/lib/types/mobile/messages.d.ts +103 -0
  42. package/lib/types/mobile/messages.d.ts.map +1 -0
  43. package/lib/types/mobile/messages.test.d.ts +2 -0
  44. package/lib/types/mobile/messages.test.d.ts.map +1 -0
  45. package/lib/types/mobile/mobile-styles.d.ts +6 -0
  46. package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
  47. package/lib/types/mobile/mobile-theme.d.ts +22 -0
  48. package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
  49. package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
  50. package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
  51. package/lib/types/mobile/mux.d.ts +52 -0
  52. package/lib/types/mobile/mux.d.ts.map +1 -0
  53. package/lib/types/mobile/rpc.d.ts +38 -0
  54. package/lib/types/mobile/rpc.d.ts.map +1 -0
  55. package/lib/types/mobile/theme-toggle.d.ts +12 -0
  56. package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
  57. package/lib/types/mobile/views/App.d.ts +59 -0
  58. package/lib/types/mobile/views/App.d.ts.map +1 -0
  59. package/lib/types/mobile/views/ChatView.d.ts +39 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
  61. package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
  62. package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
  63. package/lib/types/mobile/views/SessionListView.d.ts +27 -0
  64. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
  65. package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
  66. package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
  67. package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
  68. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
  69. package/lib/types/mobile-api.d.ts +41 -0
  70. package/lib/types/mobile-api.d.ts.map +1 -0
  71. package/lib/types/mobile-routes.d.ts +16 -0
  72. package/lib/types/mobile-routes.d.ts.map +1 -0
  73. package/lib/types/pairing.d.ts +215 -0
  74. package/lib/types/pairing.d.ts.map +1 -0
  75. package/lib/types/routes.d.ts +58 -0
  76. package/lib/types/routes.d.ts.map +1 -0
  77. package/lib/types/tunnel.d.ts +99 -0
  78. package/lib/types/tunnel.d.ts.map +1 -0
  79. package/package.json +104 -0
  80. package/src/client/PairFailedNotice.tsx +33 -0
  81. package/src/client/PhoneIcon.tsx +31 -0
  82. package/src/client/PluginSettingsCard.tsx +207 -0
  83. package/src/client/RemoteEntry.tsx +202 -0
  84. package/src/client/RemotePanel.tsx +186 -0
  85. package/src/client/RemoteSettingsCard.tsx +220 -0
  86. package/src/client/deep-link.ts +137 -0
  87. package/src/client/index.ts +167 -0
  88. package/src/client/locales.ts +149 -0
  89. package/src/client/pair-api.ts +176 -0
  90. package/src/client/remote.module.css +422 -0
  91. package/src/client/settings-card.module.css +296 -0
  92. package/src/client/settings-form.ts +294 -0
  93. package/src/css-modules.d.ts +6 -0
  94. package/src/gate.ts +121 -0
  95. package/src/index.ts +299 -0
  96. package/src/invariant.ts +32 -0
  97. package/src/lan.ts +21 -0
  98. package/src/mobile/api.ts +106 -0
  99. package/src/mobile/index.tsx +27 -0
  100. package/src/mobile/messages.test.ts +222 -0
  101. package/src/mobile/messages.ts +555 -0
  102. package/src/mobile/mobile-styles.ts +853 -0
  103. package/src/mobile/mobile-theme.test.ts +84 -0
  104. package/src/mobile/mobile-theme.ts +76 -0
  105. package/src/mobile/mux.ts +126 -0
  106. package/src/mobile/rpc.ts +90 -0
  107. package/src/mobile/theme-toggle.tsx +47 -0
  108. package/src/mobile/views/App.tsx +152 -0
  109. package/src/mobile/views/ChatView.test.tsx +243 -0
  110. package/src/mobile/views/ChatView.tsx +650 -0
  111. package/src/mobile/views/SessionListView.test.tsx +139 -0
  112. package/src/mobile/views/SessionListView.tsx +186 -0
  113. package/src/mobile/views/WorkspaceView.tsx +108 -0
  114. package/src/mobile-api.ts +271 -0
  115. package/src/mobile-routes.ts +77 -0
  116. package/src/pairing.ts +390 -0
  117. package/src/routes.ts +346 -0
  118. 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
+ }