@linxin666/dsh-remote-web-ui 0.4.2 → 0.4.4

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 (79) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +12 -10
  3. package/README.zh.md +12 -10
  4. package/lib/client.js +278 -599
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +396 -913
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +8 -9
  9. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/RemoteSettingsCard.js +1 -1
  11. package/lib/types/client/index.d.ts +32 -3
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js +51 -8
  14. package/lib/types/client/locales.d.ts +0 -84
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/client/locales.js +0 -84
  17. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  18. package/lib/types/client/mobile-adapt.js +142 -8
  19. package/lib/types/client/settings-entry-form.d.ts +50 -0
  20. package/lib/types/client/settings-entry-form.d.ts.map +1 -0
  21. package/lib/types/client/settings-entry-form.js +98 -0
  22. package/lib/types/index.d.ts +99 -7
  23. package/lib/types/index.d.ts.map +1 -1
  24. package/lib/types/index.js +150 -149
  25. package/lib/types/lan-bind-plan.d.ts +14 -0
  26. package/lib/types/lan-bind-plan.d.ts.map +1 -1
  27. package/lib/types/lan-bind-plan.js +16 -0
  28. package/lib/types/lan-bind.d.ts +1 -1
  29. package/lib/types/lan-bind.js +1 -1
  30. package/lib/types/mount-once.d.ts +38 -7
  31. package/lib/types/mount-once.d.ts.map +1 -1
  32. package/lib/types/mount-once.js +96 -12
  33. package/lib/types/pairing.d.ts.map +1 -1
  34. package/lib/types/pairing.js +43 -0
  35. package/lib/types/remote-api.d.ts +13 -1
  36. package/lib/types/remote-api.d.ts.map +1 -1
  37. package/lib/types/remote-api.js +14 -1
  38. package/lib/types/tunnel.d.ts +60 -3
  39. package/lib/types/tunnel.d.ts.map +1 -1
  40. package/lib/types/tunnel.js +136 -3
  41. package/package.json +20 -20
  42. package/src/client/RemoteEntry.tsx +7 -9
  43. package/src/client/RemoteSettingsCard.tsx +0 -2
  44. package/src/client/index.ts +53 -9
  45. package/src/client/locales.ts +0 -84
  46. package/src/client/mobile-adapt.ts +144 -5
  47. package/src/client/remote.module.css +50 -200
  48. package/src/client/settings-entry-form.ts +142 -0
  49. package/src/index.ts +202 -164
  50. package/src/lan-bind-plan.ts +22 -0
  51. package/src/lan-bind.ts +1 -1
  52. package/src/mount-once.ts +107 -13
  53. package/src/pairing.ts +44 -1
  54. package/src/remote-api.ts +14 -1
  55. package/src/tunnel.ts +142 -3
  56. package/lib/types/client/UpdateEntry.d.ts +0 -14
  57. package/lib/types/client/UpdateEntry.d.ts.map +0 -1
  58. package/lib/types/client/UpdateEntry.js +0 -112
  59. package/lib/types/client/UpdatePanel.d.ts +0 -42
  60. package/lib/types/client/UpdatePanel.d.ts.map +0 -1
  61. package/lib/types/client/UpdatePanel.js +0 -101
  62. package/lib/types/client/update-api.d.ts +0 -29
  63. package/lib/types/client/update-api.d.ts.map +0 -1
  64. package/lib/types/client/update-api.js +0 -47
  65. package/lib/types/console-output.d.ts +0 -41
  66. package/lib/types/console-output.d.ts.map +0 -1
  67. package/lib/types/console-output.js +0 -89
  68. package/lib/types/update-routes.d.ts +0 -30
  69. package/lib/types/update-routes.d.ts.map +0 -1
  70. package/lib/types/update-routes.js +0 -47
  71. package/lib/types/update.d.ts +0 -282
  72. package/lib/types/update.d.ts.map +0 -1
  73. package/lib/types/update.js +0 -773
  74. package/src/client/UpdateEntry.tsx +0 -144
  75. package/src/client/UpdatePanel.tsx +0 -256
  76. package/src/client/update-api.ts +0 -49
  77. package/src/console-output.ts +0 -97
  78. package/src/update-routes.ts +0 -65
  79. package/src/update.ts +0 -915
package/src/mount-once.ts CHANGED
@@ -5,44 +5,138 @@
5
5
  * the loader accepts a standalone install of the same package side by side;
