locadot 2.1.0-beta.2 → 2.1.0-beta.3

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
@@ -35,6 +35,13 @@
35
35
  ### Fixed
36
36
  - The proxy can now tell a user's `LOCADOT_HTTP_PORT`/`LOCADOT_HTTPS_PORT` from the copies the CLI pins on every spawn
37
37
  (`LOCADOT_USER_PORTS`), so saved ports aren't reported as env-overridden.
38
+ - `--cors` through remote access: a shared `--cors` mapping is now `--cors` on the receiver too (shown in `list` and the
39
+ dashboard, kept in step by `remote:sync` and `remote:update`). The sender used to rewrite its apps' origins in pages to its
40
+ *own* local URLs, which don't exist on the receiver. Now the receiver sends the names it uses (`X-Locadot-Names`), so a
41
+ page's API calls go to the receiver's name for that mapping (e.g. `api.alice.localhost` after a clash), on the receiver's
42
+ port. The upstream still sees the calling page's real origin. `<port>.<domain>.localhost` gets the CORS handling of a
43
+ `--cors` mapping on that port. The sender keeps names only for mappings the peer can see, and only as `*.localhost` URLs.
44
+ Both machines need this version for the rewriting; with an older receiver the sender behaves as before.
38
45
 
39
46
  ## 2.1.0-beta.0 (2026-09-27)
40
47
 
package/README.md CHANGED
@@ -131,6 +131,12 @@ target, so an editor can reach anything the sender's machine can reach. Only giv
131
131
  of the tokens are stored, and failed attempts are rate limited. Only admins can open the sender's dashboard through the hub.
132
132
  The dashboard has **Remote access** and **Connected machines** cards for all of this.
133
133
 
