locadot 2.6.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.0 (2026-10-02)
4
+
5
+ ### Added
6
+ - Change which hosts a viewer sees after pairing: ⋯ → **Choose hosts…** on a viewer under Remote → Peers, `locadot
7
+ peers:hosts <id> [hosts] [--add …] [--remove …]`, or `PUT /api/peers/:id {"hosts": [...]}`. Peers now show the hosts
8
+ they see.
9
+ - Opening an unmapped `*.localhost` on a connected machine syncs with the machines it's connected to first, then
10
+ carries on to the page if the name turned up. Otherwise the "not mapped" page says what each sync found, or its
11
+ error, and has a **Sync again** link.
12
+ - A locadot logo (a padlock whose keyhole is the "dot"). The dashboard and sign-in page use it as their favicon
13
+ (`/favicon.svg`; `/favicon.ico` serves the same) and in the sidebar.
14
+
15
+ ### Changed
16
+ - Syncing a connection now also removes mappings (and aliases) the sender no longer shares with you, instead of
17
+ leaving them behind. `remote:sync` lists them.
18
+ - The proxy's own pages ("not mapped", "upstream unreachable" and a remote's localhost page) have the dashboard's
19
+ look: light and dark themes, the logo, buttons for the dashboard and docs, and copyable `locadot add` commands.
20
+
3
21
  ## 2.6.1 (2026-09-30)
4
22
 
5
23
  ### Fixed
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <img src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/logo.svg" alt="" width="72" align="right">
2
+
1
3
  # locadot 🔐
2
4
 