6
6
  * without this guard the second instance would still re-register the same
7
7
  * webserver routes, tools, settings namespaces, and system-prompt sections
8
- * and fail the boot. mountOnce makes the second host apply a no-op for the
9
- * lifetime of the first instance (the browser half is already deduped by
10
- * package name in the client module host).
8
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
9
+ * no-op for as long as the first instance lives (the browser half is already
10
+ * deduped by package name in the client module host).
11
+ *
12
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
13
+ * long after the refused mount ran its course: the Host reloads a profile by
14
+ * creating the new loader entries before the old ones are torn down (a
15
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
16
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
17
+ * previous entry still owns the name. Dropping that refused mount lost the
18
+ * plugin for good - the previous entry then disposed its own mount, releasing
19
+ * the name with nobody left to take it, and the family row stayed listed as
20
+ * active while its host routes 404ed (the task-board panel showed
21
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
22
+ * restart recovered it).
23
+ *
24
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
25
+ * the holder releases the name - if the waiting fiber is still alive then. The
26
+ * single-instance guarantee is unchanged: exactly one mount is live per
27
+ * package name, and the replay re-enters the guard so a later mount still
28
+ * dedupes against it.
11
29
  *
12
30
  * The registry rides a global symbol so two module instances of the same
13
- * package (npm copy vs repository link) still share one verdict. cordis
14
- * `ctx.effect` runs its callback immediately and treats the callback's
31
+ * package (npm copy vs repository link) still share one verdict. That symbol
32
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
33
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
34
+ * dsh-community-plugins) are separate repositories carrying their own copy of
35
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
36
+ * must keep the shape every published copy reads (a `Set` of package names
37
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
38
+ * under their own additive key, and the registry reads back a `Set` even when
39
+ * some other build left a different value there. Changing `MOUNTED`'s shape
40
+ * in place broke that contract once: a satellite's legacy copy created a
41
+ * `Set`, the family's new copy read it as a `Map`, and every family row
42
+ * mounted after it failed with "claims.get is not a function".
43
+ *
44
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
15
45
  * return value as the fiber disposer, so the unmarker is returned, not run.
16
46
  */
17
47
 
48
+ /** Published cross-repository contract: package names currently mounted. */
18
49
  const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
19
50
 
51
+ /** Additive key this guard owns: refused mounts waiting for the name. */
52
+ const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters')
53
+
54
+ /** The slice of a cordis context this guard touches. */
55
+ interface MountContext {
56
+ effect?: (effect: () => unknown) => unknown
57
+ }
58
+
59
+ /** One mount that was refused while another fiber owned the package name. */
60
+ interface PendingMount {
61
+ /** Replay the refused mount; a no-op once its own fiber has been disposed. */
62
+ run(): void
63
+ }
64
+
20
65
  interface MountRegistry {
21
- [MOUNTED]?: Set<string>
66
+ [MOUNTED]?: unknown
67
+ [WAITERS]?: Map<string, PendingMount[]>
22
68
  }
23
69
 
70
+ /**
71
+ * The shared name registry, always a `Set` whatever another build stored here:
72
+ * a foreign value (an interim shape, a hand-written global) must not take every
73
+ * family plugin down with it.
74
+ * @returns the process-wide set of mounted package names.
75
+ */
24
76
  function mountedSet(): Set<string> {
25
77
  const registry = globalThis as MountRegistry
26
- return (registry[MOUNTED] ??= new Set())
78
+ const existing = registry[MOUNTED]
79
+ if (existing instanceof Set) return existing as Set<string>
80
+ const created = new Set<string>()
81
+ registry[MOUNTED] = created
82
+ return created
83
+ }
84
+
85
+ /** Queue per package name for mounts refused while a holder was alive. */
86
+ function mountWaiters(): Map<string, PendingMount[]> {
87
+ const registry = globalThis as MountRegistry
88
+ return (registry[WAITERS] ??= new Map())
27
89
  }
28
90
 
29
91
  /**
30
92
  * Wrap a cordis plugin apply so the package runs at most once per process.
31
- * The first mount registers normally and unmarks when its fiber disposes;
32
- * any later mount of the same package name is a no-op.
93
+ * The first mount registers normally and releases the name when its fiber
94
+ * disposes; a mount refused while that name is held waits for the release and
95
+ * then runs, unless its own fiber disposes first.
33
96
  * @param packageName - npm package identity shared by every install source.
34
97
  * @param fn - the original plugin apply.
35
98
  * @returns an apply of the same shape.
36
99
  */
37
100
  export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
- return ((...args: unknown[]) => {
101
+ const mount = (...args: unknown[]): unknown => {
39
102
  const mounted = mountedSet()
40
- if (mounted.has(packageName)) return
103
+ const ctx = args[0] as MountContext | undefined
104
+ if (mounted.has(packageName)) {
105
+ // Another fiber (the aggregate row, a standalone install, or a copy built
106
+ // from an older revision of this guard) owns the name. Queue instead of
107
+ // dropping: dropping lost the plugin for the rest of the process when the
108
+ // holder was disposed by a reload.
109
+ const waiters = mountWaiters()
110
+ const queue = waiters.get(packageName) ?? []
111
+ let alive = true
112
+ const pending: PendingMount = {
113
+ run: () => {
114
+ if (alive) mount(...args)
115
+ },
116
+ }
117
+ // A fiber that dies while it waits must not be replayed afterwards: its
118
+ // context is disposed and registering effects on it would throw.
119
+ ctx?.effect?.(() => () => {
120
+ alive = false
121
+ const index = queue.indexOf(pending)
122
+ if (index >= 0) queue.splice(index, 1)
123
+ })
124
+ queue.push(pending)
125
+ waiters.set(packageName, queue)
126
+ return
127
+ }
41
128
  mounted.add(packageName)
42
- const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
129
  ctx?.effect?.(() => () => {
44
130
  mounted.delete(packageName)
131
+ const waiters = mountWaiters()
132
+ const queue = waiters.get(packageName)
133
+ if (queue === undefined) return
134
+ waiters.delete(packageName)
135
+ // Defer the handover one microtask so the disposing mount's own effects
136
+ // (routes, ledger, timers) are torn down before the next one registers.
137
+ for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() })
45
138
  })
46
139
  return fn(...args)
47
- }) as T
140
+ }
141
+ return mount as T
48
142
  }