134
+ `--cors` carries over: a sender's `--cors` mapping is `--cors` on the receiver too, with no extra flag. Preflights and CORS
135
+ headers work as they do locally, and origins in the sender's pages are rewritten to the receiver's own names and port (so
136
+ `http://localhost:8000` in a page becomes `http://api.alice.localhost` if that's the receiver's name for `api.localhost`).
137
+ An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` mapping points at that port.
138
+ `remote:sync` picks up a sender turning `--cors` on or off.
139
+
134
140
  ### Certificates
135
141
 
136
142
  | Command | What it does |
@@ -91,6 +91,8 @@ const isDomainLabel = (value) => {
91
91
  exports.isDomainLabel = isDomainLabel;
92
92
  /** Sender allows the peer onto its localhost, treating an older sender (no `localhost` field) as "yes" when admin. */
93
93
  const localhostAllowed = (data, role) => typeof data.localhost === "boolean" ? data.localhost : role === "admin";
94
+ /** A shared --cors mapping is --cors here too; the sender still does the CORS work (see router forwardRemote). */
95
+ const inherited = (h) => (h.cors ? { cors: true } : {});
94
96
  /** Domains already spoken for: other remotes' `domain`, and any single-label `*.localhost` registry host. */
95
97
  const takenDomains = (excludeName) => {
96
98
  const store = readFile();
@@ -212,7 +214,7 @@ class Remotes {
212
214
  continue;
213
215
  }
214
216
  taken.add(local);
215
- registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
217
+ registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
216
218
  mapped.push({ local, host: h.host });
217
219
  }
218
220
  });
@@ -244,9 +246,14 @@ class Remotes {
244
246
  const now = new Date().toISOString();
245
247
  await registry_1.default.mutate((registry) => {
246
248
  const already = new Set();
247
- for (const entry of Object.values(registry.hosts)) {
248
- if (entry.remote?.name === name)
249
- already.add(entry.remote.host);
249
+ const flags = new Map(hosts.map((h) => [h.host, h]));
250
+ for (const [local, entry] of Object.entries(registry.hosts)) {
251
+ if (entry.remote?.name !== name)
252
+ continue;
253
+ already.add(entry.remote.host);
254
+ const h = flags.get(entry.remote.host);
255
+ if (h && Boolean(h.cors) !== Boolean(entry.cors))
256
+ registry.hosts[local] = { ...entry, cors: h.cors || undefined };
250
257
  }
251
258
  const taken = new Set(Object.keys(registry.hosts));
252
259
  for (const h of hosts) {
@@ -256,7 +263,7 @@ class Remotes {
256
263
  if (!local)
257
264
  continue;
258
265
  taken.add(local);
259
- registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
266
+ registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
260
267
  }
261
268
  });
262
269
  const registry = registry_1.default.read();
@@ -270,13 +277,14 @@ class Remotes {
270
277
  const local = localhost_1.default.requireHost(localHost);
271
278
  const hostsRes = await call(`${apiBase(remote.url)}/hosts`, { token: remote.token });
272
279
  const hosts = hostsRes.hosts;
273
- if (!hosts.some((h) => h.host === remoteHost))
280
+ const shared = hosts.find((h) => h.host === remoteHost);
281
+ if (!shared)
274
282
  throw new hosts_1.NotFoundError(`"${remoteHost}" is not available on "${name}".`);
275
283
  const now = new Date().toISOString();
276
284
  await registry_1.default.mutate((registry) => {
277
285
  if (registry.hosts[local])
278
286
  throw new hosts_1.ConflictError(`❌ ${local} is already mapped to ${registry.hosts[local].target}.`);
279
- registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, createdAt: now, updatedAt: now };
287
+ registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, ...inherited(shared), createdAt: now, updatedAt: now };
280
288
  });
281
289
  }
282
290
  static setUrl(name, url) {
@@ -329,7 +337,7 @@ class Remotes {
329
337
  const taken = new Set(Object.keys(registry.hosts));
330
338
  const local = Remotes.localName(name, created.host, taken);
331
339
  if (local)
332
- registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, createdAt: now, updatedAt: now };
340
+ registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, ...inherited(created), createdAt: now, updatedAt: now };
333
341
  });
334
342
  return created;
335
343
  }
@@ -340,7 +348,14 @@ class Remotes {
340
348
  token: remote.token,
341
349
  body: input,
342
350
  });
343
- return data.host;
351
+ const updated = data.host;
352
+ await registry_1.default.mutate((registry) => {
353
+ for (const [local, entry] of Object.entries(registry.hosts)) {
354
+ if (entry.remote?.name === name && entry.remote.host === host)
355
+ registry.hosts[local] = { ...entry, cors: updated.cors || undefined };
356
+ }
357
+ });
358
+ return updated;
344
359
  }
345
360
  static async removeHost(name, host) {
346
361
  const remote = Remotes.mustGet(name);
@@ -24,12 +24,16 @@ exports.preflightHeaders = preflightHeaders;
24
24
  /**
25
25
  * --cors: the upstream should see a request from a site it trusts, so origin/CSRF checks pass.
26
26
  * A page on another mapped domain (signalsant.localhost calling api.signalsant.localhost) is
27
- * sent as that domain's real origin; anything else as the target's own origin.
27
+ * sent as that domain's real origin; anything else as the target's own origin. A peer's page is on the
28
+ * receiver's name for one of our domains, so it is matched by those names, never by our own.
28
29
  */
29
30
  const sameOriginHeaders = (req, entry, lookup) => {
31
+ const names = (0, request_1.peerNamesOf)(req);
32
+ const callerHost = (url) => names ? Object.keys(names).find((host) => new URL(names[host]).host === url.host) : url.hostname;
30
33
  const upstreamOrigin = (value) => {
31
34
  try {
32
- const caller = lookup(new URL(value).hostname);
35
+ const host = callerHost(new URL(value));
36
+ const caller = host ? lookup(host) : undefined;
33
37
  if (caller)
34
38
  return new URL(caller.target).origin;
35
39
  }
package/dist/proxy/hub.js CHANGED
@@ -87,6 +87,39 @@ const receiverHost = (req) => {
87
87
  const match = /^([^:]+)(?::(\d{1,5}))?$/.exec(raw);
88
88
  return match && localhost_1.default.isValidLocalhostDomain(match[1]) ? raw : undefined;
89
89
  };
90
+ const MAX_NAMES = 16 * 1024;
91
+ /**
92
+ * `X-Locadot-Names` from the receiver: `<our host>=<its URL for it>,…`. Kept only for hosts this peer can see and
93
+ * `http(s)://*.localhost[:port]` URLs; they only ever end up in responses to that same peer.
94
+ */
95
+ const peerNames = (req, visible) => {
96
+ const header = req.headers["x-locadot-names"];
97
+ if (typeof header !== "string" || header.length > MAX_NAMES)
98
+ return undefined;
99
+ const names = {};
100
+ for (const pair of header.split(",")) {
101
+ const [host, url] = pair.trim().toLowerCase().split("=");
102
+ const match = url ? /^https?:\/\/([^/:]+)(?::(\d{1,5}))?$/.exec(url) : null;
103
+ if (host && match && visible.includes(host) && localhost_1.default.isValidLocalhostDomain(match[1]))
104
+ names[host] = url;
105
+ }
106
+ return names;
107
+ };
108
+ /** Sender side: a --cors mapping whose target is this machine's `port`, so port access gets its cors too. */
109
+ const corsMappingFor = (hosts, port) => Object.keys(hosts).find((host) => {
110
+ const entry = hosts[host];
111
+ if (!entry.cors || entry.remote)
112
+ return false;
113
+ try {
114
+ const url = new URL(entry.target);
115
+ const own = Number(url.port || (url.protocol === "https:" ? 443 : 80));
116
+ return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) && own === port;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ });
122
+ const ownHosts = (hosts, peer) => links_1.default.visibleHosts(peer, Object.keys(hosts)).filter((host) => !hosts[host].remote);
90
123
  const stripLocadotHeaders = (req) => {
91
124
  for (const name of Object.keys(req.headers))
92
125
  if (name.toLowerCase().startsWith("x-locadot-"))
@@ -127,7 +160,7 @@ function classify(req, publicHost, hosts, opts) {
127
160
  if (!origin)
128
161
  return { kind: "deny", status: 400, message: "Bad request" };
129
162
  // Keep X-Locadot-Token: the dashboard page sends it on every mutating call.
130
- for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port"])
163
+ for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port", "x-locadot-names"])
131
164
  delete req.headers[name];
132
165
  return { kind: "dashboard", peer, origin };
133
166
  }
@@ -139,8 +172,15 @@ function classify(req, publicHost, hosts, opts) {
139
172
  return { kind: "deny", status: 400, message: "Bad request" };
140
173
  if (opts?.blockedPorts?.includes(port))
141
174
  return { kind: "deny", status: 403, message: "Forbidden" };
175
+ const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, ownHosts(hosts, peer));
176
+ const host = corsMappingFor(hosts, port);
177
+ // The page stays on <port>.<domain>.localhost rather than moving to the receiver's name for the mapping.
178
+ const self = receiverHost(req);
179
+ const scheme = names && Object.values(names)[0]?.split(":")[0];
180
+ if (host && names && self && scheme)
181
+ names[host] = `${scheme}://${self}`;
142
182
  stripLocadotHeaders(req);
143
- return { kind: "local", port, peer };
183
+ return { kind: "local", port, peer, names, host };
144
184
  }