3
5
  [![npm](https://img.shields.io/npm/v/locadot.svg)](https://www.npmjs.com/package/locadot)
@@ -152,8 +154,9 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
152
154
  | `locadot hub:panel <on\|off>` | Sender: serve this dashboard to any browser at the hub's public URL, behind the [dashboard password](#dashboard-password). Off by default. See [Sharing the dashboard](#sharing-the-dashboard-in-a-browser). |
153
155
  | `locadot share --role viewer\|editor\|admin [--hosts a.localhost,b.localhost]` | Sender: print a pairing string. It works once and expires after 5 minutes. `--hosts` limits a viewer to those mappings. |
154
156
  | `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
157
+ | `locadot peers:hosts <id> [a.localhost,b.localhost] [--add …] [--remove …]` | Sender: show or change the mappings a viewer sees after pairing. A list replaces them; `--add`/`--remove` edit them. Taking one away cuts it off at once; the viewer gets the new list on its next sync. Also under ⋯ → **Choose hosts…** on a viewer in the dashboard. |
155
158
  | `locadot connect "<pairing string>" [--name alice] [--domain dev]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. Admins get a local domain that opens the sender's dashboard and localhost (see below); `--domain` picks it, otherwise it's a random `adjective-noun`. |
156
- | `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections (with their domain), pull new mappings, or remove the connection and its names. |
159
+ | `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections (with their domain), pull new mappings and drop ones no longer shared, or remove the connection and its names. |
157
160
  | `locadot remote:domain <name> [domain] [--off]` | Receiver, admin only: set the local domain for a remote's localhost, randomize it (no domain given), or remove it (`--off`). |
158
161
  | `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
159
162
  | `locadot remote:add\|remote:update\|remote:rm <name> …` | Receiver: change mappings on the sender (editor or admin). |
@@ -175,6 +178,11 @@ headers work as they do locally, and origins in the sender's pages are rewritten
175
178
  An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` mapping points at that port.
176
179
  `remote:sync` picks up a sender turning `--cors` on or off.
177
180
 
181
+ Opening a name that isn't mapped on the receiver (say a host the sender just added, or assigned to you) syncs every
182
+ connected machine first, then sends the browser back to the same URL if the name turned up. If it didn't, the "not mapped"
183
+ page lists what each sync found or why it failed, with a **Sync again** link. Requests through a tunnel or the hub never
184
+ trigger a sync, and a sync less than 10 seconds old is reused (2 seconds for **Sync again**).
185
+
178
186
  When a target is down, requests through the hub or a public share get a **503** with locadot's "upstream unreachable" page.
179
187
  Cloudflare replaces an origin's 502 with its own generic "bad gateway" error, which would hide the real cause.
180
188
 
@@ -295,6 +303,7 @@ each time the proxy starts.
295
303
  | `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on) and `panel` (is the dashboard shared). |
296
304
  | `PUT /api/hub/localhost` | `{ "enabled": true }` | Sender: turn localhost access for admin peers on/off (400 if the hub isn't configured). |
297
305
  | `PUT /api/hub/panel` | `{ "enabled": true }` | Sender: share the dashboard at the hub's public URL on/off (400 if the hub isn't configured, or no dashboard password is set). |
306
+ | `PUT /api/peers/:id` | `{ "role": "viewer", "hosts": ["app.localhost"] }` or `{ "hosts": [...] }` | Sender: change a peer's role, or only a viewer's mappings (400 for an editor or admin, 404 unknown). |
298
307
  | `GET /api/remotes` | | Every remote (never the token), including `domain` and `localhost`. |
299
308
  | `POST /api/remotes` | `{ "string": "<pairing string>", "name": "alice", "domain": "dev" }` | Receiver: connect using a pairing string; `domain` is optional (admin only, random if omitted). |
300
309
  | `PUT /api/remotes/:name` | `{ "domain": "dev" }` or `{ "domain": "random" }` or `{ "domain": null }` | Receiver: set, randomize or remove a remote's local domain (400 invalid, 409 clash, 404 unknown). |
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -13,6 +46,7 @@ exports.hubPanel = hubPanel;
13
46
  exports.share = share;
14
47
  exports.peers = peers;
15
48
  exports.peersRole = peersRole;
49
+ exports.peersHosts = peersHosts;
16
50
  exports.peersRevoke = peersRevoke;
17
51
  exports.connect = connect;
18
52
  exports.remotes = remotes;
@@ -31,7 +65,7 @@ const localhost_1 = require("../../lib/localhost");
31
65
  const urls_1 = require("../../lib/urls");
32
66
  const registry_1 = __importDefault(require("../../lib/registry"));
33
67
  const file_1 = __importDefault(require("../../utils/file"));
34
- const links_1 = __importDefault(require("../../lib/links"));
68
+ const links_1 = __importStar(require("../../lib/links"));
35
69
  const hub_config_1 = __importDefault(require("../../lib/hub-config"));
36
70
  const hub_tunnel_1 = require("../../proxy/hub-tunnel");
37
71
  const remotes_1 = __importDefault(require("../../lib/remotes"));
@@ -235,6 +269,20 @@ async function peersRole(id, role, options) {
235
269
  const peer = links_1.default.setRole(id, parsedRole, hosts);
236
270
  (0, shared_1.print)(`✅ ${peer.name} is now ${peer.role}${peer.hosts ? ` [${peer.hosts.join(", ")}]` : ""}.`);
237
271
  }
272
+ /** A viewer's hosts: replace them, or --add / --remove some; with neither, print them. */
273
+ async function peersHosts(id, hosts, options) {
274
+ const peer = links_1.default.list().peers.find((p) => p.id === id);
275
+ if (!peer)
276
+ throw new links_1.LinkError(404, "Peer not found.");
277
+ if (hosts === undefined && !options.add && !options.remove) {
278
+ (0, shared_1.print)(peer.role === "viewer" ? `${peer.name}: ${peer.hosts?.length ? peer.hosts.join(", ") : "no hosts"}` : `${peer.name} is ${peer.role} and sees every host.`);
279
+ return;
280
+ }
281
+ const remove = new Set(splitHosts(options.remove)?.map((h) => h.toLowerCase()));
282
+ const next = [...(hosts !== undefined ? splitHosts(hosts) ?? [] : peer.hosts ?? []), ...(splitHosts(options.add) ?? [])].filter((h) => !remove.has(h.toLowerCase()));
283
+ const updated = links_1.default.setHosts(id, next);
284
+ (0, shared_1.print)(`✅ ${updated.name} now sees ${updated.hosts?.length ? updated.hosts.join(", ") : "no hosts"}. They get the change on their next sync.`);
285
+ }
238
286
  async function peersRevoke(id) {
239
287
  links_1.default.revoke(id);
240
288
  (0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
@@ -277,10 +325,12 @@ async function remotes(options) {
277
325
  }
278
326
  }
279
327
  async function remoteSync(name) {
280
- const { remote, mapped } = await remotes_1.default.sync(name);
328
+ const { remote, mapped, removed } = await remotes_1.default.sync(name);
281
329
  (0, shared_1.print)(`✅ ${name} synced.`);
282
330
  for (const m of mapped)
283
331
  (0, shared_1.print)(` + ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
332
+ for (const local of removed)
333
+ (0, shared_1.print)(` - ${(0, urls_1.urlFor)(local)} (no longer shared)`);
284
334
  printDomain(remote);
285
335
  }
286
336
  async function remoteDomain(name, domain, options) {
@@ -2,87 +2,188 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.upstreamDown = exports.remoteLocalhost = exports.proxyNotFound = void 0;
4
4
  const escape_1 = require("../dashboard/escape");
5
- const page = (title, body, dashboardUrl) => `<!DOCTYPE html>
5
+ const logo_1 = require("../dashboard/logo");
6
+ const theme_1 = require("../dashboard/ui/theme");
7
+ /** The proxy's own pages (not mapped, upstream unreachable, remote localhost), in the dashboard's look. */
8
+ const page = (opts) => `<!DOCTYPE html>
6
9
  <html lang="en">
7
10
  <head>
8
11
  <meta charset="UTF-8" />
9
12
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
- <title>${(0, escape_1.escapeHtml)(title)}</title>
13
+ <meta name="color-scheme" content="dark light" />
14
+ <title>${(0, escape_1.escapeHtml)(opts.title)}</title>
11
15
  <style>
16
+ :root {${theme_1.darkVars}
17
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
18
+ --sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
19
+ }
20
+ @media (prefers-color-scheme: light) { :root {${theme_1.lightVars}} }
21
+ * { box-sizing: border-box; }
12
22
  body {
13
- background-color: #121212;
14
- color: #f8d7da;
15
- font-family: 'Segoe UI', system-ui, sans-serif;
16
- margin: 0;
17
- padding: 20px;
18
- display: flex;
19
- align-items: center;
20
- justify-content: center;
21
- min-height: 90vh;
23
+ margin: 0; min-height: 100vh; padding: 24px;
24
+ display: flex; align-items: center; justify-content: center;
25
+ background: var(--bg); color: var(--fg);
26
+ font: 14px/1.55 var(--sans); -webkit-font-smoothing: antialiased;
27
+ }
28
+ a { color: var(--accent); text-decoration: none; }
29
+ a:hover { text-decoration: underline; text-underline-offset: 2px; }
30
+ *:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
31
+ .card {
32
+ width: 100%; max-width: 600px; background: var(--surface); border: 1px solid var(--border);
33
+ border-radius: 16px; box-shadow: var(--shadow-md); overflow: hidden;
34
+ }
35
+ .head { display: flex; align-items: center; gap: 10px; padding: 16px 24px; border-bottom: 1px solid var(--border); }
36
+ .logo { width: 24px; height: 24px; flex: none; }
37
+ .logo svg { display: block; width: 100%; height: 100%; }
38
+ .wordmark { font-weight: 700; font-size: 15px; letter-spacing: -0.01em; }
39
+ .badge {
40
+ margin-left: auto; display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px;
41
+ border-radius: 999px; font-size: 11.5px; font-weight: 600; white-space: nowrap;
22
42
  }
23
- .error-box {
24
- background-color: #1e1e1e;
25
- border: 1px solid #d9534f;
26
- padding: 24px;
27
- border-radius: 8px;
28
- max-width: 640px;
29
- width: 100%;
30
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.7);
31
- line-height: 1.6;
43
+ .badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
44
+ .tone-down { color: var(--down); background: var(--down-bg); }
45
+ .tone-warn { color: var(--warn); background: var(--warn-bg); }
46
+ .tone-accent { color: var(--accent-strong); background: var(--accent-bg); }
47
+ .body { padding: 24px; }
48
+ h1 { margin: 0 0 6px; font-size: 22px; line-height: 1.25; letter-spacing: -0.015em; }
49
+ p { margin: 0 0 14px; color: var(--fg-2); }
50
+ .muted { color: var(--muted); }
51
+ code { font-family: var(--mono); font-size: 0.92em; background: var(--surface-2); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; color: var(--fg); overflow-wrap: anywhere; }
52
+ h2 { margin: 22px 0 8px; font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
53
+ .cmd {
54
+ display: flex; align-items: center; gap: 8px; margin: 0 0 8px; padding: 8px 8px 8px 12px;
55
+ background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
32
56
  }
33
- .error-box code {
34
- background: #2e2e2e;
35
- color: #ffd5d5;
36
- padding: 2px 6px;
37
- border-radius: 4px;
38
- font-family: monospace;
57
+ .cmd code { flex: 1; background: none; border: 0; padding: 0; line-height: 1.6; }
58
+ .cmd code::before { content: "$ "; color: var(--muted-dim); }
59
+ .list { list-style: none; margin: 0 0 12px; padding: 0; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
60
+ .list li { display: flex; align-items: baseline; gap: 10px; padding: 9px 12px; }
61
+ .list li + li { border-top: 1px solid var(--border); }
62
+ .list .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
63
+ .dot.up { background: var(--up); } .dot.down { background: var(--down); }
64
+ .list .name { font-family: var(--mono); font-size: 13px; font-weight: 600; }
65
+ .list .detail { color: var(--muted); font-size: 13px; }
66
+ .route { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 14px; }
67
+ .route .arrow { color: var(--muted); }
68
+ .note { padding: 10px 12px; border-radius: 10px; background: var(--down-bg); color: var(--fg-2); margin: 0 0 14px; }
69
+ .btn {
70
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; padding: 0 14px;
71
+ border-radius: 8px; border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--fg);
72
+ font: 600 13px/1 var(--sans); cursor: pointer; white-space: nowrap;
73
+ transition: background 120ms, border-color 120ms;
39
74
  }
40
- .error-box a { color: #61dafb; text-decoration: none; font-weight: bold; }
41
- .error-box a:hover { text-decoration: underline; }
75
+ .btn:hover { text-decoration: none; background: var(--surface-3); }
76
+ .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
77
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
78
+ .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
79
+ .btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
80
+ .btn-ghost:hover { background: var(--surface-3); color: var(--fg); }
81
+ .foot { display: flex; flex-wrap: wrap; gap: 8px; padding: 16px 24px; border-top: 1px solid var(--border); background: var(--surface-2); }
82
+ .foot .spacer { flex: 1; }
83
+ form.go { display: flex; gap: 8px; margin: 0 0 8px; }
84
+ .input {
85
+ flex: 1; min-width: 0; height: 34px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--border-strong);
86
+ background: var(--bg); color: var(--fg); font: 14px var(--mono);
87
+ }
88
+ .input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
89
+ @media (max-width: 520px) { body { padding: 12px; } .body, .head, .foot { padding-left: 16px; padding-right: 16px; } }
42
90
  </style>
43
91
  </head>
44
92
  <body>
45
- <div class="error-box">
46
- ${body}
47
- <br/><br/>
48
- <a href="${(0, escape_1.escapeHtml)(dashboardUrl)}">📋 Open the locadot dashboard</a> ·
49
- <a href="https://github.com/avinashid/locadot#readme" target="_blank" rel="noopener noreferrer">📘 Docs</a>
50
- </div>
93
+ <main class="card">
94
+ <div class="head">
95
+ <span class="logo" aria-hidden="true">${logo_1.LOGO_SVG}</span>
96
+ <span class="wordmark">locadot</span>
97
+ <span class="badge tone-${opts.tone}">${(0, escape_1.escapeHtml)(opts.badge)}</span>
98
+ </div>
99
+ <div class="body">
100
+ <h1>${opts.heading}</h1>
101
+ ${opts.body}
102
+ </div>
103
+ <div class="foot">
104
+ <a class="btn btn-primary" href="${(0, escape_1.escapeHtml)(opts.dashboardUrl)}">Open the dashboard</a>
105
+ <a class="btn" href="https://github.com/avinashid/locadot#readme" target="_blank" rel="noopener noreferrer">Docs</a>
106
+ </div>
107
+ </main>
108
+ <script>
109
+ document.querySelectorAll("[data-copy]").forEach(function (b) {
110
+ b.addEventListener("click", function () {
111
+ var text = b.parentNode.querySelector("code").textContent;
112
+ var done = function () { b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1500); };
113
+ if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, function () {});
114
+ });
115
+ });
116
+ </script>
51
117
  </body>
52
118
  </html>
53
119
  `;
54
- const proxyNotFound = (host, dashboardUrl) => page("locadot: host not mapped", `<strong style="color: #ff6b6b;">Not mapped:</strong>
55
- no locadot mapping exists for <code>${(0, escape_1.escapeHtml)(host)}</code>.<br/><br/>
56
- Add one with<br/>
57
- <code>npx locadot add --host ${(0, escape_1.escapeHtml)(host)} --port PORT</code><br/>
58
- or<br/>
59
- <code>npx locadot add --host ${(0, escape_1.escapeHtml)(host)} --target https://example.com</code>`, dashboardUrl);
120
+ const cmd = (text) => `<div class="cmd"><code>${(0, escape_1.escapeHtml)(text)}</code><button type="button" class="btn btn-sm btn-ghost" data-copy>Copy</button></div>`;
121
+ const syncLine = (r) => {
122
+ if (!r.ok)
123
+ return `<li><span class="dot down"></span><span class="name">${(0, escape_1.escapeHtml)(r.name)}</span><span class="detail">couldn't sync: ${(0, escape_1.escapeHtml)(r.error || "unknown error")}</span></li>`;
124
+ const changes = [...r.added.map((h) => `+${h}`), ...r.removed.map((h) => `−${h}`)];
125
+ return `<li><span class="dot up"></span><span class="name">${(0, escape_1.escapeHtml)(r.name)}</span><span class="detail">synced, ${changes.length ? (0, escape_1.escapeHtml)(changes.join(", ")) : "nothing new"}</span></li>`;
126
+ };
127
+ /** `sync`: when there are connected machines, what syncing with them just now found, and the "Sync again" link. */
128
+ const proxyNotFound = (host, dashboardUrl, sync) => page({
129
+ title: "locadot: host not mapped",
130
+ badge: "Not mapped",
131
+ tone: "down",
132
+ heading: "Not mapped",
133
+ body: `<p>No locadot mapping exists for <code>${(0, escape_1.escapeHtml)(host)}</code>.</p>
134
+ ${sync
135
+ ? `<h2>Connected machines</h2>
136
+ <p class="muted">Synced just now; none of them shares <code>${(0, escape_1.escapeHtml)(host)}</code> with you.</p>
137
+ <ul class="list">${sync.reports.map(syncLine).join("")}</ul>
138
+ <p><a class="btn" href="${(0, escape_1.escapeHtml)(sync.href)}">🔄 Sync again</a></p>
139
+ <p class="muted">If it should be shared with you, ask whoever shared their locadot to add it to your hosts. Or map it here:</p>`
140
+ : `<h2>Map it</h2>`}
141
+ ${cmd(`npx locadot add --host ${host} --port PORT`)}
142
+ ${cmd(`npx locadot add --host ${host} --target https://example.com`)}`,
143
+ dashboardUrl,
144
+ });
60
145
  exports.proxyNotFound = proxyNotFound;
61
- const upstreamDown = (host, target, reason, dashboardUrl) => page("locadot: upstream unreachable", `<strong style="color: #ff6b6b;">Upstream unreachable:</strong>
62
- <code>${(0, escape_1.escapeHtml)(host)}</code> → <code>${(0, escape_1.escapeHtml)(target)}</code><br/><br/>
63
- ${(0, escape_1.escapeHtml)(reason)}. Is the app running?`, dashboardUrl);
146
+ const upstreamDown = (host, target, reason, dashboardUrl) => page({
147
+ title: "locadot: upstream unreachable",
148
+ badge: "Upstream unreachable",
149
+ tone: "down",
150
+ heading: "Upstream unreachable",
151
+ body: `<div class="route"><code>${(0, escape_1.escapeHtml)(host)}</code><span class="arrow">→</span><code>${(0, escape_1.escapeHtml)(target)}</code></div>
152
+ <div class="note">${(0, escape_1.escapeHtml)(reason)}</div>
153
+ <p class="muted">Is the app running? Start it, then reload this page.</p>`,
154
+ dashboardUrl,
155
+ });
64
156
  exports.upstreamDown = upstreamDown;
65
157
  /** Receiver side: `<domain>.localhost/_locadot/ports`, the port picker and shared hosts of an admin remote. */
66
- const remoteLocalhost = (opts) => page(`locadot: ${opts.sender} localhost`, opts.allowed
67
- ? `<strong style="color: #7ee2a8;">${(0, escape_1.escapeHtml)(opts.sender)}</strong>: localhost<br/><br/>
68
- Its dashboard is <a href="/">${(0, escape_1.escapeHtml)(opts.domain)}.localhost</a>.<br/>
69
- Any port on that machine is at <code>${(0, escape_1.escapeHtml)(opts.portUrl.replace("PORT", "<port>"))}</code>.<br/><br/>
70
- <form id="go" style="display:flex;gap:8px">
71
- <input id="port" inputmode="numeric" pattern="[0-9]{1,5}" placeholder="3000" required
72
- style="flex:1;padding:6px 10px;border-radius:4px;border:1px solid #444;background:#2e2e2e;color:#fff" />
73
- <button style="padding:6px 14px;border-radius:4px;border:0;background:#61dafb;color:#000;font-weight:bold">Open</button>
74
- </form>
75
- <script>
76
- document.getElementById("go").addEventListener("submit", function (e) {
77
- e.preventDefault();
78
- var port = document.getElementById("port").value.trim();
79
- if (/^[0-9]{1,5}$/.test(port)) location.href = ${JSON.stringify(opts.portUrl)}.replace("PORT", port);
80
- });
81
- </script>
82
- ${opts.hosts.length
83
- ? `<br/>Shared hosts:<br/>${opts.hosts.map((h) => `<a href="${(0, escape_1.escapeHtml)(h.url)}">${(0, escape_1.escapeHtml)(h.local)}</a>`).join("<br/>")}`
84
- : ""}`
85
- : `<strong style="color: #ff6b6b;">Not available:</strong>
86
- <code>${(0, escape_1.escapeHtml)(opts.domain)}.localhost</code> reaches ${(0, escape_1.escapeHtml)(opts.sender)}'s localhost only while you're an
87
- admin there and it allows localhost access. Ask its owner, then run <code>locadot remote:sync ${(0, escape_1.escapeHtml)(opts.name)}</code>.`, opts.dashboardUrl);
158
+ const remoteLocalhost = (opts) => page({
159
+ title: `locadot: ${opts.sender} localhost`,
160
+ badge: opts.allowed ? "Remote" : "Not available",
161
+ tone: opts.allowed ? "accent" : "warn",
162
+ heading: `${(0, escape_1.escapeHtml)(opts.sender)}: localhost`,
163
+ body: opts.allowed
164
+ ? `<p>Its dashboard is <a href="/">${(0, escape_1.escapeHtml)(opts.domain)}.localhost</a>.
165
+ Any port on that machine is at <code>${(0, escape_1.escapeHtml)(opts.portUrl.replace("PORT", "<port>"))}</code>.</p>
166
+ <h2>Open a port</h2>
167
+ <form id="go" class="go">
168
+ <input id="port" class="input" inputmode="numeric" pattern="[0-9]{1,5}" placeholder="3000" required aria-label="Port" />
169
+ <button class="btn btn-primary">Open</button>
170
+ </form>
171
+ <script>
172
+ document.getElementById("go").addEventListener("submit", function (e) {
173
+ e.preventDefault();
174
+ var port = document.getElementById("port").value.trim();
175
+ if (/^[0-9]{1,5}$/.test(port)) location.href = ${JSON.stringify(opts.portUrl)}.replace("PORT", port);
176
+ });
177
+ </script>
178
+ ${opts.hosts.length
179
+ ? `<h2>Shared hosts</h2><ul class="list">${opts.hosts
180
+ .map((h) => `<li><span class="dot up"></span><a class="name" href="${(0, escape_1.escapeHtml)(h.url)}">${(0, escape_1.escapeHtml)(h.local)}</a></li>`)
181
+ .join("")}</ul>`
182
+ : ""}`
183
+ : `<p><code>${(0, escape_1.escapeHtml)(opts.domain)}.localhost</code> reaches ${(0, escape_1.escapeHtml)(opts.sender)}'s localhost only while you're an
184
+ admin there and it allows localhost access.</p>
185
+ <p class="muted">Ask its owner, then run:</p>
186
+ ${cmd(`locadot remote:sync ${opts.name}`)}`,
187
+ dashboardUrl: opts.dashboardUrl,
188
+ });
88
189
  exports.remoteLocalhost = remoteLocalhost;
@@ -535,9 +535,11 @@ async function route(req, url, ctx) {
535
535
  }
536
536
  if (peerMatch && method === "PUT") {
537
537
  const id = decodeURIComponent(peerMatch[1]);
538
+ const hosts = optionalHosts(body.hosts);
539
+ if (body.role === undefined && hosts !== undefined)
540
+ return { status: 200, body: { peer: sanitizePeer(links_1.default.setHosts(id, hosts)) } };
538
541
  if (!isRole(body.role))
539
542
  throw new ApiError(400, "`role` must be viewer, editor or admin.");
540
- const hosts = optionalHosts(body.hosts);
541
543
  const peer = links_1.default.setRole(id, body.role, hosts);
542
544
  return { status: 200, body: { peer: sanitizePeer(peer) } };
543
545
  }
@@ -40,6 +40,7 @@ exports.handleDashboardRequest = handleDashboardRequest;
40
40
  const crypto_1 = __importDefault(require("crypto"));
41
41
  const host_auth_1 = __importDefault(require("../lib/host-auth"));
42
42
  const page_1 = require("./page");
43
+ const logo_1 = require("./logo");
43
44
  const api_1 = require("./api");
44
45
  const request_1 = require("../proxy/request");
45
46
  const login_1 = require("./login");
@@ -149,9 +150,10 @@ function handleDashboardRequest(req, res, ctx) {
149
150
  case "/":
150
151
  sendBody(res, method, 200, "text/html; charset=utf-8", (0, page_1.renderPage)(nonce, (0, request_1.peerOriginOf)(req) ? api_1.PEER_TOKEN : ctx.token, ui_auth_1.default.enabled()));
151
152
  return;
153
+ case "/favicon.svg":
152
154
  case "/favicon.ico":
153
- res.statusCode = 204;
154
- res.end();
155
+ res.setHeader("Cache-Control", "public, max-age=86400");
156
+ sendBody(res, method, 200, "image/svg+xml", logo_1.LOGO_SVG);
155
157
  return;
156
158
  case "/healthz":
157
159
  sendBody(res, method, 200, "text/plain; charset=utf-8", "ok");
@@ -41,6 +41,7 @@ exports.gate = gate;
41
41
  const crypto_1 = __importDefault(require("crypto"));
42
42
  const logger_1 = __importDefault(require("../utils/logger"));
43
43
  const escape_1 = require("./escape");
44
+ const logo_1 = require("./logo");
44
45
  const api_1 = require("./api");
45
46
  const ui_auth_1 = __importStar(require("../lib/ui-auth"));
46
47
  const request_1 = require("../proxy/request");
@@ -53,6 +54,7 @@ const loginPage = (nonce, error) => `<!doctype html>
53
54
  <meta name="viewport" content="width=device-width, initial-scale=1">
54
55
  <meta name="color-scheme" content="dark light">
55
56
  <title>Sign in · locadot</title>
57
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
56
58
  <style nonce="${(0, escape_1.escapeHtml)(nonce)}">
57
59
  :root { --bg: #f6f7f9; --surface: #fff; --border: #e3e6eb; --fg: #111827; --muted: #6b7280; --accent: #2563eb; --down: #dc2626; }
58
60
  @media (prefers-color-scheme: dark) { :root { --bg: #0d1117; --surface: #161b22; --border: #30363d; --fg: #e6edf3; --muted: #8b949e; --accent: #3b82f6; --down: #f87171; } }
@@ -60,8 +62,7 @@ const loginPage = (nonce, error) => `<!doctype html>
60
62
  body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
61
63
  form { width: 100%; max-width: 340px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; display: flex; flex-direction: column; gap: 14px; }
62
64
  .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; }
63
- .logo { width: 24px; height: 24px; border-radius: 7px; background: var(--accent); position: relative; }
64
- .logo::after { content: ""; position: absolute; inset: 8px; border-radius: 50%; background: #fff; }
65
+ .logo { width: 24px; height: 24px; background: url("${logo_1.LOGO_DATA_URI}") center / contain no-repeat; }
65
66
  p { margin: 0; color: var(--muted); }
66
67
  label { font-weight: 600; font-size: 13px; }
67
68
  input { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font: inherit; }
@@ -111,7 +112,7 @@ const secure = (req) => ((0, request_1.isPanel)(req) ? (0, request_1.isTls)(req)
111
112
  const cookie = (req, value, maxAge) => `${ui_auth_1.SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? "; Secure" : ""}`;
112
113
  const sendLogin = (res, method, nonce, status, error) => {
113
114
  // The dashboard's CSP forbids form posts; the login form needs one.
114
- res.setHeader("Content-Security-Policy", `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
115
+ res.setHeader("Content-Security-Policy", `default-src 'none'; style-src 'nonce-${nonce}'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
115
116
  res.statusCode = status;
116
117
  res.setHeader("Content-Type", "text/html; charset=utf-8");
117
118
  res.end(method === "HEAD" ? undefined : loginPage(nonce, error));
@@ -207,7 +208,7 @@ function gate(req, res, url, token, nonce) {
207
208
  res.end(JSON.stringify({ ok: true }));
208
209
  return true;
209
210
  }
210
- if (path === "/healthz" || path === "/favicon.ico")
211
+ if (path === "/healthz" || path === "/favicon.ico" || path === "/favicon.svg")
211
212
  return false;
212
213
  if ((0, exports.signedIn)(req) || hasToken(req, token))
213
214
  return false;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LOGO_DATA_URI = exports.LOGO_SVG = void 0;
4
+ /**
5
+ * The locadot mark: a padlock on a blue tile, its body the "dot". Served as the favicon and used by the
6
+ * sidebar and sign-in page (.logo background). docs/logo.svg is the same drawing for the README.
7
+ */
8
+ exports.LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
9
+ `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
10
+ `<stop offset="0" stop-color="#4f8ff7"/><stop offset=".55" stop-color="#2563eb"/><stop offset="1" stop-color="#1e4fd6"/>` +
11
+ `</linearGradient></defs>` +
12
+ `<rect width="64" height="64" rx="15" fill="url(#g)"/>` +
13
+ `<path d="M23.5 30v-6.5a8.5 8.5 0 0 1 17 0V30" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>` +
14
+ `<rect x="15" y="28" width="34" height="24" rx="6" fill="#fff"/>` +
15
+ `<circle cx="32" cy="40" r="5" fill="#2563eb"/>` +
16
+ `</svg>`;
17
+ exports.LOGO_DATA_URI = `data:image/svg+xml,${encodeURIComponent(exports.LOGO_SVG)}`;
@@ -36,6 +36,7 @@ function renderPage(nonce, token, uiAuth = false) {
36
36
  <meta name="locadot-token" content="${safeToken}">
37
37
  <meta name="locadot-ui-auth" content="${uiAuth ? "on" : "off"}">
38
38
  <title>locadot dashboard</title>
39
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
39
40
  <script nonce="${safeNonce}">try { var t = localStorage.getItem("locadot.theme"); if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t); var s = localStorage.getItem("locadot.sidebar"); if (s === "collapsed" || (s !== "expanded" && window.innerWidth < 1100)) document.documentElement.setAttribute("data-sidebar", "collapsed"); } catch (e) {}</script>
40
41
  <style nonce="${safeNonce}">${theme_1.css}${hosts_1.css}${remote_1.css}${system_1.css}</style>
41
42
  </head>
@@ -56,6 +56,13 @@ exports.jsHub = ` // ---------- remote access: hub (sender) ----------
56
56
  sub.className = "muted";
57
57
  sub.textContent = "last seen " + fmtRelative(peer.lastSeen);
58
58
  main.appendChild(sub);
59
+ if (peer.role === "viewer") {
60
+ var seesLine = document.createElement("div");
61
+ seesLine.className = "muted peer-hosts";
62
+ var sees = peer.hosts || [];
63
+ seesLine.textContent = sees.length ? "sees " + sees.join(", ") : "sees no hosts";
64
+ main.appendChild(seesLine);
65
+ }
59
66
  row.appendChild(main);
60
67
 
61
68
  var meta = document.createElement("div");
@@ -80,9 +87,10 @@ exports.jsHub = ` // ---------- remote access: hub (sender) ----------
80
87
  });
81
88
  }
82
89
  meta.appendChild(makeMoreButton(function () {
83
- return ["viewer", "editor", "admin"].map(function (r) {
90
+ var items = peer.role === "viewer" ? [{ label: "Choose hosts\\u2026", icon: ICONS.edit, onSelect: function () { choosePeerHosts(peer); } }, "-"] : [];
91
+ return items.concat(["viewer", "editor", "admin"].map(function (r) {
84
92
  return { label: "Set as " + r.charAt(0).toUpperCase() + r.slice(1), disabled: r === peer.role, onSelect: function () { setRole(r); } };
85
- }).concat(["-", { label: "Revoke", icon: ICONS.trash, danger: true, onSelect: revoke }]);
93
+ })).concat(["-", { label: "Revoke", icon: ICONS.trash, danger: true, onSelect: revoke }]);
86
94
  }, { label: "Actions for " + peer.name, small: true }));
87
95
  row.appendChild(meta);
88
96
  peersListEl.appendChild(row);
@@ -90,6 +98,57 @@ exports.jsHub = ` // ---------- remote access: hub (sender) ----------
90
98
  });
91
99
  }
92
100
 
101
+ // Which of this machine's hosts a viewer peer sees; the peer picks the change up on its next sync.
102
+ var peerHostsDialog = null;
103
+ function choosePeerHosts(peer) {
104
+ if (!peerHostsDialog) {
105
+ peerHostsDialog = document.createElement("dialog");
106
+ peerHostsDialog.id = "peer-hosts-dialog";
107
+ peerHostsDialog.innerHTML =
108
+ '<div class="modal-head"><div><h3 class="modal-title"></h3><p class="modal-desc">They see only the hosts you tick. Changes reach them on their next sync.</p></div>' +
109
+ '<button type="button" class="btn btn-ghost modal-close" data-close aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div>' +
110
+ '<div class="modal-body"><div class="host-checks"></div><p class="dim" data-empty hidden>No hosts on this machine yet.</p></div>' +
111
+ '<div class="modal-foot"><button type="button" class="btn" data-close>Cancel</button><button type="button" class="btn btn-primary" data-save>Save</button></div>';
112
+ document.body.appendChild(peerHostsDialog);
113
+ Array.prototype.forEach.call(peerHostsDialog.querySelectorAll("[data-close]"), function (b) {
114
+ b.addEventListener("click", function () { peerHostsDialog.close(); });
115
+ });
116
+ peerHostsDialog.addEventListener("click", function (e) { if (e.target === peerHostsDialog) peerHostsDialog.close(); });
117
+ }
118
+ var dialog = peerHostsDialog;
119
+ dialog.querySelector(".modal-title").textContent = "Hosts " + peer.name + " sees";
120
+ var checks = dialog.querySelector(".host-checks");
121
+ clear(checks);
122
+ var current = {};
123
+ (peer.hosts || []).forEach(function (h) { current[h] = true; });
124
+ var own = lastHosts.filter(function (r) { return !r.remote; }).map(function (r) { return r.host; });
125
+ // Keep assigned names that have no mapping right now, so saving doesn't drop them silently.
126
+ (peer.hosts || []).forEach(function (h) { if (own.indexOf(h) < 0) own.push(h); });
127
+ own.forEach(function (h) {
128
+ var label = document.createElement("label");
129
+ var cb = document.createElement("input");
130
+ cb.type = "checkbox";
131
+ cb.value = h;
132
+ cb.checked = Boolean(current[h]);
133
+ label.appendChild(cb);
134
+ label.appendChild(document.createTextNode(h));
135
+ checks.appendChild(label);
136
+ });
137
+ dialog.querySelector("[data-empty]").hidden = own.length !== 0;
138
+ var save = dialog.querySelector("[data-save]");
139
+ var fresh = save.cloneNode(true);
140
+ save.parentNode.replaceChild(fresh, save);
141
+ fresh.addEventListener("click", function () {
142
+ var hosts = Array.prototype.filter.call(checks.querySelectorAll("input"), function (cb) { return cb.checked; }).map(function (cb) { return cb.value; });
143
+ fresh.disabled = true;
144
+ apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "PUT", body: JSON.stringify({ hosts: hosts }) })
145
+ .then(function () { dialog.close(); showToast("Updated hosts for " + peer.name, "success"); return loadHub(); })
146
+ .catch(function (err) { apiError(err, "Couldn't update hosts"); })
147
+ .then(function () { fresh.disabled = false; });
148
+ });
149
+ dialog.showModal();
150
+ }
151
+
93
152
  function renderInvites(invites) {
94
153
  invitesEmptyEl.hidden = invites.length !== 0;
95
154
  clear(invitesListEl);
@@ -1,4 +1,6 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.css = exports.darkVars = exports.lightVars = void 0;
2
4
  /**
3
5
  * The dashboard's styles: design tokens, base, app shell, shared components, pages, responsive rules.
4
6
  *
@@ -21,8 +23,7 @@
21
23
  * Motion .anim-in (one element), .anim-list (children stagger in); all motion off under prefers-reduced-motion
22
24
  * Tooltips data-tip="text" on any element (data-tip-pos="right|bottom|top")
23
25
  */
24
- Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.css = exports.lightVars = void 0;
26
+ const logo_1 = require("../logo");
26
27
  exports.lightVars = `
27
28
  color-scheme: light;
28
29
  --bg: #f7f7f8;
@@ -59,8 +60,7 @@ exports.lightVars = `
59
60
  --shadow-md: 0 4px 12px rgba(16, 16, 24, 0.08), 0 1px 3px rgba(16, 16, 24, 0.06);
60
61
  --shadow-lg: 0 16px 40px rgba(16, 16, 24, 0.16), 0 2px 6px rgba(16, 16, 24, 0.06);
61
62
  `;
62
- exports.css = `
63
- :root {
63
+ exports.darkVars = `
64
64
  color-scheme: dark;
65
65
  --bg: #09090b;
66
66
  --surface: #111114;
@@ -95,7 +95,9 @@ exports.css = `
95
95
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
96
96
  --shadow-md: 0 6px 16px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.02);
97
97
  --shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04);
98
-
98
+ `;
99
+ exports.css = `
100
+ :root {${exports.darkVars}
99
101
  --radius-xs: 6px;
100
102
  --radius-sm: 8px;
101
103
  --radius-md: 10px;
@@ -199,19 +201,10 @@ header {
199
201
  .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
200
202
  .logo {
201
203
  width: 24px; height: 24px;
202
- border-radius: 7px;
203
- background: linear-gradient(145deg, #4f8ff7, var(--accent) 55%, #1e4fd6);
204
- position: relative;
204
+ border-radius: 6px;
205
+ background: url("${logo_1.LOGO_DATA_URI}") center / contain no-repeat;
205
206
  flex-shrink: 0;
206
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16), 0 1px 2px rgba(37, 99, 235, 0.35);
207
- }
208
- .logo::after {
209
- content: "";
210
- position: absolute;
211
- left: 8px; top: 8px;
212
- width: 8px; height: 8px;
213
- border-radius: 50%;
214
- background: #fff;
207
+ box-shadow: 0 1px 2px rgba(37, 99, 235, 0.35);
215
208
  }
216
209
  .wordmark {
217
210
  font-size: 15px;
package/dist/index.js CHANGED
@@ -188,6 +188,12 @@ program
188
188
  .description("Change a peer's role")
189
189
  .option("--hosts <hosts>", "Comma separated hosts (viewer role only)")
190
190
  .action(run((id, role, options) => commands_1.default.peersRole(id, role, options)));
191
+ program
192
+ .command("peers:hosts <id> [hosts]")
193
+ .description("Show or change the hosts a viewer peer sees (comma separated hosts replace the list)")
194
+ .option("--add <hosts>", "Comma separated hosts to add")
195
+ .option("--remove <hosts>", "Comma separated hosts to remove")
196
+ .action(run((id, hosts, options) => commands_1.default.peersHosts(id, hosts, options)));
191
197
  program.command("peers:revoke <id>").description("Revoke a peer").action(run((id) => commands_1.default.peersRevoke(id)));
192
198
  program
193
199
  .command("connect <string>")
package/dist/lib/links.js CHANGED
@@ -134,6 +134,18 @@ class Links {
134
134
  save(data);
135
135
  return peer;
136
136
  }
137
+ /** A viewer's mappings, changed after pairing. Editors and admins see everything, so they have no list. */
138
+ static setHosts(peerId, hosts) {
139
+ const data = load();
140
+ const peer = data.peers.find((p) => p.id === peerId);
141
+ if (!peer)
142
+ throw new LinkError(404, "Peer not found.");
143
+ if (peer.role !== "viewer")
144
+ throw new LinkError(400, `${peer.name} is ${peer.role === "admin" ? "an admin" : "an editor"} and already sees every host.`);
145
+ peer.hosts = [...new Set(hosts.map((h) => h.trim().toLowerCase()).filter(Boolean))].sort();
146
+ save(data);
147
+ return peer;
148
+ }
137
149
  static revoke(peerId) {
138
150
  const data = load();
139
151
  const peers = data.peers.filter((p) => p.id !== peerId);
@@ -244,15 +244,23 @@ class Remotes {
244
244
  const hostsRes = await call(`${apiBase(remote.url)}/hosts`, { token: remote.token });
245
245
  const hosts = hostsRes.hosts;
246
246
  const now = new Date().toISOString();
247
+ const added = [];
248
+ const removed = [];
247
249
  await registry_1.default.mutate((registry) => {
248
250
  const already = new Set();
249
251
  const flags = new Map(hosts.map((h) => [h.host, h]));
250
252
  for (const [local, entry] of Object.entries(registry.hosts)) {
251
253
  if (entry.remote?.name !== name)
252
254
  continue;
253
- already.add(entry.remote.host);
254
255
  const h = flags.get(entry.remote.host);
255
- if (h && Boolean(h.cors) !== Boolean(entry.cors))
256
+ // No longer shared with us (unassigned from a viewer, or deleted on the sender): drop it, aliases too.
257
+ if (!h) {
258
+ delete registry.hosts[local];
259
+ removed.push(local);
260
+ continue;
261
+ }
262
+ already.add(entry.remote.host);
263
+ if (Boolean(h.cors) !== Boolean(entry.cors))
256
264
  registry.hosts[local] = { ...entry, cors: h.cors || undefined };
257
265
  }
258
266
  const taken = new Set(Object.keys(registry.hosts));
@@ -264,13 +272,14 @@ class Remotes {
264
272
  continue;
265
273
  taken.add(local);
266
274
  registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
275
+ added.push(local);
267
276
  }
268
277
  });
269
278
  const registry = registry_1.default.read();
270
279
  const mapped = Object.entries(registry.hosts)
271
280
  .filter(([, e]) => e.remote?.name === name)
272
281
  .map(([local, e]) => ({ local, host: e.remote.host }));
273
- return { remote: updated, hosts, mapped, domain: updated.domain };
282
+ return { remote: updated, hosts, mapped, added, removed, domain: updated.domain };
274
283
  }
275
284
  static async alias(name, remoteHost, localHost) {
276
285
  const remote = Remotes.mustGet(name);
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withoutSyncParam = exports.wantsForcedSync = exports.mayAutoSync = exports.SYNC_PARAM = void 0;
4
+ exports.createAutoSync = createAutoSync;
5
+ const request_1 = require("./request");
6
+ /** The "Sync again" button on the not-mapped page: same URL plus this query parameter. */
7
+ exports.SYNC_PARAM = "locadot-sync";
8
+ const withTimeout = (promise, ms) => new Promise((resolve, reject) => {
9
+ const timer = setTimeout(() => reject(new Error(`timed out after ${Math.round(ms / 1000)}s`)), ms);
10
+ promise.then((value) => (clearTimeout(timer), resolve(value)), (error) => (clearTimeout(timer), reject(error)));
11
+ });
12
+ const message = (error) => String(error?.message || error).replace(/^❌\s*/u, "");
13
+ /**
14
+ * Syncs every connected machine when a browser asks for a name that isn't mapped: the sender may have
15
+ * added or assigned it since the last sync. Concurrent requests share one run, and a recent run is reused.
16
+ * Resolves undefined when there's nothing to sync with.
17
+ */
18
+ function createAutoSync(opts) {
19
+ const freshMs = opts.freshMs ?? 10000;
20
+ const forcedFreshMs = opts.forcedFreshMs ?? 2000;
21
+ const timeoutMs = opts.timeoutMs ?? 5000;
22
+ let running;
23
+ let last;
24
+ const run = async (names) => {
25
+ const reports = await Promise.all(names.map((name) => withTimeout(opts.sync(name), timeoutMs).then((r) => ({ name, ok: true, added: r.added, removed: r.removed }), (error) => ({ name, ok: false, added: [], removed: [], error: message(error) }))));
26
+ if (reports.some((r) => r.added.length || r.removed.length))
27
+ opts.changed();
28
+ return reports;
29
+ };
30
+ return (force = false) => {
31
+ const names = opts.remotes();
32
+ if (!names.length)
33
+ return Promise.resolve(undefined);
34
+ if (running)
35
+ return running;
36
+ if (last && Date.now() - last.at < (force ? forcedFreshMs : freshMs))
37
+ return Promise.resolve(last.reports);
38
+ running = run(names).then((reports) => {
39
+ last = { at: Date.now(), reports };
40
+ return reports;
41
+ });
42
+ running.finally(() => (running = undefined)).catch(() => { });
43
+ return running;
44
+ };
45
+ }
46
+ /** Only this machine's own browser triggers a sync; tunnels, the hub and peers just get the page. */
47
+ const mayAutoSync = (req, hub) => !(hub && hub.kind !== "none") && !(0, request_1.fromTunnel)(req) && !(0, request_1.fromRemote)(req) && !req.headers["cf-ray"];
48
+ exports.mayAutoSync = mayAutoSync;
49
+ const wantsForcedSync = (req) => new URLSearchParams((req.url || "").split("?")[1] || "").has(exports.SYNC_PARAM);
50
+ exports.wantsForcedSync = wantsForcedSync;
51
+ /** The URL the browser asked for, minus the sync parameter: where to send it once the name is mapped. */
52
+ const withoutSyncParam = (url) => {
53
+ const [path, query = ""] = url.split("?");
54
+ const params = new URLSearchParams(query);
55
+ params.delete(exports.SYNC_PARAM);
56
+ const rest = params.toString();
57
+ return (path.startsWith("/") ? path : "/") + (rest ? `?${rest}` : "");
58
+ };
59
+ exports.withoutSyncParam = withoutSyncParam;
@@ -21,6 +21,7 @@ const request_1 = require("./request");
21
21
  const stats_1 = require("./stats");
22
22
  const protect_1 = require("./protect");
23
23
  const https_redirect_1 = require("./https-redirect");
24
+ const auto_sync_1 = require("./auto-sync");
24
25
  // Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
25
26
  const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
26
27
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
@@ -111,6 +112,33 @@ const localTarget = (req, ctx, host, hub) => {
111
112
  return undefined;
112
113
  return ctx.localFor?.(host);
113
114
  };
115
+ /** An unknown name may be one a connected machine shared since the last sync: sync, then retry or explain. */
116
+ function notMapped(req, res, ctx, host, hub) {
117
+ const notFound = (reports) => {
118
+ res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
119
+ const href = (path) => `${path}${path.includes("?") ? "&" : "?"}${auto_sync_1.SYNC_PARAM}=1`;
120
+ res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req), reports && { reports, href: href((0, auto_sync_1.withoutSyncParam)(req.url || "/")) }));
121
+ };
122
+ if (!ctx.autoSync || !(0, auto_sync_1.mayAutoSync)(req, hub))
123
+ return notFound();
124
+ ctx
125
+ .autoSync((0, auto_sync_1.wantsForcedSync)(req))
126
+ .then((reports) => {
127
+ if (res.headersSent || res.destroyed)
128
+ return;
129
+ if (reports && (ctx.lookup(host) || localTarget(req, ctx, host, hub))) {
130
+ res.writeHead(307, { Location: (0, auto_sync_1.withoutSyncParam)(req.url || "/"), "Cache-Control": "no-store" });
131
+ res.end();
132
+ return;
133
+ }
134
+ notFound(reports);
135
+ })
136
+ .catch((error) => {
137
+ logger_1.default.warn(`auto-sync for ${host}: ${error?.message || error}`);
138
+ if (!res.headersSent)
139
+ notFound();
140
+ });
141
+ }
114
142
  function handleRequest(req, res, ctx) {
115
143
  const hub = ctx.hub?.classify(req);
116
144
  if (hub?.kind === "api") {
@@ -139,8 +167,7 @@ function handleRequest(req, res, ctx) {
139
167
  forwardLocal(req, res, ctx, host, local);
140
168
  return;
141
169
  }
142
- res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
143
- res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
170
+ notMapped(req, res, ctx, host, hub);
144
171
  return;
145
172
  }
146
173
  if (entry.remote) {
@@ -16,6 +16,8 @@ const tunnel_1 = require("../proxy/tunnel");
16
16
  const hub_tunnel_1 = require("../proxy/hub-tunnel");
17
17
  const hub_1 = require("../proxy/hub");
18
18
  const remote_1 = require("../proxy/remote");
19
+ const auto_sync_1 = require("../proxy/auto-sync");
20
+ const remotes_1 = __importDefault(require("../lib/remotes"));
19
21
  const hub_config_1 = __importDefault(require("../lib/hub-config"));
20
22
  const config_1 = __importDefault(require("../lib/config"));
21
23
  const file_1 = __importDefault(require("../utils/file"));
@@ -71,6 +73,11 @@ async function startCentralProxy() {
71
73
  ownPorts: () => [info.httpPort, info.httpsPort],
72
74
  httpsRedirect: () => config_1.default.httpsRedirect(),
73
75
  httpsPort: () => info.httpsPort,
76
+ autoSync: (0, auto_sync_1.createAutoSync)({
77
+ remotes: () => remotes_1.default.list().map((remote) => remote.name),
78
+ sync: (name) => remotes_1.default.sync(name),
79
+ changed: registry.reload,
80
+ }),
74
81
  remoteFor: remote_1.remoteFor,
75
82
  localFor: remote_1.localFor,
76
83
  remoteHosts: (name) => Object.entries(registry.get().hosts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "description": "Secure your local development environment with HTTPS and custom domains like dev.localhost.",
5
5
  "homepage": "https://www.npmjs.com/package/locadot",
6
6
  "main": "dist/index.js",