package/src/pairing.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  import { randomBytes } from 'node:crypto'
25
25
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
26
26
  import { dirname } from 'node:path'
27
- import type { PostureSnapshot } from './posture.ts'
27
+ import type { PostureHost, PostureSnapshot } from './posture.ts'
28
28
 
29
29
  /** The observable pairing phases the panel renders. */
30
30
  export type PairingPhase =
@@ -663,6 +663,7 @@ function snapshotsEqual(a: PairingSnapshot, b: PairingSnapshot): boolean {
663
663
  && a.publicUrl === b.publicUrl
664
664
  && tunnelEqual(a.tunnel, b.tunnel)
665
665
  && relayEqual(a.relay, b.relay)
666
+ && postureEqual(a.posture, b.posture)
666
667
  && a.tokenId === b.tokenId
667
668
  && a.tokenExpiresAt === b.tokenExpiresAt
668
669
  && a.deviceCount === b.deviceCount
@@ -695,6 +696,48 @@ function relayEqual(a: RelayStatus | undefined, b: RelayStatus | undefined): boo
695
696
  && a.state === b.state && a.url === b.url && a.error === b.error)
696
697
  }
697
698
 
699
+ /**
700
+ * Posture equality. A probe round can change this frame while every other field
701
+ * stays put — the common case, since the probe re-runs on a settings change and
702
+ * on a tunnel reaching running, and a quick-tunnel restart churns the probed
703
+ * host set. Leaving the frame out of the comparison suppressed those emits, so
704
+ * the loopback SSE stream and the desktop panel could sit on a stale "exposed"
705
+ * (or stale "clean") verdict for as long as the phase and roster stayed put.
706
+ */
707
+ function postureEqual(a: PostureSnapshot | undefined, b: PostureSnapshot | undefined): boolean {
708
+ if (a === b) return true
709
+ // A frame that is not an object at all (a hand-built context can pass
710
+ // anything) counts as changed instead of throwing out of the setter.
711
+ if (!isPostureFrame(a) || !isPostureFrame(b)) return false
712
+ return a.checkedAt === b.checkedAt && hostsEqual(a.hosts, b.hosts)
713
+ }
714
+
715
+ /** Whether a value has the shape a posture frame is compared on. */
716
+ function isPostureFrame(value: unknown): value is PostureSnapshot {
717
+ return typeof value === 'object' && value !== null
718
+ }
719
+
720
+ /**
721
+ * Per-host posture equality (a round's verdicts, in probe order). Tolerates a
722
+ * frame that is not the expected shape: this runs inside the emit path, outside
723
+ * the listener error containment, so a malformed snapshot handed in by another
724
+ * host-fiber caller must degrade to "changed" instead of throwing out of the
725
+ * setter that produced it.
726
+ */
727
+ function hostsEqual(a: readonly PostureHost[] | undefined, b: readonly PostureHost[] | undefined): boolean {
728
+ if (a === b) return true
729
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
730
+ return a.length === b.length && a.every((host, index) => {
731
+ const other = b[index]
732
+ return isPostureHost(host) && isPostureHost(other) && host.host === other.host && host.exposed === other.exposed
733
+ })
734
+ }
735
+
736
+ /** Whether a value has the shape one probed host is compared on. */
737
+ function isPostureHost(value: unknown): value is PostureHost {
738
+ return typeof value === 'object' && value !== null
739
+ }
740
+
698
741
  /** Element-wise string list equality (interface order is meaningful). */