145
185
  const hostHeader = req.headers["x-locadot-host"];
146
186
  const host = typeof hostHeader === "string" ? hostHeader.trim().toLowerCase() : "";
@@ -152,8 +192,9 @@ function classify(req, publicHost, hosts, opts) {
152
192
  if (!visible.includes(host)) {
153
193
  return { kind: "deny", status: 403, message: "Forbidden" };
154
194
  }
195
+ const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, visible);
155
196
  stripLocadotHeaders(req);
156
- return { kind: "app", host, peer };
197
+ return { kind: "app", host, peer, names };
157
198
  }
158
199
  /* ---------- /_locadot/v1 API ---------- */
159
200
  const send = (res, status, body) => {
@@ -32,7 +32,7 @@ const remotes = () => {
32
32
  };
33
33
  const remoteFor = (name) => remotes()[name];
34
34
  exports.remoteFor = remoteFor;
35
- const remoteOptions = (req, entry, remote) => ({
35
+ const remoteOptions = (req, entry, remote, names) => ({
36
36
  target: remote.url,
37
37
  changeOrigin: true,
38
38
  // Don't leak the receiver's LAN addresses to the sender.
@@ -47,6 +47,7 @@ const remoteOptions = (req, entry, remote) => ({
47
47
  "X-Original-Host": req.headers.host || "",
48
48
  "X-Locadot-Peer": remote.token,
49
49
  "X-Locadot-Host": entry.remote.host,
50
+ ...(names ? { "X-Locadot-Names": names } : {}),
50
51
  },
51
52
  });
52
53
  exports.remoteOptions = remoteOptions;
@@ -75,7 +76,7 @@ exports.localFor = localFor;
75
76
  const canReachLocalhost = (remote) => remote.role === "admin" && remote.localhost !== false;
76
77
  exports.canReachLocalhost = canReachLocalhost;
77
78
  /** Without a port the sender serves its dashboard. */
78
- const localOptions = (req, remote, port) => ({
79
+ const localOptions = (req, remote, port, names) => ({
79
80
  target: remote.url,
80
81
  changeOrigin: true,
81
82
  xfwd: false,
@@ -88,7 +89,7 @@ const localOptions = (req, remote, port) => ({
88
89
  headers: {
89
90
  "X-Original-Host": req.headers.host || "",
90
91
  "X-Locadot-Peer": remote.token,
91
- ...(port ? { "X-Locadot-Port": String(port) } : { "X-Locadot-Dashboard": "1" }),
92
+ ...(port ? { "X-Locadot-Port": String(port), ...(names ? { "X-Locadot-Names": names } : {}) } : { "X-Locadot-Dashboard": "1" }),
92
93
  },
93
94
  });
94
95
  exports.localOptions = localOptions;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isTls = exports.viaOf = exports.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
3
+ exports.isTls = exports.viaOf = exports.peerNamesOf = exports.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
4
4
  const tags = new WeakMap();
5
5
  const tag = (req, values) => {
6
6
  tags.set(req, { ...tags.get(req), ...values });
@@ -23,6 +23,8 @@ const fromRemote = (req) => Boolean(tags.get(req)?.remote);
23
23
  exports.fromRemote = fromRemote;
24
24
  const peerOriginOf = (req) => tags.get(req)?.peerOrigin;
25
25
  exports.peerOriginOf = peerOriginOf;
26
+ const peerNamesOf = (req) => tags.get(req)?.peerNames;
27
+ exports.peerNamesOf = peerNamesOf;
26
28
  const viaOf = (req) => tags.get(req)?.via;
27
29
  exports.viaOf = viaOf;
28
30
  /** Tunnel visitors are on https even though cloudflared talks plain http to us. */
@@ -23,7 +23,8 @@ const handleProxyResponse = (proxyRes, req, res, hosts, publicUrl = () => undefi
23
23
  return;
24
24
  stripHopByHop(proxyRes.headers);
25
25
  const entry = hosts[(0, request_1.mappedHost)(req)];
26
- if (!entry?.cors)
26
+ // A remote mapping's cors flag is inherited: the sender already applied it.
27
+ if (!entry?.cors || entry.remote)
27
28
  return;
28
29
  (0, cors_1.applyCors)(req, proxyRes.headers);
29
30
  const via = (0, request_1.viaOf)(req);
@@ -25,9 +25,11 @@ const originMap = (req, hosts, publicUrl = () => undefined) => {
25
25
  const tls = (0, request_1.isTls)(req);
26
26
  const self = (0, request_1.mappedHost)(req);
27
27
  const tunnel = (0, request_1.fromTunnel)(req);
28
+ // A peer's browser knows our hosts by its own names; older receivers don't send them.
29
+ const peer = (0, request_1.peerNamesOf)(req);
28
30
  const pairs = [];
29
31
  for (const [host, entry] of Object.entries(hosts)) {
30
- const local = tunnel ? publicUrl(host) : (0, urls_1.urlFor)(host, tls);
32
+ const local = tunnel ? publicUrl(host) : peer ? peer[host] : (0, urls_1.urlFor)(host, tls);
31
33
  if (!local)
32
34
  continue;
33
35
  try {
@@ -20,7 +20,7 @@ const stats_1 = require("./stats");
20
20
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
21
21
  const resolveHost = (req, ctx, hub) => {
22
22
  if (hub?.kind === "app") {
23
- (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure() });
23
+ (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names });
24
24
  return hub.host;
25
25
  }
26
26
  if (hub?.kind === "dashboard") {
@@ -28,8 +28,9 @@ const resolveHost = (req, ctx, hub) => {
28
28
  return "localhost";
29
29
  }
30
30
  if (hub?.kind === "local") {
31
- const host = `localhost:${hub.port}`;
32
- (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure() });
31
+ // A port behind a --cors mapping is served as that mapping, cors included.
32
+ const host = hub.host ?? `localhost:${hub.port}`;
33
+ (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names });
33
34
  return host;
34
35
  }
35
36
  const host = (0, request_1.hostOf)(req);
@@ -42,6 +43,16 @@ const dashboardUrl = (req) => `${(0, urls_1.urlFor)("localhost", (0, request_1.i
42
43
  const passThrough = (req, entry) => Boolean(entry.cors);
43
44
  /** Same-origin only, and a tunnel visitor may only reach public hosts (see guard.ts). */
44
45
  const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host));
46
+ /**
47
+ * Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
48
+ * its apps' origins to the names this machine uses rather than its own.
49
+ */
50
+ const peerNames = (req, ctx, name) => (ctx.remoteHosts?.(name) || [])
51
+ .flatMap((local) => {
52
+ const host = ctx.lookup(local)?.remote?.host;
53
+ return host ? [`${host}=${(0, urls_1.urlFor)(local, (0, request_1.isTls)(req))}`] : [];
54
+ })
55
+ .join(",");
45
56
  const reason = (err) => err?.code || err?.message;
46
57
  const remoteGone = (entry) => `the connection to ${entry.remote.name} was removed`;
47
58
  /** Sender side: an admin peer reaching one of this machine's ports. */
@@ -84,7 +95,7 @@ function handleRequest(req, res, ctx) {
84
95
  ctx.dashboard(req, res);
85
96
  return;
86
97
  }
87
- const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
98
+ const entry = hub?.kind === "local" && !hub.host ? localEntry(hub.port) : ctx.lookup(host);
88
99
  if (!entry) {
89
100
  const local = localTarget(req, ctx, host, hub);
90
101
  if (local) {
@@ -155,7 +166,7 @@ function forwardRemote(req, res, ctx, host, entry) {
155
166
  }
156
167
  const started = Date.now();
157
168
  res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
158
- ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
169
+ ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote, peerNames(req, ctx, remote.name)), (err) => {
159
170
  logger_1.default.warn(`${host} → ${remote.name} (${remote.url}): ${reason(err)}`);
160
171
  if (res.headersSent) {
161
172
  res.destroy();
@@ -182,7 +193,7 @@ function forwardLocal(req, res, ctx, host, local) {
182
193
  const label = `${remote.name}: ${port ? `localhost:${port}` : "dashboard"}`;
183
194
  const started = Date.now();
184
195
  res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
185
- ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port), (err) => {
196
+ ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port, port ? peerNames(req, ctx, remote.name) : undefined), (err) => {
186
197
  logger_1.default.warn(`${host} → ${label}: ${reason(err)}`);
187
198
  if (res.headersSent) {
188
199
  res.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.1.0-beta.2",
3
+ "version": "2.1.0-beta.3",
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",