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

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 (46) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +24 -4
  3. package/README.zh.md +24 -4
  4. package/lib/client.js +77 -8
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +409 -44
  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/index.d.ts +28 -2
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/index.js +138 -23
  23. package/lib/types/pairing.d.ts +17 -0
  24. package/lib/types/pairing.d.ts.map +1 -1
  25. package/lib/types/pairing.js +13 -0
  26. package/lib/types/relay-registry.d.ts +97 -0
  27. package/lib/types/relay-registry.d.ts.map +1 -0
  28. package/lib/types/relay-registry.js +191 -0
  29. package/lib/types/tunnel-plan.d.ts +36 -0
  30. package/lib/types/tunnel-plan.d.ts.map +1 -0
  31. package/lib/types/tunnel-plan.js +43 -0
  32. package/lib/types/tunnel.d.ts +66 -13
  33. package/lib/types/tunnel.d.ts.map +1 -1
  34. package/lib/types/tunnel.js +93 -21
  35. package/package.json +1 -1
  36. package/src/client/RemoteEntry.tsx +2 -1
  37. package/src/client/RemotePanel.tsx +11 -0
  38. package/src/client/RemoteSettingsCard.tsx +43 -1
  39. package/src/client/locales.ts +16 -4
  40. package/src/client/pair-api.ts +11 -0
  41. package/src/client/settings-card.module.css +14 -8
  42. package/src/index.ts +153 -25
  43. package/src/pairing.ts +30 -0
  44. package/src/relay-registry.ts +232 -0
  45. package/src/tunnel-plan.ts +51 -0
  46. package/src/tunnel.ts +120 -24
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ import { z as z$1 } from "zod";
11
11
  import http, { request } from "node:http";
12
12
  import { connect } from "node:net";
13
13
  import { spawn, spawnSync } from "node:child_process";
14
+ import { EventEmitter } from "node:events";
14
15
  import { Tunnel, bin, install } from "cloudflared";
15
16
  //#region src/pairing.ts
16
17
  /**
@@ -86,6 +87,7 @@ var PairingService = class {
86
87
  publicBase;
87
88
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
88
89
  tunnelStatus;
90
+ relayStatus;
89
91
  posture;
90
92
  /** True when lastSeenAt changed since the last persist (flushed on sweep). */
91
93
  dirty = false;
@@ -201,6 +203,11 @@ var PairingService = class {
201
203
  this.tunnelStatus = status;
202
204
  this.notify();
203
205
  }
206
+ /** Set or clear the relay-registry status frame (undefined when not in play). */
207
+ setRelayStatus(status) {
208
+ this.relayStatus = status;
209
+ this.notify();
210
+ }
204
211
  /** Set the latest /api posture probe result (see posture.ts). */