699
742
  function sameStrings(a: readonly string[], b: readonly string[]): boolean {
700
743
  return a.length === b.length && a.every((value, index) => value === b[index])
package/src/remote-api.ts CHANGED
@@ -182,7 +182,19 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
182
182
  }
183
183
 
184
184
  /**
185
- * Map one outer upgrade URL onto the loopback path (query string included).
185
+ * Map one outer upgrade URL onto the loopback path, dropping the cookieless
186
+ * device credential on the way in.
187
+ *
188
+ * The outer handshake carries the device id as `?device=<id>` because WebSocket
189
+ * handshakes cannot carry headers from the Web API. That id is a live session
190
+ * credential — it opens the full host API through this same channel — and the
191
+ * inner leg is a request the rest of the loopback origin sees (on a WebSocket
192
+ * leg, a raw handshake request line written to a loopback socket), so forwarding
193
+ * it handed a working credential to every other observer of the local port for
194
+ * no benefit: the gate above has already authenticated the caller, and no
195
+ * upstream route reads the parameter (the gateway mux matches on path alone).
196
+ * The rest of the query rides through unchanged, so a caller's own parameters
197
+ * still reach the upstream.
186
198
  */
187
199
  export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: string): string {
188
200
  if (reqUrl === undefined || reqUrl === '') return fallbackPath
@@ -194,6 +206,7 @@ export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: strin
194
206
  }
195
207
  const inner = innerPathOf(url.pathname)
196
208
  if (inner === undefined) return fallbackPath
209
+ url.searchParams.delete(REMOTE_DEVICE_QUERY)
197
210
  return `${inner}${url.search}`
198
211
  }
199
212
 
package/src/tunnel.ts CHANGED
@@ -12,9 +12,16 @@
12
12
  *
13
13
  * The cloudflared package's Tunnel is a thin spawn wrapper; this manager
