@linxin666/dsh-remote-web-ui 0.3.12 → 0.3.14

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 (65) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +26 -6
  3. package/README.zh.md +26 -6
  4. package/lib/client.js +77 -12
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +412 -52
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +1 -0
  9. package/lib/types/client/RemotePanel.d.ts +3 -1
  10. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  11. package/lib/types/client/RemotePanel.js +1 -1
  12. package/lib/types/client/RemoteSettingsCard.d.ts +16 -0
  13. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/RemoteSettingsCard.js +6 -2
  15. package/lib/types/client/locales.d.ts +12 -0
  16. package/lib/types/client/locales.d.ts.map +1 -1
  17. package/lib/types/client/locales.js +16 -4
  18. package/lib/types/client/pair-api.d.ts +10 -0
  19. package/lib/types/client/pair-api.d.ts.map +1 -1
  20. package/lib/types/client/remote-channel.d.ts +0 -2
  21. package/lib/types/client/remote-channel.d.ts.map +1 -1
  22. package/lib/types/client/remote-channel.js +0 -4
  23. package/lib/types/index.d.ts +28 -2
  24. package/lib/types/index.d.ts.map +1 -1
  25. package/lib/types/index.js +138 -23
  26. package/lib/types/pairing.d.ts +17 -0
  27. package/lib/types/pairing.d.ts.map +1 -1
  28. package/lib/types/pairing.js +13 -0
  29. package/lib/types/relay-registry.d.ts +97 -0
  30. package/lib/types/relay-registry.d.ts.map +1 -0
  31. package/lib/types/relay-registry.js +191 -0
  32. package/lib/types/remote-api.d.ts +1 -1
  33. package/lib/types/remote-api.d.ts.map +1 -1
  34. package/lib/types/remote-api.js +1 -1
  35. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  36. package/lib/types/remote-channel-boot.js +0 -1
  37. package/lib/types/remote-channel-rules.d.ts +0 -1
  38. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  39. package/lib/types/remote-channel-rules.js +0 -1
  40. package/lib/types/remote-methods.d.ts +1 -4
  41. package/lib/types/remote-methods.d.ts.map +1 -1
  42. package/lib/types/remote-methods.js +1 -5
  43. package/lib/types/tunnel-plan.d.ts +36 -0
  44. package/lib/types/tunnel-plan.d.ts.map +1 -0
  45. package/lib/types/tunnel-plan.js +43 -0
  46. package/lib/types/tunnel.d.ts +66 -13
  47. package/lib/types/tunnel.d.ts.map +1 -1
  48. package/lib/types/tunnel.js +93 -21
  49. package/package.json +18 -18
  50. package/src/client/RemoteEntry.tsx +2 -1
  51. package/src/client/RemotePanel.tsx +11 -0
  52. package/src/client/RemoteSettingsCard.tsx +43 -1
  53. package/src/client/locales.ts +16 -4
  54. package/src/client/pair-api.ts +11 -0
  55. package/src/client/remote-channel.ts +0 -3
  56. package/src/client/settings-card.module.css +14 -8
  57. package/src/index.ts +153 -25
  58. package/src/pairing.ts +30 -0
  59. package/src/relay-registry.ts +232 -0
  60. package/src/remote-api.ts +0 -1
  61. package/src/remote-channel-boot.ts +0 -1
  62. package/src/remote-channel-rules.ts +0 -2
  63. package/src/remote-methods.ts +1 -6
  64. package/src/tunnel-plan.ts +51 -0
  65. package/src/tunnel.ts +120 -24
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Relay registry client: a stable `https://<id>.dsh-market.com` origin in
3
+ * front of the plugin-managed quick tunnel, so a paired phone keeps one
4
+ * bookmark and one cookie context across `dsh web` restarts.
5
+ *
6
+ * The plugin mints an id + secret on first run and persists them per profile
7
+ * under `$DSH_HOME/remote-web-ui-registry/`; every tunnel (re)start
8
+ * re-registers the current quick URL with the market worker. Registration is
9
+ * best-effort: a registry outage never blocks the tunnel — the QR just falls
10
+ * back to the raw quick URL for that session (the pre-relay behavior).
11
+ */
12
+
13
+ import { randomBytes } from 'node:crypto'
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { dshHome } from './dsh-home.ts'
17
+
18
+ /** The market worker's registration endpoints (dsh-market.com edge API). */
19
+ export const RELAY_REGISTER_URL = 'https://dsh-market.com/api/relay/register'
20
+ export const RELAY_UNREGISTER_URL = 'https://dsh-market.com/api/relay/unregister'
21
+ /**
22
+ * The public origin template: one stable single-label subdomain per
23
+ * registration id. Single label on purpose — Universal SSL covers exactly
24
+ * one subdomain level, so a two-level hostname would get no certificate.
25
+ */
26
+ export const RELAY_BASE_SUFFIX = '.dsh-market.com'
27
+
28
+ export const RELAY_ID_RE = /^[a-z0-9]{16}$/
29
+ export const RELAY_SECRET_RE = /^[A-Za-z0-9_-]{43}$/
30
+
31
+ /** Persisted per-profile relay identity. */
32
+ export interface RelayIdentity {
33
+ id: string
34
+ secret: string
35
+ /**
36
+ * True only for an identity minted in this process that has never been
37
+ * accepted by the registry: the first register carries `new_secret` so
38
+ * the endpoint may create the row; refreshes authenticate with `secret`
39
+ * alone and must not re-claim an id that already exists.
40
+ */
41
+ fresh?: boolean
42
+ }
43
+
44
+ /** The observable relay lifecycle the settings card renders. */
45
+ export type RelayState =
46
+ | { state: 'off' }
47
+ | { state: 'registering' }
48
+ | { state: 'running'; url: string }
49
+ | { state: 'failed'; error: string }
50
+
51
+ /** Registry storage layout: one file per profile under the relay directory. */
52
+ export function relayIdentityFile(profile: string, home: string = dshHome()): string {
53
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile)) {
54
+ throw new Error(`relay registry: profile ${JSON.stringify(profile)} is not a safe path segment`)
55
+ }
56
+ return join(home, 'remote-web-ui-registry', `${profile}.json`)
57
+ }
58
+
59
+ /** Generate one identity pair (id: 8-byte hex slug; secret: 32-byte base64url). */
60
+ export function generateRelayIdentity(random: (size: number) => Buffer = randomBytes): RelayIdentity {
61
+ return {
62
+ id: random(8).toString('hex'),
63
+ secret: random(32).toString('base64url'),
64
+ fresh: true,
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Load (or lazily mint) the per-profile relay identity. The file is written
70
+ * owner-only on first mint; a corrupt file is reminted (the phone then
71
+ * re-pairs once — the same cost as losing the devices file).
72
+ */
73
+ export function loadRelayIdentity(profile: string, home: string = dshHome(), random: (size: number) => Buffer = randomBytes): RelayIdentity {
74
+ const file = relayIdentityFile(profile, home)
75
+ try {
76
+ if (existsSync(file)) {
77
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<RelayIdentity>
78
+ if (typeof parsed.id === 'string' && RELAY_ID_RE.test(parsed.id)
79
+ && typeof parsed.secret === 'string' && RELAY_SECRET_RE.test(parsed.secret)) {
80
+ // A persisted identity has certainly been registered before.
81
+ return { id: parsed.id, secret: parsed.secret }
82
+ }
83
+ console.warn(`relay registry: ${file} is malformed — reminting the identity (paired devices must scan the QR once more)`)
84
+ }
85
+ } catch (error) {
86
+ console.warn(`relay registry: could not read ${file} (${error instanceof Error ? error.message : String(error)}) — reminting`)
87
+ }
88
+ const identity = generateRelayIdentity(random)
89
+ mkdirSync(dirname(file), { recursive: true })
90
+ writeFileSync(file, JSON.stringify({ id: identity.id, secret: identity.secret }, null, 2) + '\n', { mode: 0o600 })
91
+ return identity
92
+ }
93
+
94
+ /** Build the registration request body for one identity + tunnel target. */
95
+ function registrationBody(identity: RelayIdentity, target: string): string {
96
+ return JSON.stringify({
97
+ id: identity.id,
98
+ secret: identity.secret,
99
+ new_secret: identity.fresh === true ? identity.secret : undefined,
100
+ target,
101
+ })
102
+ }
103
+
104
+ /** Best-effort removal of the mapping (used on disable; errors are swallowed). */
105
+ export async function unregisterRelay(
106
+ identity: RelayIdentity,
107
+ endpoint: string = RELAY_UNREGISTER_URL,
108
+ fetchFn: typeof fetch = fetch,
109
+ ): Promise<void> {
110
+ await fetchFn(endpoint, {
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify({ id: identity.id, secret: identity.secret }),
114
+ })
115
+ }
116
+
117
+ /**
118
+ * The stable relay base for one identity — the origin the QR and the phone
119
+ * bookmark use while (or after) the registration is accepted.
120
+ */
121
+ export function relayBaseOf(identity: RelayIdentity): string {
122
+ return `https://${identity.id}${RELAY_BASE_SUFFIX}`
123
+ }
124
+
125
+ /**
126
+ * Registration loop with capped exponential backoff. Re-registration happens
127
+ * on every tunnel start (the quick URL can change after a crash-restart);
128
+ * failures retry with backoff and surface the last error to the card.
129
+ */
130
+ export class RelayRegistrar {
131
+ private timer: NodeJS.Timeout | undefined
132
+ private attempts = 0
133
+ private lastError: string | undefined
134
+ private disposed = false
135
+
136
+ constructor(
137
+ private readonly identity: RelayIdentity,
138
+ private readonly setState: (state: RelayState) => void,
139
+ private readonly options: {
140
+ fetchFn?: typeof fetch
141
+ registerUrl?: string
142
+ unregisterUrl?: string
143
+ baseDelayMs?: number
144
+ maxDelayMs?: number
145
+ timerFn?: (fn: () => void, ms: number) => NodeJS.Timeout
146
+ } = {},
147
+ ) {}
148
+
149
+ /** The stable relay base for the QR while registered. */
150
+ get baseUrl(): string {
151
+ return relayBaseOf(this.identity)
152
+ }
153
+
154
+ /** The last registration failure detail (undefined while healthy). */
155
+ get error(): string | undefined {
156
+ return this.lastError
157
+ }
158
+
159
+ /** Push one tunnel target; resolves the stable base once accepted. */
160
+ async announce(target: string): Promise<string | undefined> {
161
+ // A disposed registrar stays disposed: a pending retry timer must not
162
+ // resurrect it (re-enabling creates a fresh registrar instead).
163
+ if (this.disposed) return undefined
164
+ if (this.timer !== undefined) {
165
+ clearTimeout(this.timer)
166
+ this.timer = undefined
167
+ }
168
+ this.setState({ state: 'registering' })
169
+ try {
170
+ const fetchFn = this.options.fetchFn ?? fetch
171
+ const response = await fetchFn(this.options.registerUrl ?? RELAY_REGISTER_URL, {
172
+ method: 'PUT',
173
+ headers: { 'content-type': 'application/json' },
174
+ body: registrationBody(this.identity, target),
175
+ })
176
+ if (!response.ok) {
177
+ const detail = await response.json().catch(() => undefined)
178
+ const error = detail && typeof detail.error === 'string' ? detail.error : `HTTP ${String(response.status)}`
179
+ throw new Error(error)
180
+ }
181
+ // The row exists now: never claim a fresh id twice.
182
+ this.identity.fresh = false
183
+ this.attempts = 0
184
+ this.lastError = undefined
185
+ this.setState({ state: 'running', url: this.baseUrl })
186
+ return this.baseUrl
187
+ } catch (error) {
188
+ this.lastError = error instanceof Error ? error.message : String(error)
189
+ if (this.lastError === 'invalid-params' && this.identity.fresh !== true) {
190
+ // The registry lost the row (a D1 restore or manual wipe): re-claim
191
+ // the id with a fresh claim on the next retry. A 403 auth-failed
192
+ // means the id is owned by another secret and stays failed.
193
+ this.identity.fresh = true
194
+ }
195
+ this.setState({ state: 'failed', error: this.lastError })
196
+ this.scheduleRetry(target)
197
+ return undefined
198
+ }
199
+ }
200
+
201
+ /** Best-effort removal of the registry row (the relay toggle turned off). */
202
+ async unregister(): Promise<void> {
203
+ await unregisterRelay(this.identity, this.options.unregisterUrl, this.options.fetchFn)
204
+ }
205
+
206
+ private scheduleRetry(target: string): void {
207
+ if (this.disposed) return
208
+ const base = this.options.baseDelayMs ?? 5_000
209
+ const max = this.options.maxDelayMs ?? 60_000
210
+ const delay = Math.min(base * 2 ** this.attempts, max)
211
+ this.attempts += 1
212
+ const timerFn = this.options.timerFn ?? ((fn: () => void, ms: number) => {
213
+ const timer = setTimeout(fn, ms)
214
+ timer.unref?.()
215
+ return timer
216
+ })
217
+ this.timer = timerFn(() => {
218
+ this.timer = undefined
219
+ void this.announce(target)
220
+ }, delay)
221
+ }
222
+
223
+ /** Stop retrying and clear the card state (tunnel stopped or disabled). */
224
+ dispose(): void {
225
+ this.disposed = true
226
+ if (this.timer !== undefined) {
227
+ clearTimeout(this.timer)
228
+ this.timer = undefined
229
+ }
230
+ this.setState({ state: 'off' })
231
+ }
232
+ }
package/src/remote-api.ts CHANGED
@@ -39,7 +39,6 @@ import {
39
39
  } from './remote-methods.ts'
40
40
 
41
41
  export {
42
- DESKTOP_LAUNCHER_PATH,
43
42
  LOCAL_ONLY_PREFIXES,
44
43
  PLUGIN_MANAGER_PATH,
45
44
  REMOTE_API_PATHS,
@@ -59,7 +59,6 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
59
59
  'function sf(p){' +
60
60
  'if(p.indexOf(R.pairPrefix)===0)return false;' +
61
61
  'if(p.indexOf(R.updatePrefix)===0)return false;' +
62
- 'if(p===R.desktopLauncherPrefix||p.indexOf(R.desktopLauncherPrefix+"/")===0)return false;' +
63
62
  'if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+"/")===0)return false;' +
64
63
  'if(p.indexOf(R.apiPrefix)===0)return true;' +
65
64
  'if(p.indexOf(R.sidebarPrefix)===0||p==="/sidebar")return true;' +
@@ -22,7 +22,6 @@ export interface RemoteChannelRules {
22
22
  readonly apiPrefix: string
23
23
  readonly pairPrefix: string
24
24
  readonly updatePrefix: string
25
- readonly desktopLauncherPrefix: string
26
25
  readonly settingsBridgePrefix: string
27
26
  readonly sidebarPrefix: string
28
27
  readonly gitPrefix: string
@@ -42,7 +41,6 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
42
41
  apiPrefix: '/api/',
43
42
  pairPrefix: '/api/pair/',
44
43
  updatePrefix: '/api/update/',
45
- desktopLauncherPrefix: '/api/dsh-desktop-launcher',
46
44
  settingsBridgePrefix: '/api/dsh-web-ui-settings',
47
45
  sidebarPrefix: '/sidebar/',
48
46
  gitPrefix: '/git/',
@@ -45,9 +45,6 @@ export const REMOTE_UPGRADE_PATHS = [
45
45
  /** Plugin-manager HTTP prefix: install/remove stay physically local. */
46
46
  export const PLUGIN_MANAGER_PATH = '/api/plugin-manager'
47
47
 
48
- /** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
49
- export const DESKTOP_LAUNCHER_PATH = '/api/dsh-desktop-launcher'
50
-
51
48
  /** Family settings-bridge HTTP prefix — re-exposed to paired devices (plain settings parity). */
52
49
  export const WEB_UI_SETTINGS_BRIDGE_PATH = '/api/dsh-web-ui-settings'
53
50
 
@@ -68,14 +65,12 @@ export const REMOTE_DEVICE_QUERY = 'device'
68
65
  * paired remote desktop may use the full host API (chat, sessions,
69
66
  * settings, credentials, presets — it is a full-control credential), but it
70
67
  * must not reach the machine-control planes: pairing control itself, the
71
- * dsh-web self-update installer, plugin install/remove, and desktop
72
- * launcher actions (host shutdown, shortcuts).
68
+ * dsh-web self-update installer, and plugin install/remove.
73
69
  */
74
70
  export const LOCAL_ONLY_PREFIXES: readonly string[] = [
75
71
  '/api/pair',
76
72
  '/api/update',
77
73
  PLUGIN_MANAGER_PATH,
78
- DESKTOP_LAUNCHER_PATH,
79
74
  ] as const
80
75
 
81
76
  /**
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Pure planner for the settings-sync tunnel branch: given the resolved
3
+ * settings slice, decide which tunnel mode runs and with what target. Kept
4
+ * free of cordis types so the precedence matrix is unit-testable without a
5
+ * host context.
6
+ */
7
+
8
+ /** Whether a configured public base is a parseable http(s) URL with a host. */
9
+ export function isHttpUrl(value: string): boolean {
10
+ try {
11
+ const url = new URL(value)
12
+ return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname !== ''
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
18
+ /** How the settings sync should drive the tunnel for one resolved value. */
19
+ export type TunnelPlan =
20
+ | { mode: 'off' }
21
+ | { mode: 'quick'; targetUrl: string; ignored: Array<'tunnelToken' | 'publicBaseUrl'> }
22
+ | { mode: 'named'; token: string; publicUrl: string }
23
+
24
+ /**
25
+ * Decide the tunnel mode and target from one resolved settings value.
26
+ * Precedence: the quick tunnel wins when `autoTunnel` is on (anything else
27
+ * configured is reported as ignored); the named tunnel runs only with a
28
+ * token AND a valid public hostname (the token does not carry the hostname,
29
+ * so the QR base and the fence trust would have no source without it);
30
+ * otherwise no tunnel and the manual public base applies.
31
+ * @param value - the resolved settings slice that drives the tunnel.
32
+ * @param port - the local webServer port the tunnel forwards to.
33
+ */
34
+ export function tunnelPlanOf(
35
+ value: { autoTunnel?: boolean; tunnelToken?: string; publicBaseUrl?: string },
36
+ port: number,
37
+ ): TunnelPlan {
38
+ if (value.autoTunnel === true) {
39
+ const ignored: Array<'tunnelToken' | 'publicBaseUrl'> = []
40
+ if (value.tunnelToken !== undefined && value.tunnelToken !== '') ignored.push('tunnelToken')
41
+ if (value.publicBaseUrl !== undefined && value.publicBaseUrl !== '') ignored.push('publicBaseUrl')
42
+ return { mode: 'quick', targetUrl: `http://127.0.0.1:${String(port)}`, ignored }
43
+ }
44
+ if (value.tunnelToken !== undefined && value.tunnelToken !== '') {
45
+ if (value.publicBaseUrl !== undefined && isHttpUrl(value.publicBaseUrl)) {
46
+ return { mode: 'named', token: value.tunnelToken, publicUrl: value.publicBaseUrl }
47
+ }
48
+ return { mode: 'off' }
49
+ }
50
+ return { mode: 'off' }
51
+ }
package/src/tunnel.ts CHANGED
@@ -1,9 +1,12 @@
1
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.
2
+ * Auto-tunnel manager: spawns a Cloudflare tunnel through the `cloudflared`
3
+ * npm package — its postinstall downloads the platform binary, so no
4
+ * user-side tooling is involved — surfaces the public URL, and restarts the
5
+ * process after unexpected exits with exponential backoff. Two modes: the
6
+ * accountless quick tunnel (`https://xxx.trycloudflare.com`, hostname minted
7
+ * per start) and an account named tunnel (`cloudflared tunnel run --token`,
8
+ * fixed public hostname — the mode that lets a paired phone keep its
9
+ * bookmark and pairing cookie across restarts).
7
10
  *
8
11
  * The cloudflared package's Tunnel is a thin spawn wrapper; this manager
9
12
  * owns the lifecycle policy (binary readiness, URL timeout, restart
@@ -13,11 +16,79 @@
13
16
  */
14
17
 
15
18
  import { existsSync } from 'node:fs'
19
+ import { EventEmitter } from 'node:events'
16
20
  import { bin, install, Tunnel } from 'cloudflared'
17
21
 
18
22
  /** The observable tunnel lifecycle the settings/panel surfaces render. */
19
23
  export type TunnelPhase = 'stopped' | 'starting' | 'running' | 'failed'
20
24
 
25
+ /**
26
+ * What the manager should run. `quick` is the accountless trycloudflare
27
+ * tunnel whose hostname is minted per start (and dies with the process);
28
+ * `named` is an account tunnel whose public hostname is fixed — the point
29
+ * of the named mode is that a paired phone keeps its bookmark and its
30
+ * pairing cookie across restarts.
31
+ */
32
+ export type TunnelTarget =
33
+ | { kind: 'quick'; targetUrl: string; originHostHeader?: string }
34
+ | { kind: 'named'; token: string; publicUrl: string }
35
+
36
+ /** Compare two targets for the start idempotence check. */
37
+ function sameTarget(left: TunnelTarget | undefined, right: TunnelTarget): boolean {
38
+ if (left === undefined) return false
39
+ return JSON.stringify(left) === JSON.stringify(right)
40
+ }
41
+
42
+ /** The event face the named adapter wraps (a cloudflared package Tunnel). */
43
+ interface NamedTunnelProcess {
44
+ on(event: 'connected', listener: () => void): unknown
45
+ on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown
46
+ on(event: 'error', listener: (value: Error) => void): unknown
47
+ off(event: string, listener: (...args: any[]) => void): unknown
48
+ stop(): boolean
49
+ }
50
+
51
+ /**
52
+ * Wrap a named-tunnel process so it fits the quick-tunnel handle shape: the
53
+ * fixed public URL is reported through the same `url` event once the first
54
+ * edge connection registers (cloudflared fires `connected` per connection,
55
+ * so only the first is taken), and exit/error pass through. The manager's
56
+ * URL timeout, crash-restart backoff, and stop semantics then stay fully
57
+ * mode-agnostic.
58
+ * @param inner - the running named-tunnel process.
59
+ * @param publicUrl - the fixed public hostname ingress maps to this server.
60
+ * @returns a handle emitting `url` once the tunnel is reachable.
61
+ */
62
+ export function namedTunnelHandle(inner: NamedTunnelProcess, publicUrl: string): TunnelHandle {
63
+ const handle = new EventEmitter() as unknown as TunnelHandle & {
64
+ emit(event: string, ...args: any[]): boolean
65
+ }
66
+ let registered = false
67
+ const onConnected = (): void => {
68
+ // cloudflared fires `connected` per edge connection (four on a healthy
69
+ // registration); the fixed URL only needs reporting once.
70
+ if (registered) return
71
+ registered = true
72
+ handle.emit('url', publicUrl)
73
+ }
74
+ const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
75
+ handle.emit('exit', code, signal)
76
+ }
77
+ const onError = (value: Error): void => {
78
+ handle.emit('error', value)
79
+ }
80
+ inner.on('connected', onConnected)
81
+ inner.on('exit', onExit)
82
+ inner.on('error', onError)
83
+ handle.stop = (): boolean => {
84
+ inner.off('connected', onConnected)
85
+ inner.off('exit', onExit)
86
+ inner.off('error', onError)
87
+ return inner.stop()
88
+ }
89
+ return handle
90
+ }
91
+
21
92
  /** One tunnel status frame. */
22
93
  export interface TunnelInfo {
23
94
  phase: TunnelPhase
@@ -36,8 +107,8 @@ export interface TunnelHandle {
36
107
 
37
108
  /** Injectable seams (defaults are the real cloudflared package + node timers). */
38
109
  export interface TunnelManagerOptions {
39
- /** Spawn one quick tunnel toward the local target URL. */
40
- factory?: (targetUrl: string) => TunnelHandle
110
+ /** Spawn one tunnel process for the target (quick or named). */
111
+ factory?: (target: TunnelTarget) => TunnelHandle
41
112
  /** Make sure the cloudflared binary exists, downloading it when absent. */
42
113
  ensureBinary?: () => Promise<void>
43
114
  /** Wait up to this long for the tunnel URL before failing the attempt. */
@@ -56,8 +127,25 @@ async function defaultEnsureBinary(): Promise<void> {
56
127
  await install(bin)
57
128
  }
58
129
 
59
- /** Default factory: the cloudflared package's quick tunnel (no account). */
60
- function defaultFactory(targetUrl: string): TunnelHandle {
130
+ /** The flags every tunnel mode shares (see the quick factory comment). */
131
+ const SHARED_TUNNEL_FLAGS = { '--no-autoupdate': true, '--protocol': 'http2' } as const
132
+
133
+ /**
134
+ * Spawn flags for one quick target. `originHostHeader` stamps the Host the
135
+ * local webserver sees: the plugin's phone-facing fence trusts the configured
136
+ * public host (the stable relay origin, Host-bound) and the harness
137
+ * browser-auth binds cookies to the Host authority, while the Workers relay
138
+ * cannot control the origin-side Host at all (fetch forces it to the URL
139
+ * authority) — so the connector must restamp it with the stable origin.
140
+ */
141
+ export function quickTunnelFlags(originHostHeader?: string): Record<string, string | boolean> {
142
+ return originHostHeader === undefined
143
+ ? { ...SHARED_TUNNEL_FLAGS }
144
+ : { ...SHARED_TUNNEL_FLAGS, '--http-host-header': originHostHeader }
145
+ }
146
+
147
+ /** Default factory: the cloudflared package's quick and named tunnels. */
148
+ function defaultFactory(target: TunnelTarget): TunnelHandle {
61
149
  // `--no-autoupdate`: the binary must never upgrade itself out from under
62
150
  // the manager (a self-updated binary would break the pinned lifecycle).
63
151
  // `--protocol http2`: the default `auto` prefers QUIC (UDP 7844), which
@@ -66,7 +154,10 @@ function defaultFactory(targetUrl: string): TunnelHandle {
66
154
  // and the auto fallback never fires on a half-open QUIC handshake. http2
67
155
  // rides TCP through the same paths reliably (verified live: auto stuck at
68
156
  // ready 0 on two fresh registrations; http2 ready immediately).
69
- return Tunnel.quick(targetUrl, { '--no-autoupdate': true, '--protocol': 'http2' })
157
+ if (target.kind === 'quick') {
158
+ return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader))
159
+ }
160
+ return namedTunnelHandle(Tunnel.withToken(target.token, { ...SHARED_TUNNEL_FLAGS }), target.publicUrl)
70
161
  }
71
162
 
72
163
  /** Node timers. */
@@ -77,7 +168,7 @@ const nodeTimer = { setTimeout, clearTimeout }
77
168
  * crash-restart backoff.
78
169
  */
79
170
  export class TunnelManager {
80
- private readonly factory: (targetUrl: string) => TunnelHandle
171
+ private readonly factory: (target: TunnelTarget) => TunnelHandle
81
172
  private readonly ensureBinary: () => Promise<void>
82
173
  private readonly urlTimeoutMs: number
83
174
  private readonly restartBaseMs: number
@@ -87,7 +178,7 @@ export class TunnelManager {
87
178
  private phase: TunnelPhase = 'stopped'
88
179
  private url: string | undefined
89
180
  private error: string | undefined
90
- private targetUrl: string | undefined
181
+ private target: TunnelTarget | undefined
91
182
  private handle: TunnelHandle | undefined
92
183
  private urlTimer: unknown | undefined
93
184
  private restartTimer: unknown | undefined
@@ -121,16 +212,21 @@ export class TunnelManager {
121
212
  }
122
213
 
123
214
  /**
124
- * Start (or keep) a quick tunnel toward `targetUrl`. Restarting with a
125
- * different target tears the old tunnel down first; restarting with the
126
- * same target while running is a no-op.
127
- * @param targetUrl - the local URL to expose, e.g. `http://127.0.0.1:3080`.
215
+ * Start (or keep) a tunnel toward `target`. Restarting with a different
216
+ * target tears the old tunnel down first; restarting with the same target
217
+ * while running is a no-op. A string is the quick mode's local target URL.
218
+ *
219
+ * In quick mode the URL surfaces when cloudflared mints the ephemeral
220
+ * trycloudflare hostname; in named mode the fixed `publicUrl` surfaces
221
+ * once the first edge connection registers (see {@link namedTunnelHandle}).
222
+ * @param target - what to run (a string means quick toward that local URL).
128
223
  */
129
- start(targetUrl: string): void {
130
- if (this.targetUrl === targetUrl && (this.phase === 'starting' || this.phase === 'running')) return
224
+ start(target: TunnelTarget | string): void {
225
+ const resolved: TunnelTarget = typeof target === 'string' ? { kind: 'quick', targetUrl: target } : target
226
+ if (sameTarget(this.target, resolved) && (this.phase === 'starting' || this.phase === 'running')) return
131
227
  this.teardown()
132
228
  this.stopping = false
133
- this.targetUrl = targetUrl
229
+ this.target = resolved
134
230
  this.attempts = 0
135
231
  this.generation += 1
136
232
  this.attempt()
@@ -140,7 +236,7 @@ export class TunnelManager {
140
236
  stop(): void {
141
237
  this.teardown()
142
238
  this.stopping = false
143
- this.targetUrl = undefined
239
+ this.target = undefined
144
240
  this.setPhase('stopped')
145
241
  }
146
242
 
@@ -162,15 +258,15 @@ export class TunnelManager {
162
258
  }
163
259
 
164
260
  private attempt(): void {
165
- if (this.stopping || this.targetUrl === undefined) return
261
+ if (this.stopping || this.target === undefined) return
166
262
  const gen = this.generation
167
263
  this.setPhase('starting')
168
264
  this.handle = undefined
169
265
  this.url = undefined
170
266
  this.error = undefined
171
267
  void this.ensureBinary().then(() => {
172
- if (this.stopping || this.targetUrl === undefined || gen !== this.generation) return
173
- const handle = this.factory(this.targetUrl)
268
+ if (this.stopping || this.target === undefined || gen !== this.generation) return
269
+ const handle = this.factory(this.target)
174
270
  this.handle = handle
175
271
  this.urlTimer = this.timer.setTimeout(() => {
176
272
  // The tunnel never reported a URL: kill it and retry with backoff.
@@ -192,7 +288,7 @@ export class TunnelManager {
192
288
  })
193
289
  }).catch((value: unknown) => {
194
290
  // Binary install failed (no network, no platform build): report it.
195
- if (this.stopping || this.targetUrl === undefined || gen !== this.generation) return
291
+ if (this.stopping || this.target === undefined || gen !== this.generation) return
196
292
  const message = value instanceof Error ? value.message : String(value)
197
293
  this.fail(`could not obtain the cloudflared binary: ${message}`)
198
294
  })