205
212
  setPosture(snapshot) {
206
213
  this.posture = snapshot;
@@ -349,6 +356,7 @@ var PairingService = class {
349
356
  lanAddresses: [...this.lanBases.keys()],
350
357
  ...this.publicBase !== void 0 ? { publicUrl: this.publicBase } : {},
351
358
  ...this.tunnelStatus !== void 0 ? { tunnel: this.tunnelStatus } : {},
359
+ ...this.relayStatus !== void 0 ? { relay: this.relayStatus } : {},
352
360
  ...this.posture !== void 0 ? { posture: this.posture } : {},
353
361
  ...token !== void 0 ? {
354
362
  tokenId: token.record.id,
@@ -430,7 +438,7 @@ var PairingService = class {
430
438
  };
431
439
  /** Structural equality over the snapshot's wire fields. */
432
440
  function snapshotsEqual(a, b) {
433
- return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount && devicesEqual(a.devices, b.devices);
441
+ return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && relayEqual(a.relay, b.relay) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount && devicesEqual(a.devices, b.devices);
434
442
  }
435
443
  /** Per-device roster equality (order is pairing time). */
436
444
  function devicesEqual(a, b) {
@@ -443,6 +451,10 @@ function devicesEqual(a, b) {
443
451
  function tunnelEqual(a, b) {
444
452
  return a === b || a !== void 0 && b !== void 0 && a.state === b.state && a.url === b.url && a.error === b.error;
445
453
  }
454
+ /** Relay frame equality (same shape as the tunnel frame). */
455
+ function relayEqual(a, b) {
456
+ return a === b || a !== void 0 && b !== void 0 && a.state === b.state && a.url === b.url && a.error === b.error;
457
+ }
446
458
  /** Element-wise string list equality (interface order is meaningful). */
447
459
  function sameStrings(a, b) {
448
460
  return a.length === b.length && a.every((value, index) => value === b[index]);
@@ -2308,6 +2320,224 @@ function writeLanBind(host, port, profile, home = dshHome()) {
2308
2320
  renameSync(temp, file);
2309
2321
  }
2310
2322
  //#endregion
2323
+ //#region src/tunnel-plan.ts
2324
+ /**
2325
+ * Pure planner for the settings-sync tunnel branch: given the resolved
2326
+ * settings slice, decide which tunnel mode runs and with what target. Kept
2327
+ * free of cordis types so the precedence matrix is unit-testable without a
2328
+ * host context.
2329
+ */
2330
+ /** Whether a configured public base is a parseable http(s) URL with a host. */
2331
+ function isHttpUrl(value) {
2332
+ try {
2333
+ const url = new URL(value);
2334
+ return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
2335
+ } catch {
2336
+ return false;
2337
+ }
2338
+ }
2339
+ /**
2340
+ * Decide the tunnel mode and target from one resolved settings value.
2341
+ * Precedence: the quick tunnel wins when `autoTunnel` is on (anything else
2342
+ * configured is reported as ignored); the named tunnel runs only with a
2343
+ * token AND a valid public hostname (the token does not carry the hostname,
2344
+ * so the QR base and the fence trust would have no source without it);
2345
+ * otherwise no tunnel and the manual public base applies.
2346
+ * @param value - the resolved settings slice that drives the tunnel.
2347
+ * @param port - the local webServer port the tunnel forwards to.
2348
+ */
2349
+ function tunnelPlanOf(value, port) {
2350
+ if (value.autoTunnel === true) {
2351
+ const ignored = [];
2352
+ if (value.tunnelToken !== void 0 && value.tunnelToken !== "") ignored.push("tunnelToken");
2353
+ if (value.publicBaseUrl !== void 0 && value.publicBaseUrl !== "") ignored.push("publicBaseUrl");
2354
+ return {
2355
+ mode: "quick",
2356
+ targetUrl: `http://127.0.0.1:${String(port)}`,
2357
+ ignored
2358
+ };
2359
+ }
2360
+ if (value.tunnelToken !== void 0 && value.tunnelToken !== "") {
2361
+ if (value.publicBaseUrl !== void 0 && isHttpUrl(value.publicBaseUrl)) return {
2362
+ mode: "named",
2363
+ token: value.tunnelToken,
2364
+ publicUrl: value.publicBaseUrl
2365
+ };
2366
+ return { mode: "off" };
2367
+ }
2368
+ return { mode: "off" };
2369
+ }
2370
+ const RELAY_UNREGISTER_URL = "https://dsh-market.com/api/relay/unregister";
2371
+ /**
2372
+ * The public origin template: one stable single-label subdomain per
2373
+ * registration id. Single label on purpose — Universal SSL covers exactly
2374
+ * one subdomain level, so a two-level hostname would get no certificate.
2375
+ */
2376
+ const RELAY_BASE_SUFFIX = ".dsh-market.com";
2377
+ const RELAY_ID_RE = /^[a-z0-9]{16}$/;
2378
+ const RELAY_SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
2379
+ /** Registry storage layout: one file per profile under the relay directory. */
2380
+ function relayIdentityFile(profile, home = dshHome()) {
2381
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile)) throw new Error(`relay registry: profile ${JSON.stringify(profile)} is not a safe path segment`);
2382
+ return join(home, "remote-web-ui-registry", `${profile}.json`);
2383
+ }
2384
+ /** Generate one identity pair (id: 8-byte hex slug; secret: 32-byte base64url). */
2385
+ function generateRelayIdentity(random = randomBytes) {
2386
+ return {
2387
+ id: random(8).toString("hex"),
2388
+ secret: random(32).toString("base64url"),
2389
+ fresh: true
2390
+ };
2391
+ }
2392
+ /**
2393
+ * Load (or lazily mint) the per-profile relay identity. The file is written
2394
+ * owner-only on first mint; a corrupt file is reminted (the phone then
2395
+ * re-pairs once — the same cost as losing the devices file).
2396
+ */
2397
+ function loadRelayIdentity(profile, home = dshHome(), random = randomBytes) {
2398
+ const file = relayIdentityFile(profile, home);
2399
+ try {
2400
+ if (existsSync(file)) {
2401
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
2402
+ if (typeof parsed.id === "string" && RELAY_ID_RE.test(parsed.id) && typeof parsed.secret === "string" && RELAY_SECRET_RE.test(parsed.secret)) return {
2403
+ id: parsed.id,
2404
+ secret: parsed.secret
2405
+ };
2406
+ console.warn(`relay registry: ${file} is malformed — reminting the identity (paired devices must scan the QR once more)`);
2407
+ }
2408
+ } catch (error) {
2409
+ console.warn(`relay registry: could not read ${file} (${error instanceof Error ? error.message : String(error)}) — reminting`);
2410
+ }
2411
+ const identity = generateRelayIdentity(random);
2412
+ mkdirSync(dirname(file), { recursive: true });
2413
+ writeFileSync(file, JSON.stringify({
2414
+ id: identity.id,
2415
+ secret: identity.secret
2416
+ }, null, 2) + "\n", { mode: 384 });
2417
+ return identity;
2418
+ }
2419
+ /** Build the registration request body for one identity + tunnel target. */
2420
+ function registrationBody(identity, target) {
2421
+ return JSON.stringify({
2422
+ id: identity.id,
2423
+ secret: identity.secret,
2424
+ new_secret: identity.fresh === true ? identity.secret : void 0,
2425
+ target
2426
+ });
2427
+ }
2428
+ /** Best-effort removal of the mapping (used on disable; errors are swallowed). */
2429
+ async function unregisterRelay(identity, endpoint = RELAY_UNREGISTER_URL, fetchFn = fetch) {
2430
+ await fetchFn(endpoint, {
2431
+ method: "POST",
2432
+ headers: { "content-type": "application/json" },
2433
+ body: JSON.stringify({
2434
+ id: identity.id,
2435
+ secret: identity.secret
2436
+ })
2437
+ });
2438
+ }
2439
+ /**
2440
+ * The stable relay base for one identity — the origin the QR and the phone
2441
+ * bookmark use while (or after) the registration is accepted.
2442
+ */
2443
+ function relayBaseOf(identity) {
2444
+ return `https://${identity.id}${RELAY_BASE_SUFFIX}`;
2445
+ }
2446
+ /**
2447
+ * Registration loop with capped exponential backoff. Re-registration happens
2448
+ * on every tunnel start (the quick URL can change after a crash-restart);
2449
+ * failures retry with backoff and surface the last error to the card.
2450
+ */
2451
+ var RelayRegistrar = class {
2452
+ identity;
2453
+ setState;
2454
+ options;
2455
+ timer;
2456
+ attempts = 0;
2457
+ lastError;
2458
+ disposed = false;
2459
+ constructor(identity, setState, options = {}) {
2460
+ this.identity = identity;
2461
+ this.setState = setState;
2462
+ this.options = options;
2463
+ }
2464
+ /** The stable relay base for the QR while registered. */
2465
+ get baseUrl() {
2466
+ return relayBaseOf(this.identity);
2467
+ }
2468
+ /** The last registration failure detail (undefined while healthy). */
2469
+ get error() {
2470
+ return this.lastError;
2471
+ }
2472
+ /** Push one tunnel target; resolves the stable base once accepted. */
2473
+ async announce(target) {
2474
+ if (this.disposed) return void 0;
2475
+ if (this.timer !== void 0) {
2476
+ clearTimeout(this.timer);
2477
+ this.timer = void 0;
2478
+ }
2479
+ this.setState({ state: "registering" });
2480
+ try {
2481
+ const response = await (this.options.fetchFn ?? fetch)(this.options.registerUrl ?? "https://dsh-market.com/api/relay/register", {
2482
+ method: "PUT",
2483
+ headers: { "content-type": "application/json" },
2484
+ body: registrationBody(this.identity, target)
2485
+ });
2486
+ if (!response.ok) {
2487
+ const detail = await response.json().catch(() => void 0);
2488
+ const error = detail && typeof detail.error === "string" ? detail.error : `HTTP ${String(response.status)}`;
2489
+ throw new Error(error);
2490
+ }
2491
+ this.identity.fresh = false;
2492
+ this.attempts = 0;
2493
+ this.lastError = void 0;
2494
+ this.setState({
2495
+ state: "running",
2496
+ url: this.baseUrl
2497
+ });
2498
+ return this.baseUrl;
2499
+ } catch (error) {
2500
+ this.lastError = error instanceof Error ? error.message : String(error);
2501
+ if (this.lastError === "invalid-params" && this.identity.fresh !== true) this.identity.fresh = true;
2502
+ this.setState({
2503
+ state: "failed",
2504
+ error: this.lastError
2505
+ });
2506
+ this.scheduleRetry(target);
2507
+ return;
2508
+ }
2509
+ }
2510
+ /** Best-effort removal of the registry row (the relay toggle turned off). */
2511
+ async unregister() {
2512
+ await unregisterRelay(this.identity, this.options.unregisterUrl, this.options.fetchFn);
2513
+ }
2514
+ scheduleRetry(target) {
2515
+ if (this.disposed) return;
2516
+ const base = this.options.baseDelayMs ?? 5e3;
2517
+ const max = this.options.maxDelayMs ?? 6e4;
2518
+ const delay = Math.min(base * 2 ** this.attempts, max);
2519
+ this.attempts += 1;
2520
+ const timerFn = this.options.timerFn ?? ((fn, ms) => {
2521
+ const timer = setTimeout(fn, ms);
2522
+ timer.unref?.();
2523
+ return timer;
2524
+ });
2525
+ this.timer = timerFn(() => {
2526
+ this.timer = void 0;
2527
+ this.announce(target);
2528
+ }, delay);
2529
+ }
2530
+ /** Stop retrying and clear the card state (tunnel stopped or disabled). */
2531
+ dispose() {
2532
+ this.disposed = true;
2533
+ if (this.timer !== void 0) {
2534
+ clearTimeout(this.timer);
2535
+ this.timer = void 0;
2536
+ }
2537
+ this.setState({ state: "off" });
2538
+ }
2539
+ };
2540
+ //#endregion
2311
2541
  //#region src/lan-bind-plan.ts
2312
2542
  /**
2313
2543
  * The bind host the managed block should pin: an explicit CLI --host wins,
@@ -2406,11 +2636,14 @@ function createInnerAuth(launchUrl, fetchImpl = fetch) {
2406
2636
  //#endregion
2407
2637
  //#region src/tunnel.ts
2408
2638
  /**
2409
- * Auto-tunnel manager: spawns a Cloudflare quick tunnel (`cloudflared
2410
- * tunnel --url <local>`) through the `cloudflared` npm package — its
2411
- * postinstall downloads the platform binary, so no user-side tooling is
2412
- * involved — surfaces the minted `https://xxx.trycloudflare.com` URL, and
2413
- * restarts the process after unexpected exits with exponential backoff.
2639
+ * Auto-tunnel manager: spawns a Cloudflare tunnel through the `cloudflared`
2640
+ * npm package — its postinstall downloads the platform binary, so no
2641
+ * user-side tooling is involved — surfaces the public URL, and restarts the
2642
+ * process after unexpected exits with exponential backoff. Two modes: the
2643
+ * accountless quick tunnel (`https://xxx.trycloudflare.com`, hostname minted
2644
+ * per start) and an account named tunnel (`cloudflared tunnel run --token`,
2645
+ * fixed public hostname — the mode that lets a paired phone keep its
2646
+ * bookmark and pairing cookie across restarts).
2414
2647
  *
2415
2648
  * The cloudflared package's Tunnel is a thin spawn wrapper; this manager
2416
2649
  * owns the lifecycle policy (binary readiness, URL timeout, restart
@@ -2418,17 +2651,75 @@ function createInnerAuth(launchUrl, fetchImpl = fetch) {
2418
2651
  * timers — are injectable so the whole lifecycle is unit-testable without
2419
2652
  * a real binary or network.
2420
2653
  */
2654
+ /** Compare two targets for the start idempotence check. */
2655
+ function sameTarget(left, right) {
2656
+ if (left === void 0) return false;
2657
+ return JSON.stringify(left) === JSON.stringify(right);
2658
+ }
2659
+ /**
2660
+ * Wrap a named-tunnel process so it fits the quick-tunnel handle shape: the
2661
+ * fixed public URL is reported through the same `url` event once the first
2662
+ * edge connection registers (cloudflared fires `connected` per connection,
2663
+ * so only the first is taken), and exit/error pass through. The manager's
2664
+ * URL timeout, crash-restart backoff, and stop semantics then stay fully
2665
+ * mode-agnostic.
2666
+ * @param inner - the running named-tunnel process.
2667
+ * @param publicUrl - the fixed public hostname ingress maps to this server.
2668
+ * @returns a handle emitting `url` once the tunnel is reachable.
2669
+ */
2670
+ function namedTunnelHandle(inner, publicUrl) {
2671
+ const handle = new EventEmitter();
2672
+ let registered = false;
2673
+ const onConnected = () => {
2674
+ if (registered) return;
2675
+ registered = true;
2676
+ handle.emit("url", publicUrl);
2677
+ };
2678
+ const onExit = (code, signal) => {
2679
+ handle.emit("exit", code, signal);
2680
+ };
2681
+ const onError = (value) => {
2682
+ handle.emit("error", value);
2683
+ };
2684
+ inner.on("connected", onConnected);
2685
+ inner.on("exit", onExit);
2686
+ inner.on("error", onError);
2687
+ handle.stop = () => {
2688
+ inner.off("connected", onConnected);
2689
+ inner.off("exit", onExit);
2690
+ inner.off("error", onError);
2691
+ return inner.stop();
2692
+ };
2693
+ return handle;
2694
+ }
2421
2695
  /** Default binary readiness: download the platform binary on first use. */
2422
2696
  async function defaultEnsureBinary() {
2423
2697
  if (existsSync(bin)) return;
2424
2698
  await install(bin);
2425
2699
  }
2426
- /** Default factory: the cloudflared package's quick tunnel (no account). */
2427
- function defaultFactory(targetUrl) {
2428
- return Tunnel.quick(targetUrl, {
2429
- "--no-autoupdate": true,
2430
- "--protocol": "http2"
2431
- });
2700
+ /** The flags every tunnel mode shares (see the quick factory comment). */
2701
+ const SHARED_TUNNEL_FLAGS = {
2702
+ "--no-autoupdate": true,
2703
+ "--protocol": "http2"
2704
+ };
2705
+ /**
2706
+ * Spawn flags for one quick target. `originHostHeader` stamps the Host the
2707
+ * local webserver sees: the plugin's phone-facing fence trusts the configured
2708
+ * public host (the stable relay origin, Host-bound) and the harness
2709
+ * browser-auth binds cookies to the Host authority, while the Workers relay
2710
+ * cannot control the origin-side Host at all (fetch forces it to the URL
2711
+ * authority) — so the connector must restamp it with the stable origin.
2712
+ */
2713
+ function quickTunnelFlags(originHostHeader) {
2714
+ return originHostHeader === void 0 ? { ...SHARED_TUNNEL_FLAGS } : {
2715
+ ...SHARED_TUNNEL_FLAGS,
2716
+ "--http-host-header": originHostHeader
2717
+ };
2718
+ }
2719
+ /** Default factory: the cloudflared package's quick and named tunnels. */
2720
+ function defaultFactory(target) {
2721
+ if (target.kind === "quick") return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader));
2722
+ return namedTunnelHandle(Tunnel.withToken(target.token, { ...SHARED_TUNNEL_FLAGS }), target.publicUrl);
2432
2723
  }
2433
2724
  /** Node timers. */
2434
2725
  const nodeTimer = {
@@ -2449,7 +2740,7 @@ var TunnelManager = class {
2449
2740
  phase = "stopped";
2450
2741
  url;
2451
2742
  error;
2452
- targetUrl;
2743
+ target;
2453
2744
  handle;
2454
2745
  urlTimer;
2455
2746
  restartTimer;
@@ -2478,16 +2769,24 @@ var TunnelManager = class {
2478
2769
  };
2479
2770
  }
2480
2771
  /**
2481
- * Start (or keep) a quick tunnel toward `targetUrl`. Restarting with a
2482
- * different target tears the old tunnel down first; restarting with the
2483
- * same target while running is a no-op.
2484
- * @param targetUrl - the local URL to expose, e.g. `http://127.0.0.1:3080`.
2772
+ * Start (or keep) a tunnel toward `target`. Restarting with a different
2773
+ * target tears the old tunnel down first; restarting with the same target
2774
+ * while running is a no-op. A string is the quick mode's local target URL.
2775
+ *
2776
+ * In quick mode the URL surfaces when cloudflared mints the ephemeral
2777
+ * trycloudflare hostname; in named mode the fixed `publicUrl` surfaces
2778
+ * once the first edge connection registers (see {@link namedTunnelHandle}).
2779
+ * @param target - what to run (a string means quick toward that local URL).
2485
2780
  */
2486
- start(targetUrl) {
2487
- if (this.targetUrl === targetUrl && (this.phase === "starting" || this.phase === "running")) return;
2781
+ start(target) {
2782
+ const resolved = typeof target === "string" ? {
2783
+ kind: "quick",
2784
+ targetUrl: target
2785
+ } : target;
2786
+ if (sameTarget(this.target, resolved) && (this.phase === "starting" || this.phase === "running")) return;
2488
2787
  this.teardown();
2489
2788
  this.stopping = false;
2490
- this.targetUrl = targetUrl;
2789
+ this.target = resolved;
2491
2790
  this.attempts = 0;
2492
2791
  this.generation += 1;
2493
2792
  this.attempt();
@@ -2496,7 +2795,7 @@ var TunnelManager = class {
2496
2795
  stop() {
2497
2796
  this.teardown();
2498
2797
  this.stopping = false;
2499
- this.targetUrl = void 0;
2798
+ this.target = void 0;
2500
2799
  this.setPhase("stopped");
2501
2800
  }
2502
2801
  /** Alias of {@link stop} for plugin-effect disposal. */
@@ -2518,15 +2817,15 @@ var TunnelManager = class {
2518
2817
  };
2519
2818
  }
2520
2819
  attempt() {
2521
- if (this.stopping || this.targetUrl === void 0) return;
2820
+ if (this.stopping || this.target === void 0) return;
2522
2821
  const gen = this.generation;
2523
2822
  this.setPhase("starting");
2524
2823
  this.handle = void 0;
2525
2824
  this.url = void 0;
2526
2825
  this.error = void 0;
2527
2826
  this.ensureBinary().then(() => {
2528
- if (this.stopping || this.targetUrl === void 0 || gen !== this.generation) return;
2529
- const handle = this.factory(this.targetUrl);
2827
+ if (this.stopping || this.target === void 0 || gen !== this.generation) return;
2828
+ const handle = this.factory(this.target);
2530
2829
  this.handle = handle;
2531
2830
  this.urlTimer = this.timer.setTimeout(() => {
2532
2831
  this.fail("timed out waiting for the tunnel URL");
@@ -2544,7 +2843,7 @@ var TunnelManager = class {
2544
2843
  this.error = value instanceof Error ? value.message : String(value);
2545
2844
  });
2546
2845
  }).catch((value) => {
2547
- if (this.stopping || this.targetUrl === void 0 || gen !== this.generation) return;
2846
+ if (this.stopping || this.target === void 0 || gen !== this.generation) return;
2548
2847
  const message = value instanceof Error ? value.message : String(value);
2549
2848
  this.fail(`could not obtain the cloudflared binary: ${message}`);
2550
2849
  });
@@ -3418,6 +3717,8 @@ const Config = z.object({
3418
3717
  publicBaseUrl: z.string(),
3419
3718
  devicesFile: z.string(),
3420
3719
  autoTunnel: z.boolean().default(false),
3720
+ tunnelToken: z.string().role("secret"),
3721
+ relay: z.boolean().default(true),
3421
3722
  lanBind: z.boolean(),
3422
3723
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
3423
3724
  enabled: z.boolean().default(true)
@@ -3455,6 +3756,8 @@ const DEFAULTS = {
3455
3756
  publicBaseUrl: void 0,
3456
3757
  devicesFile: defaultDevicesFile(),
3457
3758
  autoTunnel: false,
3759
+ tunnelToken: void 0,
3760
+ relay: true,
3458
3761
  lanBind: void 0,
3459
3762
  profile: process.env.DSH_PROFILE ?? "web",
3460
3763
  enabled: true
@@ -3476,6 +3779,8 @@ function applyImpl(ctx, config) {
3476
3779
  publicBaseUrl: config?.publicBaseUrl,
3477
3780
  devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
3478
3781
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
3782
+ tunnelToken: config?.tunnelToken,
3783
+ relay: config?.relay ?? DEFAULTS.relay,
3479
3784
  lanBind: config?.lanBind,
3480
3785
  profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
3481
3786
  enabled: config?.enabled ?? DEFAULTS.enabled
@@ -3493,6 +3798,8 @@ function applyImpl(ctx, config) {
3493
3798
  publicBaseUrl: value.publicBaseUrl,
3494
3799
  devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
3495
3800
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
3801
+ tunnelToken: value.tunnelToken,
3802
+ relay: value.relay ?? DEFAULTS.relay,
3496
3803
  lanBind: value.lanBind,
3497
3804
  profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
3498
3805
  enabled: value.enabled ?? DEFAULTS.enabled
@@ -3500,21 +3807,64 @@ function applyImpl(ctx, config) {
3500
3807
  };
3501
3808
  const service = new PairingService(pairingConfigOf(resolved));
3502
3809
  const tunnel = new TunnelManager();
3503
- let autoTunnel = resolved.autoTunnel;
3810
+ let relayRegistrar;
3811
+ let relayUrl;
3812
+ let rawTunnelUrl;
3813
+ /** The tunnel target the registrar last announced (dedupes sync re-runs). */
3814
+ let relayAnnouncedFor;
3815
+ const setPublicBase = () => {
3816
+ service.setPublicBaseUrl(relayUrl ?? rawTunnelUrl);
3817
+ };
3818
+ const disposeRelayRegistrar = (unregister = false) => {
3819
+ const registrar = relayRegistrar;
3820
+ relayRegistrar = void 0;
3821
+ relayUrl = void 0;
3822
+ relayAnnouncedFor = void 0;
3823
+ if (registrar === void 0) return;
3824
+ if (unregister) registrar.unregister().catch(() => void 0);
3825
+ registrar.dispose();
3826
+ };
3827
+ const ensureRelayRegistrar = () => {
3828
+ if (resolve().relay === false) return void 0;
3829
+ if (relayRegistrar === void 0) try {
3830
+ relayRegistrar = new RelayRegistrar(loadRelayIdentity(resolve().profile), (state) => {
3831
+ service.setRelayStatus(state.state === "off" ? void 0 : state);
3832
+ if (state.state === "running") relayUrl = state.url;
3833
+ else if (state.state === "off") relayUrl = void 0;
3834
+ else if (state.state === "failed") console.warn(`remote-web-ui: relay registration failed (${state.error}) — the stable origin may serve its offline page until the retry lands`);
3835
+ setPublicBase();
3836
+ });
3837
+ } catch (error) {
3838
+ console.warn(`remote-web-ui: relay registry unavailable (${error instanceof Error ? error.message : String(error)}) — the quick URL is the QR base`);
3839
+ }
3840
+ return relayRegistrar;
3841
+ };
3842
+ const announceRelay = (registrar, target) => {
3843
+ if (relayAnnouncedFor === target) return;
3844
+ relayAnnouncedFor = target;
3845
+ registrar.announce(target);
3846
+ };
3847
+ let tunnelMode = resolved.autoTunnel ? "quick" : "off";
3504
3848
  tunnel.onPhase((info) => {
3505
- if (!autoTunnel) return;
3849
+ if (tunnelMode === "off") return;
3506
3850
  if (info.phase === "running" && info.url !== void 0) {
3507
- service.setPublicBaseUrl(info.url);
3851
+ rawTunnelUrl = info.url;
3852
+ setPublicBase();
3508
3853
  service.setTunnelStatus({
3509
3854
  state: "running",
3510
3855
  url: info.url
3511
3856
  });
3857
+ const registrar = tunnelMode === "quick" ? ensureRelayRegistrar() : void 0;
3858
+ if (registrar !== void 0) announceRelay(registrar, info.url);
3512
3859
  runPostureProbe();
3513
3860
  } else if (info.phase === "starting") {
3514
- service.setPublicBaseUrl(void 0);
3861
+ rawTunnelUrl = void 0;
3862
+ relayUrl = void 0;
3863
+ setPublicBase();
3515
3864
  service.setTunnelStatus({ state: "starting" });
3516
3865
  } else if (info.phase === "failed") {
3517
- service.setPublicBaseUrl(void 0);
3866
+ rawTunnelUrl = void 0;
3867
+ setPublicBase();
3518
3868
  service.setTunnelStatus(info.error === void 0 ? { state: "failed" } : {
3519
3869
  state: "failed",
3520
3870
  error: info.error
@@ -3522,6 +3872,7 @@ function applyImpl(ctx, config) {
3522
3872
  }
3523
3873
  });
3524
3874
  ctx.effect(() => () => {
3875
+ disposeRelayRegistrar();
3525
3876
  tunnel.dispose();
3526
3877
  }, "remote-web-ui: auto tunnel");
3527
3878
  if ((ctx.webServer.host === "0.0.0.0" || ctx.webServer.host === "127.0.0.1") && Number.isFinite(ctx.webServer.port)) {
@@ -3762,12 +4113,35 @@ function applyImpl(ctx, config) {
3762
4113
  else console.error("remote-web-ui: the host firewall rule could not be updated (admin rights required on managed platforms)");
3763
4114
  }
3764
4115
  }
3765
- autoTunnel = value.autoTunnel === true;
3766
- if (autoTunnel) {
3767
- if (value.publicBaseUrl !== void 0) console.warn("remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl");
3768
- tunnel.start(`http://127.0.0.1:${String(ctx.webServer.port)}`);
3769
- } else {
4116
+ const plan = tunnelPlanOf(value, ctx.webServer.port);
4117
+ tunnelMode = plan.mode;
4118
+ if (plan.mode !== "quick") {
4119
+ disposeRelayRegistrar();
4120
+ if (plan.mode !== "named") setPublicBase();
4121
+ } else if (value.relay === false) {
4122
+ disposeRelayRegistrar(true);
4123
+ setPublicBase();
4124
+ } else if (rawTunnelUrl !== void 0) {
4125
+ const registrar = ensureRelayRegistrar();
4126
+ if (registrar !== void 0) announceRelay(registrar, rawTunnelUrl);
4127
+ }
4128
+ if (plan.mode === "quick") {
4129
+ for (const ignored of plan.ignored) console.warn(`remote-web-ui: autoTunnel is on — ignoring the configured ${ignored}`);
4130
+ const registrar = ensureRelayRegistrar();
4131
+ const originHostHeader = registrar === void 0 ? void 0 : new URL(registrar.baseUrl).host;
4132
+ tunnel.start(originHostHeader === void 0 ? plan.targetUrl : {
4133
+ kind: "quick",
4134
+ targetUrl: plan.targetUrl,
4135
+ originHostHeader
4136
+ });
4137
+ } else if (plan.mode === "named") tunnel.start({
4138
+ kind: "named",
4139
+ token: plan.token,
4140
+ publicUrl: plan.publicUrl
4141
+ });
4142
+ else {
3770
4143
  tunnel.stop();
4144
+ if (value.tunnelToken !== void 0 && value.tunnelToken !== "") console.warn("remote-web-ui: tunnelToken is set but publicBaseUrl is missing or not a valid URL — fill the fixed public hostname of the named tunnel (e.g. https://dsh.example.com) to run it");
3771
4145
  if (value.publicBaseUrl !== void 0 && !isHttpUrl(value.publicBaseUrl)) {
3772
4146
  console.warn(`remote-web-ui: ignoring malformed publicBaseUrl ${JSON.stringify(value.publicBaseUrl)} (expected https://host[:port])`);
3773
4147
  service.setPublicBaseUrl(void 0);
@@ -3827,14 +4201,5 @@ function applyImpl(ctx, config) {
3827
4201
  });
3828
4202
  sync();
3829
4203
  }
3830
- /** Whether a configured public base is a parseable http(s) URL with a host. */
3831
- function isHttpUrl(value) {
3832
- try {
3833
- const url = new URL(value);
3834
- return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
3835
- } catch {
3836
- return false;
3837
- }
3838
- }
3839
4204
  //#endregion
3840
4205
  export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, defaultDevicesFile, inject, name, pairingConfigOf };
@@ -1 +1 @@
1
- {"version":3,"file":"RemoteEntry.d.ts","sourceRoot":"","sources":["../../../src/client/RemoteEntry.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAQnE,0EAA0E;AAC1E,MAAM,MAAM,gBAAgB,GAAG,WAAW,CAAC,QAAQ,CAAC,GAAG;IACrD,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;CACd,CAAA;AA0BD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BA2LxD"}
1
+ {"version":3,"file":"RemoteEntry.d.ts","sourceRoot":"","sources":["../../../src/client/RemoteEntry.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAQnE,0EAA0E;AAC1E,MAAM,MAAM,gBAAgB,GAAG,WAAW,CAAC,QAAQ,CAAC,GAAG;IACrD,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;CACd,CAAA;AA2BD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BA2LxD"}
@@ -36,6 +36,7 @@ function mergeFrame(state, frame) {
36
36
  onlineCount: frame.onlineCount,
37
37
  devices: frame.devices ?? [],
38
38
  ...(frame.tunnel !== undefined ? { tunnel: frame.tunnel } : {}),
39
+ ...(frame.relay !== undefined ? { relay: frame.relay } : {}),
39
40
  ...(frame.posture !== undefined ? { posture: frame.posture } : {}),
40
41
  };
41
42
  }
@@ -1,6 +1,6 @@
1
1
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import type { PairingPhase } from '../pairing.ts';
3
- import { type DeviceFrame, type PostureFrame, type TunnelStatusFrame } from './pair-api.ts';
3
+ import { type DeviceFrame, type PostureFrame, type RelayStatusFrame, type TunnelStatusFrame } from './pair-api.ts';
4
4
  /** The panel's view state, owned by the entry component. */
5
5
  export type PanelState = {
6
6
  kind: 'lan-required';
@@ -29,6 +29,8 @@ export type PanelState = {
29
29
  publicBaseUrl?: string;
30
30
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
31
31
  tunnel?: TunnelStatusFrame;
32
+ /** Relay-registry status, while the stable-origin relay is in play. */
33
+ relay?: RelayStatusFrame;
32
34
  /** Latest /api posture probe, once a round has completed. */
33
35
  posture?: PostureFrame;
34
36
  };
@@ -1 +1 @@
1
- {"version":3,"file":"RemotePanel.d.ts","sourceRoot":"","sources":["../../../src/client/RemotePanel.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;AAItB,4DAA4D;AAC5D,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,MAAM,CAAC,EAAE,iBAAiB,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,YAAY,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,2DAA2D;IAC3D,OAAO,EAAE,WAAW,EAAE,CAAA;IACtB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAA;IACf,yDAAyD;IACzD,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,yEAAyE;IACzE,MAAM,EAAE,OAAO,CAAA;IACf,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,6DAA6D;IAC7D,OAAO,CAAC,EAAE,YAAY,CAAA;CACvB,CAAA;AAEL,qDAAqD;AACrD,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;IACxB,KAAK,EAAE,UAAU,CAAA;IACjB,MAAM,EAAE,OAAO,CAAA;IACf,OAAO,IAAI,IAAI,CAAA;IACf,MAAM,IAAI,IAAI,CAAA;IACd,SAAS,IAAI,IAAI,CAAA;IACjB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,sDAAsD;IACtD,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,oEAAoE;IACpE,YAAY,IAAI,IAAI,CAAA;IACpB,gCAAgC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAgBD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE,gBAAgB,+BA+J5I"}
1
+ {"version":3,"file":"RemotePanel.d.ts","sourceRoot":"","sources":["../../../src/client/RemotePanel.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;AAItB,4DAA4D;AAC5D,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,MAAM,CAAC,EAAE,iBAAiB,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IACE,IAAI,EAAE,OAAO,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,YAAY,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;IACnB,2DAA2D;IAC3D,OAAO,EAAE,WAAW,EAAE,CAAA;IACtB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAA;IACf,yDAAyD;IACzD,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,yEAAyE;IACzE,MAAM,EAAE,OAAO,CAAA;IACf,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,uEAAuE;IACvE,KAAK,CAAC,EAAE,gBAAgB,CAAA;IACxB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,YAAY,CAAA;CACvB,CAAA;AAEL,qDAAqD;AACrD,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;IACxB,KAAK,EAAE,UAAU,CAAA;IACjB,MAAM,EAAE,OAAO,CAAA;IACf,OAAO,IAAI,IAAI,CAAA;IACf,MAAM,IAAI,IAAI,CAAA;IACd,SAAS,IAAI,IAAI,CAAA;IACjB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,sDAAsD;IACtD,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,oEAAoE;IACpE,YAAY,IAAI,IAAI,CAAA;IACpB,gCAAgC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAgBD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE,gBAAgB,+BAuK5I"}