14
14
  * owns the lifecycle policy (binary readiness, URL timeout, restart
15
- * backoff) around it. All seams — the tunnel factory, binary readiness,
16
- * timers — are injectable so the whole lifecycle is unit-testable without
17
- * a real binary or network.
15
+ * backoff, and the public-URL readiness watchdog) around it. All seams — the
16
+ * tunnel factory, binary readiness, the URL probe, timers — are injectable
17
+ * so the whole lifecycle is unit-testable without a real binary or network.
18
+ *
19
+ * 'running' is not a terminal state: the connector can lose its edge
20
+ * registration while the process keeps living, which leaves a hostname that
21
+ * no longer resolves. The watchdog below probes the public URL of a running
22
+ * tunnel and reuses the crash-restart path when it stops answering, so the
23
+ * relay is re-registered against a freshly minted URL instead of proxying a
24
+ * dead one (issue #1723).
18
25
  */
19
26
 
20
27
  import { existsSync } from 'node:fs'
@@ -108,6 +115,45 @@ export interface TunnelHandle {
108
115
  stop(): boolean
109
116
  }
110
117
 
118
+ /**
119
+ * Probe one public tunnel URL from the host. Resolves true when the URL is
120
+ * served (any HTTP answer below 500 — a harness without credentials answers
121
+ * 401/403, which is a healthy round trip), false for a DNS failure, a refused
122
+ * connection, a timeout, or a Cloudflare 5xx (530 "Origin DNS error" / 1033)
123
+ * — the shapes a tunnel that lost its edge registration produces.
124
+ *
125
+ * The probe must never reject: the watchdog reads a rejection as a dead
126
+ * tunnel and restarts on it, so a local programming error would otherwise
127
+ * recycle working tunnels forever.
128
+ * @param url - the tunnel's public URL.
129
+ * @param timeoutMs - how long one probe may take.
130
+ * @returns whether the URL answered as a live origin.
131
+ */
132
+ export async function probeTunnelUrl(url: string, timeoutMs: number): Promise<boolean> {
133
+ const controller = new AbortController()
134
+ const timer = setTimeout(() => { controller.abort() }, timeoutMs)
135
+ try {
136
+ const response = await fetch(url, {
137
+ method: 'GET',
138
+ redirect: 'manual',
139
+ cache: 'no-store',
140
+ signal: controller.signal,
141
+ })
142
+ // Drain so the connection is reusable instead of half-read.
143
+ await response.body?.cancel().catch(() => undefined)
144
+ return response.status < 500
145
+ } catch {
146
+ return false
147
+ } finally {
148
+ clearTimeout(timer)
149
+ }
150
+ }
151
+
152
+ /** Default probe: the real network round trip. */
153
+ async function defaultProbe(url: string, timeoutMs: number): Promise<boolean> {
154
+ return await probeTunnelUrl(url, timeoutMs)
155
+ }
156
+
111
157
  /** Injectable seams (defaults are the real cloudflared package + node timers). */
112
158
  export interface TunnelManagerOptions {
113
159
  /** Spawn one tunnel process for the target (quick or named). */
@@ -122,6 +168,14 @@ export interface TunnelManagerOptions {
122
168
  restartMaxMs?: number
123
169
  /** Timer source (injected in tests). */
124
170
  timer?: { setTimeout(fn: () => void, ms: number): unknown; clearTimeout(t: unknown): void }
171
+ /** Probe one public URL; false means the tunnel stopped answering. */
172
+ probe?: (url: string, timeoutMs: number) => Promise<boolean>
173
+ /** How often to probe the public URL of a running tunnel (0 disables it). */
174
+ healthCheckIntervalMs?: number
175
+ /** Consecutive probe failures after which the tunnel is restarted. */
176
+ healthCheckFailures?: number
177
+ /** How long one probe may take. */
178
+ probeTimeoutMs?: number
125
179
  }
126
180
 
127
181
  /** Time box for the `--version` probe that validates a staged binary. */
@@ -195,6 +249,13 @@ export function createBinaryReadiness(executable: string, seams: BinaryReadiness
195
249
  /** How many archive downloads one process may attempt before giving up. */
196
250
  export const MAX_BINARY_INSTALL_ATTEMPTS = 3
197
251
 
252
+ /** How often a running tunnel's public URL is probed (issue #1723). */
253
+ export const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 60_000
254
+ /** Consecutive failed probes before the tunnel is declared dead. */
255
+ export const DEFAULT_HEALTH_CHECK_FAILURES = 2
256
+ /** How long one public-URL probe may take. */
257
+ export const DEFAULT_PROBE_TIMEOUT_MS = 10_000
258
+
198
259
  /** The readiness policy the manager uses by default: the package's real binary path. */
199
260
  const defaultBinaryReadiness = createBinaryReadiness(bin)
200
261
 
@@ -289,6 +350,10 @@ export class TunnelManager {
289
350
  private readonly restartBaseMs: number
290
351
  private readonly restartMaxMs: number
291
352
  private readonly timer: { setTimeout(fn: () => void, ms: number): unknown; clearTimeout(t: unknown): void }
353
+ private readonly probe: (url: string, timeoutMs: number) => Promise<boolean>
354
+ private readonly healthCheckIntervalMs: number
355
+ private readonly healthCheckFailures: number
356
+ private readonly probeTimeoutMs: number
292
357
 
293
358
  private phase: TunnelPhase = 'stopped'
294
359
  private url: string | undefined
@@ -297,6 +362,8 @@ export class TunnelManager {
297
362
  private handle: TunnelHandle | undefined
298
363
  private urlTimer: unknown | undefined
299
364
  private restartTimer: unknown | undefined
365
+ private healthTimer: unknown | undefined
366
+ private healthFailures = 0
300
367
  private attempts = 0
301
368
  // Generation counter: a stale ensureBinary resolution from an earlier
302
369
  // start() must not spawn a second handle after a stop/start cycle.
@@ -315,6 +382,10 @@ export class TunnelManager {
315
382
  this.restartBaseMs = options.restartBaseMs ?? 5_000
316
383
  this.restartMaxMs = options.restartMaxMs ?? 60_000
317
384
  this.timer = options.timer ?? nodeTimer
385
+ this.probe = options.probe ?? defaultProbe
386
+ this.healthCheckIntervalMs = options.healthCheckIntervalMs ?? DEFAULT_HEALTH_CHECK_INTERVAL_MS
387
+ this.healthCheckFailures = options.healthCheckFailures ?? DEFAULT_HEALTH_CHECK_FAILURES
388
+ this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
318
389
  }
319
390
 
320
391
  /** The current status frame. */
@@ -418,6 +489,9 @@ export class TunnelManager {
418
489
  this.error = undefined
419
490
  this.attempts = 0
420
491
  this.setPhase('running')
492
+ // The URL is out; from here the connector can still lose its edge
493
+ // registration without the process exiting, so readiness is supervised.
494
+ this.startHealthWatch(value)
421
495
  for (const listener of this.urlListeners) {
422
496
  try {
423
497
  listener(value)
@@ -427,6 +501,69 @@ export class TunnelManager {
427
501
  }
428
502
  }
429
503
 
504
+ /**
505
+ * Watch the readiness of a running tunnel. cloudflared can keep its process
506
+ * (and its metrics port) alive while every edge connection is gone, leaving
507
+ * a minted hostname that no longer resolves: the manager used to report
508
+ * 'running' forever and the relay kept forwarding to that dead address, so
509
+ * the phone hit Cloudflare 1016 instead of the relay's offline page
510
+ * (issue #1723). A bounded number of consecutive probe failures ends the
511
+ * attempt through the ordinary fail/backoff path, which mints a new URL and
512
+ * re-announces the relay.
513
+ */
514
+ private startHealthWatch(url: string): void {
515
+ this.stopHealthWatch()
516
+ // A phase listener may have stopped the tunnel or replaced its target
517
+ // while this URL was being announced; only a live running phase schedules.
518
+ if (this.stopping || this.phase !== 'running') return
519
+ if (this.healthCheckIntervalMs <= 0 || this.healthCheckFailures <= 0) return
520
+ this.healthFailures = 0
521
+ const schedule = (): void => {
522
+ this.healthTimer = this.timer.setTimeout(() => {
523
+ this.healthTimer = undefined
524
+ void this.healthCheck(url)
525
+ }, this.healthCheckIntervalMs)
526
+ }
527
+ schedule()
528
+ }
529
+
530
+ /** One probe round of the running tunnel's public URL. */
531
+ private async healthCheck(url: string): Promise<void> {
532
+ // The tunnel may have been stopped, failed, or replaced while the probe
533
+ // was in flight; only a still-running tunnel toward this same URL is
534
+ // judged by the result.
535
+ if (this.stopping || this.phase !== 'running' || this.url !== url) return
536
+ let alive = false
537
+ try {
538
+ alive = await this.probe(url, this.probeTimeoutMs)
539
+ } catch {
540
+ alive = false
541
+ }
542
+ if (this.stopping || this.phase !== 'running' || this.url !== url) return
543
+ if (alive) {
544
+ this.healthFailures = 0
545
+ } else {
546
+ this.healthFailures += 1
547
+ if (this.healthFailures >= this.healthCheckFailures) {
548
+ this.fail('the tunnel stopped answering on its public URL')
549
+ return
550
+ }
551
+ }
552
+ this.healthTimer = this.timer.setTimeout(() => {
553
+ this.healthTimer = undefined
554
+ void this.healthCheck(url)
555
+ }, this.healthCheckIntervalMs)
556
+ }
557
+
558
+ /** Cancel the readiness watchdog (teardown, restart, stop). */
559
+ private stopHealthWatch(): void {
560
+ if (this.healthTimer !== undefined) {
561
+ this.timer.clearTimeout(this.healthTimer)
562
+ this.healthTimer = undefined
563
+ }
564
+ this.healthFailures = 0
565
+ }
566
+
430
567
  private handleExit(): void {
431
568
  // The exit handler is detached during teardown, so reaching this point
432
569
  // means an unexpected death: fail the current phase and schedule a retry.
@@ -446,6 +583,7 @@ export class TunnelManager {
446
583
  this.timer.clearTimeout(this.urlTimer)
447
584
  this.urlTimer = undefined
448
585
  }
586
+ this.stopHealthWatch()
449
587
  this.setPhase('failed')
450
588
  this.attempts += 1
451
589
  const delay = Math.min(this.restartBaseMs * 2 ** (this.attempts - 1), this.restartMaxMs)
@@ -458,6 +596,7 @@ export class TunnelManager {
458
596
  /** Stop the current process and cancel every pending timer (no phase change). */
459
597
  private teardown(): void {
460
598
  this.stopping = true
599
+ this.stopHealthWatch()
461
600
  if (this.urlTimer !== undefined) {
462
601
  this.timer.clearTimeout(this.urlTimer)
463
602
  this.urlTimer = undefined
@@ -1,14 +0,0 @@
1
- import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
2
- /** Entry props: the sidebar column state + the standard locale seat. */
3
- export interface UpdateEntryProps {
4
- /** Whether the sidebar renders wide content (false = 56px rail). */
5
- wide: boolean;
6
- t: TranslateNS<'remote'>;
7
- }
8
- /**
9
- * Render the update trigger and panel.
10
- * @param props - column state and locale seat.
11
- * @returns the entry element tree.
12
- */
13
- export declare function UpdateEntry({ wide, t }: UpdateEntryProps): import("react").JSX.Element;
14
- //# sourceMappingURL=UpdateEntry.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"UpdateEntry.d.ts","sourceRoot":"","sources":["../../../src/client/UpdateEntry.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAOnE,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;IACb,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BAkHxD"}
@@ -1,112 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- /**
3
- * The sidebar update seat: the download trigger beside the remote-control
4
- * trigger plus the update panel modal. Owns the flow — probe the registry
5
- * on open, show the result, and let the user start the update from the
6
- * result view (#507), then report the outcome (restart hint on success,
7
- * translated failure on error).
8
- * Component-local state per the client stack rules.
9
- */
10
- import { useCallback, useEffect, useRef, useState } from "react";
11
- import { createPortal } from "react-dom";
12
- import { IconDownloadOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives';
13
- import { fetchUpdateStatus, runUpdate, UpdateStatusError } from "./update-api.js";
14
- import { UpdatePanel } from "./UpdatePanel.js";
15
- import css from "./remote.module.css";
16
- /**
17
- * Render the update trigger and panel.
18
- * @param props - column state and locale seat.
19
- * @returns the entry element tree.
20
- */
21
- export function UpdateEntry({ wide, t }) {
22
- const [open, setOpen] = useState(false);
23
- const [view, setView] = useState({ kind: "checking" });
24
- const [updateAvailable, setUpdateAvailable] = useState(false);
25
- const runToken = useRef(0);
26
- const availabilityToken = useRef(0);
27
- const mounted = useRef(false);
28
- const probeAvailability = useCallback(async () => {
29
- const token = ++availabilityToken.current;
30
- try {
31
- const status = await fetchUpdateStatus();
32
- if (token === availabilityToken.current) {
33
- setUpdateAvailable(status.mode === "npm" && status.outdated);
34
- }
35
- }
36
- catch {
37
- if (token === availabilityToken.current)
38
- setUpdateAvailable(false);
39
- }
40
- }, []);
41
- const check = useCallback(async () => {
42
- const availabilityCheck = ++availabilityToken.current;
43
- setView({ kind: "checking" });
44
- let status;
45
- try {
46
- status = await fetchUpdateStatus();
47
- }
48
- catch (error) {
49
- if (availabilityCheck === availabilityToken.current)
50
- setUpdateAvailable(false);
51
- // HTTP 404: the update route is not mounted — the host process runs an
52
- // older plugin build (client refreshed, host did not). Restarting dsh
53
- // web loads the new plugin; a plain network failure gets the generic
54
- // offline copy instead of a misleading "cannot reach update source".
55
- if (error instanceof UpdateStatusError && error.status === 404) {
56
- setView({ kind: "error", message: t("update.unmounted"), detail: t("update.unmountedDetail") });
57
- return;
58
- }
59
- setView({ kind: "error", message: t("update.offline"), detail: t("update.offlineDetail") });
60
- return;
61
- }
62
- if (availabilityCheck === availabilityToken.current) {
63
- setUpdateAvailable(status.mode === "npm" && status.outdated);
64
- }
65
- if (status.error === "registry-unreachable") {
66
- setView({ kind: "result", status });
67
- return;
68
- }
69
- // Checking never starts an update by itself (#507): the result view
70
- // carries the confirmation button that calls startUpdate.
71
- setView({ kind: "result", status });
72
- }, [t]);
73
- const startUpdate = useCallback(async (status) => {
74
- if (status.mode !== "npm" || !status.outdated)
75
- return;
76
- setView({ kind: "updating", status });
77
- const token = ++runToken.current;
78
- try {
79
- const result = await runUpdate();
80
- if (result.ok && mounted.current)
81
- setUpdateAvailable(false);
82
- if (token !== runToken.current)
83
- return;
84
- setView({ kind: "done", result });
85
- }
86
- catch {
87
- if (token !== runToken.current)
88
- return;
89
- setView({ kind: "error", message: t("update.error"), detail: t("update.offlineDetail") });
90
- }
91
- }, [t]);
92
- const openPanel = useCallback(() => {
93
- setOpen(true);
94
- void check();
95
- }, [check]);
96
- const closePanel = useCallback(() => {
97
- runToken.current++;
98
- setOpen(false);
99
- }, []);
100
- // Check once after mount without opening the panel or starting an update.
101
- useEffect(() => {
102
- mounted.current = true;
103
- void probeAvailability();
104
- return () => {
105
- mounted.current = false;
106
- runToken.current++;
107
- availabilityToken.current++;
108
- };
109
- }, [probeAvailability]);
110
- const updateLabel = updateAvailable ? t("update.availableLabel") : t("update.label");
111
- return (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: css.trigger, "data-wide": wide ? (updateAvailable ? "wide" : undefined) : "rail", "data-update-available": updateAvailable ? "true" : undefined, "aria-label": updateLabel, "aria-expanded": open, title: updateLabel, onClick: openPanel, children: [_jsx(IconDownloadOutlineRegular, { size: wide ? 16 : 18 }), updateAvailable && wide && (_jsx("span", { className: css.updateBadgeText, children: t("update.badge") }))] }), open && createPortal((_jsxs("div", { className: css.overlay, role: "presentation", children: [_jsx("div", { className: css.mask, "aria-hidden": "true", onClick: closePanel }), _jsx(UpdatePanel, { t: t, view: view, onClose: closePanel, onRecheck: () => { void check(); }, onStartUpdate: (status) => { void startUpdate(status); } })] })), document.body)] }));
112
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * The update panel body: check progress, version comparison, the
3
- * auto-update in-flight state, and the outcome (restart hint on success,
4
- * translated failure on error). Pure presentation — all state arrives
5
- * through props from the entry behavior component.
6
- */
7
- import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
8
- import type { UpdateRunResult, UpdateStatus } from '../update.ts';
9
- /** The panel view state, owned by the entry component. */
10
- export type UpdateView = {
11
- kind: 'checking';
12
- } | {
13
- kind: 'result';
14
- status: UpdateStatus;
15
- } | {
16
- kind: 'updating';
17
- status: UpdateStatus;
18
- } | {
19
- kind: 'done';
20
- result: UpdateRunResult;
21
- } | {
22
- kind: 'error';
23
- message: string;
24
- detail?: string;
25
- };
26
- /** Full panel props: copy + view state + actions. */
27
- export interface UpdatePanelProps {
28
- t: TranslateNS<'remote'>;
29
- view: UpdateView;
30
- onClose(): void;
31
- /** Re-run the check from a terminal state. */
32
- onRecheck(): void;
33
- /** Start the update from the result view (the confirmation step, #507). */
34
- onStartUpdate(status: UpdateStatus): void;
35
- }
36
- /**
37
- * Render the update panel.
38
- * @param props - copy, view state, and actions.
39
- * @returns the panel element tree.
40
- */
41
- export declare function UpdatePanel({ t, view, onClose, onRecheck, onStartUpdate }: UpdatePanelProps): import("react").JSX.Element;
42
- //# sourceMappingURL=UpdatePanel.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"UpdatePanel.d.ts","sourceRoot":"","sources":["../../../src/client/UpdatePanel.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAEnE,OAAO,KAAK,EAAsB,eAAe,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGrF,0DAA0D;AAC1D,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE,GACpB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAEvD,qDAAqD;AACrD,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;IACxB,IAAI,EAAE,UAAU,CAAA;IAChB,OAAO,IAAI,IAAI,CAAA;IACf,8CAA8C;IAC9C,SAAS,IAAI,IAAI,CAAA;IACjB,2EAA2E;IAC3E,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAA;CAC1C;AAYD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,EAAE,gBAAgB,+BAiD3F"}