locadot 1.6.0-beta.2 → 1.6.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- The version in `package.json` is 1.6.0-beta.2 (published under the `beta` tag). The owner decides the bump. **2.0.0** is suggested because of the
5
+ The version in `package.json` is 1.6.0-beta.4 (published under the `beta` tag). The owner decides the bump. **2.0.0** is suggested because of the
6
6
  breaking items below.
7
7
 
8
8
  ### Breaking
@@ -13,6 +13,8 @@ breaking items below.
13
13
  - Bare `localhost` is reserved for the dashboard and can't be mapped.
14
14
 
15
15
  ### Added
16
+ - `locadot start --port <http> --https-port <https>` (also on `restart`) runs the proxy on other ports than 80/443. The choice is saved in the state dir, so `add`, `status`, `open` and start-at-boot keep using it; `--port 80 --https-port 443` goes back. Running it while the proxy is up on other ports restarts it on the new ones.
17
+ - `--cors` per mapping (CLI, API and dashboard). `Origin`/`Referer` are sent as the target's own origin, CORS preflights are answered locally, and any origin may call the domain with credentials. Absolute URLs of other mapped domains in text responses are rewritten to their `.localhost` names, and a mapped caller is sent as its real origin, so a site plus its API both work locally. Pages also get a small script that sends calls to any other origin through the page's own origin (`/__locadot/x/…`), so no mapping per API or third-party domain is needed. (ENH-12, BUG-15)
16
18
  - A control panel at `https://localhost` with a dark theme. You can add, edit and remove mappings, toggle CA trust and start-at-boot, see root/admin and privileged-port status,
17
19
  tail and clear logs, and stop the proxy. (FEAT-05)
18
20
  - A token-protected JSON API for scripts and AI agents (`POST/PUT/DELETE /api/hosts`, `/api/startup`, `/api/trust`, `/api/logs`, `/api/proxy/stop`),
package/README.md CHANGED
@@ -29,7 +29,8 @@ npx locadot open # opens the dashboard at htt
29
29
  ```
30
30
 
31
31
  > **Linux:** ports 80/443 need root. Either run locadot with `sudo`, or allow unprivileged binding once:
32
- > `sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80` (persist it in `/etc/sysctl.d/`). `locadot doctor` checks this for you.
32
+ > `sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80` (persist it in `/etc/sysctl.d/`). Or use other ports:
33
+ > `locadot start --port 8080 --https-port 8443` (URLs then include the port). `locadot doctor` checks this for you.
33
34
 
34
35
  ---
35
36
 
@@ -54,15 +55,16 @@ Options for `add` / `update`:
54
55
  | `-p, --port <port>` | Shorthand for `--target http://localhost:<port>`. |
55
56
  | `-t, --target <url>` | Any http(s) upstream. A bare `host:port` means `http://host:port`. |
56
57
  | `-k, --insecure` | Don't verify the TLS certificate of an `https` target (self-signed upstreams). |
58
+ | `--cors` / `--no-cors` | Bypass CORS for this domain. The upstream gets `Origin`/`Referer` as its own origin, preflights are answered locally, any origin may read responses (with credentials), and cookies become `SameSite=None` over HTTPS. The page's calls to other domains go through locadot automatically, with no extra mapping (see [Sites that call other domains](#sites-that-call-other-domains---cors)). |
57
59
  | `--no-start` | Save the mapping without starting the proxy. |
58
60
 
59
61
  ### Proxy
60
62
 
61
63
  | Command | What it does |
62
64
  | --- | --- |
63
- | `locadot start` | Start the central proxy. Fails loudly with the reason if it can't bind its ports. |
65
+ | `locadot start [--port 8080] [--https-port 8443]` | Start the central proxy. Default ports are 80 and 443. Ports you pass are remembered for later commands and start-at-boot, and a running proxy is moved to them. Fails loudly with the reason if it can't bind its ports. |
64
66
  | `locadot stop` | Stop the proxy. Mappings and logs are kept. |
65
- | `locadot restart` | Stop, then start. |
67
+ | `locadot restart [--port …] [--https-port …]` | Stop, then start. |
66
68
  | `locadot kill` | Stop the proxy, remove all mappings and clear the logs. |
67
69
  | `locadot status [--json]` | Is it running, its PID, ports, number of hosts, CA trust, start-at-boot, dashboard URL. |
68
70
  | `locadot doctor [--host h]` | Check the proxy, ports, permissions, CA trust, the registry and every target. Exits 1 if anything fails. |
@@ -93,7 +95,7 @@ are answered by locadot itself, not proxied. The page is a dark control panel:
93
95
 
94
96
  - **System:** whether the proxy runs as root/admin, whether ports below 1024 can be bound (with the Linux sysctl fix when they
95
97
  can't), CA trust with an on/off toggle, start-at-boot with an on/off toggle, ports, bind addresses and state dir. It also has a **Stop proxy** button.
96
- - **Proxies:** add a mapping (host + port / host:port / URL, and an optional "insecure TLS" box), edit a target inline, or remove one. The table shows
98
+ - **Proxies:** add a mapping (host + port / host:port / URL, and optional "Insecure TLS" and "Bypass CORS" boxes), edit a target inline, or remove one. The table shows
97
99
  each source → destination with up/down, status, latency, hits, errors, last access and average ms. It refreshes every 5 s.
98
100
  - **Logs:** a live tail with refresh and clear.
99
101
  - **CLI / API snippets** you can copy.
@@ -113,8 +115,8 @@ each time the proxy starts.
113
115
  | `GET /api/status` | | Proxy info, uptime, host count, and `system` (`platform`, `isRoot`, `canBindPrivileged`, `unprivilegedPortStart`, `caTrusted`, `startup`, `stateDir`). |
114
116
  | `GET /api/hosts` | | Every mapping with urls, probe (`up`, `status`, `ms`) and stats. |
115
117
  | `GET /api/logs?lines=200` | | `{ lines: [...] }` |
116
- | `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false }` | Add a mapping (201; 409 if it exists). |
117
- | `PUT /api/hosts/:host` | `{ "target": "https://example.com", "insecure": false }` | Change a mapping (404 if unknown). |
118
+ | `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false, "cors": false }` | Add a mapping (201; 409 if it exists). |
119
+ | `PUT /api/hosts/:host` | `{ "target": "https://example.com", "insecure": false, "cors": true }` | Change a mapping (404 if unknown). |
118
120
  | `DELETE /api/hosts/:host` | | Remove a mapping. |
119
121
  | `POST /api/startup` | `{ "enabled": true }` | Start at boot on/off. |
120
122
  | `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
@@ -147,8 +149,8 @@ All optional, via environment variables. Set them for the CLI; the proxy it star
147
149
  | Variable | Default | Purpose |
148
150
  | --- | --- | --- |
149
151
  | `LOCADOT_HOME` | OS app-data dir (`~/.config/locadot` on Linux) | Where the registry, logs, lock file and certs live. |
150
- | `LOCADOT_HTTP_PORT` | `80` | HTTP port of the proxy. |
151
- | `LOCADOT_HTTPS_PORT` | `443` | HTTPS port of the proxy. |
152
+ | `LOCADOT_HTTP_PORT` | `80` | HTTP port of the proxy. Overrides the port saved by `locadot start --port`. |
153
+ | `LOCADOT_HTTPS_PORT` | `443` | HTTPS port of the proxy. Overrides the port saved by `locadot start --https-port`. |
152
154
  | `LOCADOT_BIND` | `127.0.0.1,::1` | Comma-separated addresses to listen on. Use `0.0.0.0` to expose your mappings to your LAN (not recommended). |
153
155
  | `LOCADOT_LOG_LEVEL` | `info` | winston log level (`debug` logs every WebSocket upgrade). |
154
156
 
@@ -164,6 +166,26 @@ redirect to a *different* domain (for example `google.com` → `www.google.com`)
164
166
  locadot, map the final host instead (`--target https://www.google.com`). Some sites refuse to be framed or proxied; that's up to
165
167
  the site.
166
168
 
169
+ ### Sites that call other domains (`--cors`)
170
+
171
+ A frontend that calls its API or third-party services by absolute URL works with just the site mapped:
172
+
173
+ ```bash
174
+ locadot add --host signalsant.localhost --target https://signalsant.com --cors
175
+ ```
176
+
177
+ - **Pass-through.** HTML pages get a small script (`/__locadot/shim.js`) as the first thing in `<head>`. It sends `fetch`,
178
+ `XMLHttpRequest`, `EventSource`, `WebSocket` and `sendBeacon` calls to other origins through the page's own origin
179
+ (`/__locadot/x/https/api.signalsant.com/…`). The browser sees a same-origin request, so CORS never applies, and locadot
180
+ forwards it with `Origin: https://signalsant.com`. Only the page itself can use it: requests from other sites get a 403.
181
+ - **Cookies.** The page's cookies are forwarded only to the same site (`api.signalsant.com`), never to third parties.
182
+ Cookies set by third parties are dropped.
183
+ - **Mapped domains.** If you also map a domain (`api.signalsant.localhost` → `https://api.signalsant.com --cors`), its URLs in
184
+ HTML/JS/CSS/JSON are rewritten to the `.localhost` name and it's called there directly.
185
+
186
+ Rewritten responses are buffered and sent uncompressed. Event streams and binary files pass through untouched. Not covered:
187
+ requests made by web workers or service workers, and `<form>` posts or `<img>`/`<script>` tags (these don't need CORS).
188
+
167
189
  ---
168
190
 
169
191
  ## 🛠️ Contributing
@@ -4,16 +4,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const appdata_path_1 = __importDefault(require("appdata-path"));
7
+ const fs_1 = __importDefault(require("fs"));
7
8
  const path_1 = __importDefault(require("path"));
8
9
  const os_1 = __importDefault(require("os"));
9
10
  // LOCADOT_HOME / LOCADOT_*_PORT let tests and hosts where 80/443 are taken run an isolated instance.
10
11
  const PACKAGE_PATH = process.env.LOCADOT_HOME
11
12
  ? path_1.default.resolve(process.env.LOCADOT_HOME)
12
13
  : (0, appdata_path_1.default)("locadot");
13
- const envPort = (name, fallback) => {
14
- const value = Number(process.env[name]);
15
- return Number.isInteger(value) && value > 0 && value < 65536 ? value : fallback;
14
+ const CONFIG_FILE = path_1.default.join(PACKAGE_PATH, ".locadot-config.json");
15
+ const validPort = (value) => {
16
+ const port = Number(value);
17
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
16
18
  };
19
+ // Ports chosen with `locadot start --port/--https-port`, so later commands and boot startup reuse them.
20
+ const savedPorts = () => {
21
+ try {
22
+ return JSON.parse(fs_1.default.readFileSync(CONFIG_FILE, "utf8")) || {};
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ };
28
+ const envPort = (name, saved, fallback) => validPort(process.env[name]) ?? validPort(saved) ?? fallback;
17
29
  class Constants {
18
30
  }
19
31
  Constants.paths = {
@@ -25,10 +37,12 @@ Constants.paths = {
25
37
  CERT_DIR: path_1.default.join(PACKAGE_PATH, "certs"),
26
38
  // Secret for the dashboard's mutating API; rotated on every proxy start.
27
39
  API_TOKEN: path_1.default.join(PACKAGE_PATH, ".locadot-token"),
40
+ CONFIG_FILE,
28
41
  };
42
+ Constants.validPort = validPort;
29
43
  Constants.server = {
30
- httpPort: envPort("LOCADOT_HTTP_PORT", 80),
31
- httpsPort: envPort("LOCADOT_HTTPS_PORT", 443),
44
+ httpPort: envPort("LOCADOT_HTTP_PORT", savedPorts().httpPort, 80),
45
+ httpsPort: envPort("LOCADOT_HTTPS_PORT", savedPorts().httpsPort, 443),
32
46
  // Loopback only unless the user opts in; see tasks/enhancements ENH-06.
33
47
  bind: (process.env.LOCADOT_BIND || "127.0.0.1,::1")
34
48
  .split(",")
@@ -165,7 +165,7 @@ async function route(req, url, ctx) {
165
165
  const body = await readJson(req);
166
166
  try {
167
167
  if (path === "/api/hosts") {
168
- const { host, entry } = await hosts_1.default.add({ host: body.host, target: body.target, insecure: optionalBool(body.insecure, "insecure") });
168
+ const { host, entry } = await hosts_1.default.add({ host: body.host, target: body.target, insecure: optionalBool(body.insecure, "insecure"), cors: optionalBool(body.cors, "cors") });
169
169
  ctx.reload();
170
170
  logger_1.default.info(`➕ dashboard: ${host} → ${entry.target}`);
171
171
  return { status: 201, body: { ok: true, host, ...entry, url: hostUrl(host, ctx) } };
@@ -178,7 +178,7 @@ async function route(req, url, ctx) {
178
178
  logger_1.default.info(`🗑️ dashboard: removed ${host}`);
179
179
  return { status: 200, body: { ok: true, host } };
180
180
  }
181
- const updated = await hosts_1.default.update({ host, target: body.target, insecure: optionalBool(body.insecure, "insecure") });
181
+ const updated = await hosts_1.default.update({ host, target: body.target, insecure: optionalBool(body.insecure, "insecure"), cors: optionalBool(body.cors, "cors") });
182
182
  ctx.reload();
183
183
  logger_1.default.info(`✏️ dashboard: ${updated.host} → ${updated.entry.target}`);
184
184
  return { status: 200, body: { ok: true, host: updated.host, ...updated.entry, url: hostUrl(updated.host, ctx) } };
@@ -42,6 +42,7 @@ async function buildHosts(ctx) {
42
42
  host,
43
43
  target: entry.target,
44
44
  insecure: !!entry.insecure,
45
+ cors: !!entry.cors,
45
46
  createdAt: entry.createdAt,
46
47
  updatedAt: entry.updatedAt,
47
48
  urls: hostUrls(host, httpPort, httpsPort),
@@ -305,6 +305,9 @@ tr:last-child td { border-bottom: none; }
305
305
  width: 100%;
306
306
  min-width: 160px;
307
307
  }
308
+ .badges { display: flex; flex-wrap: wrap; gap: 4px; }
309
+ .badge-cors { color: var(--accent-cyan); border-color: var(--accent-cyan); background: rgba(34, 211, 238, 0.1); }
310
+ td .inline-check + .inline-check { margin-top: 4px; }
308
311
  .inline-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
309
312
  .dim { color: var(--muted-dim); }
310
313
 
@@ -449,6 +452,7 @@ const clientJs = `
449
452
  var addHostInput = document.getElementById("add-host");
450
453
  var addTargetInput = document.getElementById("add-target");
451
454
  var addInsecureInput = document.getElementById("add-insecure");
455
+ var addCorsInput = document.getElementById("add-cors");
452
456
  var addSubmitBtn = document.getElementById("add-submit");
453
457
  var addErrorEl = document.getElementById("add-error");
454
458
 
@@ -757,9 +761,10 @@ const clientJs = `
757
761
  }
758
762
  if (hostVal.indexOf(".") === -1) hostVal = hostVal + ".localhost";
759
763
  var insecureVal = !!addInsecureInput.checked;
764
+ var corsVal = !!addCorsInput.checked;
760
765
  addSubmitBtn.disabled = true;
761
766
  addSubmitBtn.classList.add("busy");
762
- apiFetch("/api/hosts", { method: "POST", body: JSON.stringify({ host: hostVal, target: targetVal, insecure: insecureVal }) })
767
+ apiFetch("/api/hosts", { method: "POST", body: JSON.stringify({ host: hostVal, target: targetVal, insecure: insecureVal, cors: corsVal }) })
763
768
  .then(function () {
764
769
  addForm.reset();
765
770
  showToast("Added " + hostVal, "success");
@@ -822,17 +827,24 @@ const clientJs = `
822
827
  tr.appendChild(makeCell("\\u2192", "arrow"));
823
828
  tr.appendChild(makeCell(row.target, "mono"));
824
829
 
825
- var insecureTd = document.createElement("td");
826
- if (row.insecure) {
827
- var badge = document.createElement("span");
828
- badge.className = "badge";
829
- badge.textContent = "insecure";
830
- insecureTd.appendChild(badge);
830
+ var optionsTd = document.createElement("td");
831
+ var flags = [];
832
+ if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified"]);
833
+ if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host"]);
834
+ if (flags.length) {
835
+ optionsTd.className = "badges";
836
+ flags.forEach(function (flag) {
837
+ var badge = document.createElement("span");
838
+ badge.className = "badge" + (flag[0] === "cors" ? " badge-cors" : "");
839
+ badge.textContent = flag[0];
840
+ badge.title = flag[1];
841
+ optionsTd.appendChild(badge);
842
+ });
831
843
  } else {
832
- insecureTd.className = "dim";
833
- insecureTd.textContent = "\\u2014";
844
+ optionsTd.className = "dim";
845
+ optionsTd.textContent = "\\u2014";
834
846
  }
835
- tr.appendChild(insecureTd);
847
+ tr.appendChild(optionsTd);
836
848
 
837
849
  var statusTd = document.createElement("td");
838
850
  statusTd.appendChild(statusPill(row));
@@ -899,6 +911,14 @@ const clientJs = `
899
911
  insecureLabel.appendChild(insecureInput);
900
912
  insecureLabel.appendChild(document.createTextNode("insecure"));
901
913
  insecureTd.appendChild(insecureLabel);
914
+ var corsLabel = document.createElement("label");
915
+ corsLabel.className = "inline-check";
916
+ var corsInput = document.createElement("input");
917
+ corsInput.type = "checkbox";
918
+ corsInput.checked = !!row.cors;
919
+ corsLabel.appendChild(corsInput);
920
+ corsLabel.appendChild(document.createTextNode("cors"));
921
+ insecureTd.appendChild(corsLabel);
902
922
  tr.appendChild(insecureTd);
903
923
 
904
924
  tr.appendChild(makeCell("\\u2014", "dim"));
@@ -927,7 +947,7 @@ const clientJs = `
927
947
  cancelBtn.disabled = true;
928
948
  apiFetch("/api/hosts/" + encodeURIComponent(row.host), {
929
949
  method: "PUT",
930
- body: JSON.stringify({ target: newTarget, insecure: !!insecureInput.checked })
950
+ body: JSON.stringify({ target: newTarget, insecure: !!insecureInput.checked, cors: !!corsInput.checked })
931
951
  })
932
952
  .then(function () {
933
953
  editingHost = null;
@@ -1134,6 +1154,10 @@ function renderPage(nonce, token) {
1134
1154
  <input type="checkbox" id="add-insecure" name="insecure">
1135
1155
  <label for="add-insecure">Insecure TLS</label>
1136
1156
  </div>
1157
+ <div class="field field-checkbox" title="Send Origin/Referer as the target's own and let any origin call this host">
1158
+ <input type="checkbox" id="add-cors" name="cors">
1159
+ <label for="add-cors">Bypass CORS</label>
1160
+ </div>
1137
1161
  <button type="submit" id="add-submit" class="btn btn-primary">Add proxy</button>
1138
1162
  </div>
1139
1163
  <div id="add-error" class="field-error" role="alert" hidden></div>
@@ -1153,7 +1177,7 @@ function renderPage(nonce, token) {
1153
1177
  <th scope="col">Host</th>
1154
1178
  <th scope="col" class="visually-hidden">Flow</th>
1155
1179
  <th scope="col">Target</th>
1156
- <th scope="col">Insecure</th>
1180
+ <th scope="col">Options</th>
1157
1181
  <th scope="col">Status</th>
1158
1182
  <th scope="col">Hits</th>
1159
1183
  <th scope="col">Errors</th>
package/dist/index.js CHANGED
@@ -30,6 +30,8 @@ const destinationOptions = (command) => command
30
30
  .option("-p, --port <port>", "Local port to forward to (shorthand for --target http://localhost:<port>)")
31
31
  .option("-t, --target <url>", "Any upstream: 3000, 127.0.0.1:8080, http://192.168.1.5:8080, https://google.com")
32
32
  .option("-k, --insecure", "Don't verify the TLS certificate of an https target")
33
+ .option("--cors", "Send Origin/Referer as the target's own and let any origin call this domain")
34
+ .option("--no-cors", "Turn --cors off again (update)")
33
35
  .option("--no-start", "Only save the mapping; don't start the proxy");
34
36
  program
35
37
  .name("locadot")
@@ -64,9 +66,12 @@ program
64
66
  .command("open [host]")
65
67
  .description("Open a domain, or the dashboard when no host is given, in the browser")
66
68
  .action(run((host) => commands_1.default.open(host)));
67
- program.command("start").description("Start the central proxy").action(run(() => commands_1.default.start()));
69
+ const portOptions = (command) => command
70
+ .option("-p, --port <port>", "HTTP port (default 80, or the last one you chose)")
71
+ .option("--https-port <port>", "HTTPS port (default 443, or the last one you chose)");
72
+ portOptions(program.command("start").description("Start the central proxy")).action(run((options) => commands_1.default.start(options)));
68
73
  program.command("stop").description("Stop the central proxy (keeps hosts and logs)").action(run(() => commands_1.default.stop()));
69
- program.command("restart").description("Restart the central proxy").action(run(() => commands_1.default.restart()));
74
+ portOptions(program.command("restart").description("Restart the central proxy")).action(run((options) => commands_1.default.restart(options)));
70
75
  program.command("kill").description("Stop the proxy, remove all hosts and clear logs").action(run(() => commands_1.default.kill()));
71
76
  program.command("trust").description("Install the locadot CA in the system trust store (sudo)").action(run(() => commands_1.default.trust()));
72
77
  program.command("untrust").description("Remove the locadot CA from the system trust store (sudo)").action(run(() => commands_1.default.untrust()));
@@ -52,6 +52,27 @@ const startup_1 = __importDefault(require("../utils/startup"));
52
52
  const certs_1 = require("../utils/certs");
53
53
  const trust_1 = require("../utils/trust");
54
54
  const print = (message) => logger_1.default.info(message);
55
+ /** Applies --port/--https-port and saves them, so `add`, `status`, `open` and boot startup use the same ports. */
56
+ const usePorts = (options) => {
57
+ if (options.port === undefined && options.httpsPort === undefined)
58
+ return false;
59
+ const parse = (value, flag) => {
60
+ if (value === undefined)
61
+ return undefined;
62
+ const port = constants_1.default.validPort(value);
63
+ if (!port)
64
+ throw new localhost_1.InputError(`❌ ${flag} must be a port between 1 and 65535, got "${value}".`);
65
+ return port;
66
+ };
67
+ const httpPort = parse(options.port, "--port") ?? constants_1.default.server.httpPort;
68
+ const httpsPort = parse(options.httpsPort, "--https-port") ?? constants_1.default.server.httpsPort;
69
+ if (httpPort === httpsPort)
70
+ throw new localhost_1.InputError(`❌ HTTP and HTTPS can't share port ${httpPort}.`);
71
+ Object.assign(constants_1.default.server, { httpPort, httpsPort });
72
+ file_1.default.ensureDir();
73
+ fs_1.default.writeFileSync(constants_1.default.paths.CONFIG_FILE, JSON.stringify({ httpPort, httpsPort }, null, 2) + "\n");
74
+ return true;
75
+ };
55
76
  const urlFor = (host, secure = true) => {
56
77
  const port = secure ? constants_1.default.server.httpsPort : constants_1.default.server.httpPort;
57
78
  const standard = secure ? 443 : 80;
@@ -90,7 +111,7 @@ const warnIfDown = async (entry) => {
90
111
  };
91
112
  class Commands {
92
113
  static async add(options) {
93
- const { host, entry } = await hosts_1.default.add({ host: options.host, target: resolveTarget(options), insecure: options.insecure });
114
+ const { host, entry } = await hosts_1.default.add({ host: options.host, target: resolveTarget(options), insecure: options.insecure, cors: options.cors });
94
115
  const { target } = entry;
95
116
  await ensureRunning(options);
96
117
  print(`✅ ${(0, exports.urlFor)(host)} → ${target}`);
@@ -100,7 +121,7 @@ class Commands {
100
121
  await warnIfDown(entry);
101
122
  }
102
123
  static async update(options) {
103
- const { host, entry } = await hosts_1.default.update({ host: options.host, target: resolveTarget(options), insecure: options.insecure });
124
+ const { host, entry } = await hosts_1.default.update({ host: options.host, target: resolveTarget(options), insecure: options.insecure, cors: options.cors });
104
125
  const { target } = entry;
105
126
  await ensureRunning(options);
106
127
  print(`✅ Updated ${(0, exports.urlFor)(host)} → ${target}`);
@@ -122,7 +143,7 @@ class Commands {
122
143
  }
123
144
  const width = Math.max(...hosts.map(([host]) => (0, exports.urlFor)(host).length));
124
145
  for (const [host, entry] of hosts) {
125
- print(`${(0, exports.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}`);
146
+ print(`${(0, exports.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}`);
126
147
  }
127
148
  print(`☑️ Total: ${hosts.length}.`);
128
149
  }
@@ -174,7 +195,7 @@ class Commands {
174
195
  check(listening, `Port ${port} answering`, "The proxy is running but not listening; check `locadot logs`.");
175
196
  }
176
197
  else if (listening) {
177
- check(false, `Port ${port} is free`, `Something else holds port ${port}. Find it with \`sudo lsof -i :${port}\`, or set LOCADOT_${port === constants_1.default.server.httpPort ? "HTTP" : "HTTPS"}_PORT.`);
198
+ check(false, `Port ${port} is free`, `Something else holds port ${port}. Find it with \`sudo lsof -i :${port}\`, or pick another with \`locadot start ${port === constants_1.default.server.httpPort ? "--port" : "--https-port"} <port>\`.`);
178
199
  }
179
200
  }
180
201
  if (info && (await localhost_1.default.isPortListening(constants_1.default.server.httpPort))) {
@@ -213,19 +234,30 @@ class Commands {
213
234
  if (failures)
214
235
  process.exitCode = 1;
215
236
  }
216
- static async start() {
217
- const wasRunning = proxy_1.default.running();
218
- const info = await proxy_1.default.start();
219
- print(wasRunning ? `☑️ Proxy already running (pid ${info.pid}).` : `🚀 Central proxy started (pid ${info.pid}).`);
237
+ static async start(options = {}) {
238
+ const moved = usePorts(options);
239
+ const running = proxy_1.default.running();
240
+ // Already up on other ports: the new ones only take effect after a restart.
241
+ if (running && (running.httpPort !== constants_1.default.server.httpPort || running.httpsPort !== constants_1.default.server.httpsPort)) {
242
+ const info = await proxy_1.default.restart();
243
+ print(`🔁 Proxy restarted on http ${info.httpPort}, https ${info.httpsPort} (pid ${info.pid}).`);
244
+ }
245
+ else {
246
+ const info = await proxy_1.default.start();
247
+ print(running ? `☑️ Proxy already running (pid ${info.pid}).` : `🚀 Central proxy started (pid ${info.pid}).`);
248
+ if (moved || !running)
249
+ print(` Ports: http ${info.httpPort}, https ${info.httpsPort}`);
250
+ }
220
251
  print(` Dashboard: ${(0, exports.urlFor)("localhost")}`);
221
252
  }
222
253
  static async stop() {
223
254
  const stopped = await proxy_1.default.stop();
224
255
  print(stopped ? constants_1.default.proxyInfo.softClose : "☑️ Proxy was not running.");
225
256
  }
226
- static async restart() {
257
+ static async restart(options = {}) {
258
+ usePorts(options);
227
259
  const info = await proxy_1.default.restart();
228
- print(`☑️ Proxy restarted (pid ${info.pid}).`);
260
+ print(`☑️ Proxy restarted on http ${info.httpPort}, https ${info.httpsPort} (pid ${info.pid}).`);
229
261
  }
230
262
  static async kill() {
231
263
  await proxy_1.default.kill();
@@ -262,6 +294,7 @@ class Commands {
262
294
  ["Certs", constants_1.default.paths.CERT_DIR],
263
295
  ["CA cert", (0, certs_1.caCertPath)()],
264
296
  ["API token", constants_1.default.paths.API_TOKEN],
297
+ ["Config", constants_1.default.paths.CONFIG_FILE],
265
298
  ];
266
299
  rows.forEach(([label, value]) => console.log(`${label.padEnd(10)} ${value}`));
267
300
  }
package/dist/lib/hosts.js CHANGED
@@ -69,7 +69,7 @@ class HostOps {
69
69
  if (existing) {
70
70
  throw new ConflictError(`❌ ${host} is already mapped to ${existing.target}. Use \`locadot update --host ${host} ...\` instead.`);
71
71
  }
72
- registry.hosts[host] = { target, insecure: input.insecure || undefined, createdAt: now, updatedAt: now };
72
+ registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, createdAt: now, updatedAt: now };
73
73
  return registry.hosts[host];
74
74
  });
75
75
  return { host, entry };
@@ -82,7 +82,8 @@ class HostOps {
82
82
  if (!existing)
83
83
  throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
84
84
  const insecure = input.insecure ?? existing.insecure;
85
- registry.hosts[host] = { ...existing, target, insecure: insecure || undefined, updatedAt: new Date().toISOString() };
85
+ const cors = input.cors ?? existing.cors;
86
+ registry.hosts[host] = { ...existing, target, insecure: insecure || undefined, cors: cors || undefined, updatedAt: new Date().toISOString() };
86
87
  return registry.hosts[host];
87
88
  });
88
89
  return { host, entry };
package/dist/lib/http.js CHANGED
@@ -3,10 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.hostOf = void 0;
6
+ exports.applyCors = exports.rewriteBody = exports.rewriteOrigins = exports.originMap = exports.hostOf = void 0;
7
+ const zlib_1 = __importDefault(require("zlib"));
7
8
  const template_1 = require("../constants/template");
8
9
  const constants_1 = __importDefault(require("../constants"));
9
10
  const logger_1 = __importDefault(require("../utils/logger"));
11
+ const passthrough_1 = require("./passthrough");
10
12
  /** Host header without port, lowercased; handles [::1]:443. */
11
13
  const hostOf = (req) => {
12
14
  const raw = (req.headers.host || "").trim().toLowerCase();
@@ -22,7 +24,7 @@ const dashboardUrl = (req) => {
22
24
  const standard = tls ? 443 : 80;
23
25
  return `${tls ? "https" : "http"}://localhost${port === standard ? "" : `:${port}`}/`;
24
26
  };
25
- const proxyOptions = (req, entry) => ({
27
+ const proxyOptions = (req, entry, lookup) => ({
26
28
  target: entry.target,
27
29
  changeOrigin: true,
28
30
  xfwd: true,
@@ -34,8 +36,161 @@ const proxyOptions = (req, entry) => ({
34
36
  hostRewrite: req.headers.host,
35
37
  protocolRewrite: isTls(req) ? "https" : "http",
36
38
  cookieDomainRewrite: { "*": "" },
37
- headers: { "X-Original-Host": req.headers.host || "" },
39
+ headers: { "X-Original-Host": req.headers.host || "", ...(entry.cors ? { ...sameOriginHeaders(req, entry, lookup), ...DECODABLE } : {}) },
38
40
  });
41
+ // --cors bodies are rewritten, so only ask for encodings we can decode (browsers also offer zstd).
42
+ const DECODABLE = { "Accept-Encoding": "gzip, deflate, br" };
43
+ const viaOptions = (req, entry, via) => ({
44
+ target: `${via.scheme}://${via.host}`,
45
+ changeOrigin: true,
46
+ ws: true,
47
+ secure: !entry.insecure,
48
+ cookieDomainRewrite: { "*": "" },
49
+ headers: { ...(0, passthrough_1.viaHeaders)(req, via, entry.target), ...DECODABLE },
50
+ });
51
+ /**
52
+ * --cors: the upstream should see a request from a site it trusts, so origin/CSRF checks pass.
53
+ * A page on another mapped domain (signalsant.localhost calling api.signalsant.localhost) is
54
+ * sent as that domain's real origin; anything else as the target's own origin.
55
+ */
56
+ const sameOriginHeaders = (req, entry, lookup) => {
57
+ const upstreamOrigin = (value) => {
58
+ try {
59
+ const caller = lookup(new URL(value).hostname);
60
+ if (caller)
61
+ return new URL(caller.target).origin;
62
+ }
63
+ catch { }
64
+ return new URL(entry.target).origin;
65
+ };
66
+ const headers = {};
67
+ if (req.headers.origin)
68
+ headers.Origin = upstreamOrigin(req.headers.origin);
69
+ if (req.headers.referer) {
70
+ try {
71
+ const referer = new URL(req.headers.referer);
72
+ headers.Referer = upstreamOrigin(referer.href) + referer.pathname + referer.search;
73
+ }
74
+ catch {
75
+ headers.Referer = new URL(entry.target).origin + "/";
76
+ }
77
+ }
78
+ return headers;
79
+ };
80
+ const REWRITABLE = /^(text\/(?!event-stream)|application\/(javascript|x-javascript|ecmascript|json|xml|[\w.+-]+\+(json|xml))\b)/i;
81
+ const DECODERS = {
82
+ gzip: zlib_1.default.gunzipSync,
83
+ "x-gzip": zlib_1.default.gunzipSync,
84
+ deflate: zlib_1.default.inflateSync,
85
+ br: zlib_1.default.brotliDecompressSync,
86
+ };
87
+ /** [real origin, local origin] for every mapping, e.g. https://api.x.com → https://api.x.localhost. */
88
+ const originMap = (req, hosts) => {
89
+ const tls = isTls(req);
90
+ const port = tls ? constants_1.default.server.httpsPort : constants_1.default.server.httpPort;
91
+ const suffix = port === (tls ? 443 : 80) ? "" : `:${port}`;
92
+ const self = (0, exports.hostOf)(req);
93
+ const pairs = [];
94
+ for (const [host, entry] of Object.entries(hosts)) {
95
+ try {
96
+ pairs.push([new URL(entry.target).origin, `${tls ? "https" : "http"}://${host}${suffix}`, host === self]);
97
+ }
98
+ catch { }
99
+ }
100
+ // Longest first, so https://a.x.com is not half-replaced by a mapping for https://x.com;
101
+ // when two domains share a target, the one serving this response wins.
102
+ return pairs
103
+ .sort((a, b) => b[0].length - a[0].length || Number(b[2]) - Number(a[2]))
104
+ .map(([from, to]) => [from, to]);
105
+ };
106
+ exports.originMap = originMap;
107
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ /** Replaces real origins with local ones, plain and JSON-escaped (https:\/\/…). */
109
+ const rewriteOrigins = (text, pairs) => {
110
+ for (const [from, to] of pairs) {
111
+ for (const [a, b] of [[from, to], [from.replace(/\//g, "\\/"), to.replace(/\//g, "\\/")]]) {
112
+ text = text.replace(new RegExp(escapeRegex(a) + "(?![\\w.-])", "gi"), b);
113
+ }
114
+ }
115
+ return text;
116
+ };
117
+ exports.rewriteOrigins = rewriteOrigins;
118
+ /**
119
+ * --cors: pages that call other domains by absolute URL (`https://api.x.com`) bypass the proxy,
120
+ * so the browser applies the real API's CORS policy. Rewriting mapped origins in text bodies
121
+ * keeps those calls on .localhost. Buffers the body; streams (SSE, binary) pass through.
122
+ */
123
+ const rewriteBody = (req, res, proxyRes, pairs, inject = false) => {
124
+ const headers = proxyRes.headers;
125
+ const encoding = String(headers["content-encoding"] || "identity").toLowerCase();
126
+ if (!pairs.length || req.method === "HEAD" || !REWRITABLE.test(String(headers["content-type"] || "")))
127
+ return;
128
+ if (encoding !== "identity" && !DECODERS[encoding])
129
+ return;
130
+ if (proxyRes.statusCode === 204 || proxyRes.statusCode === 304)
131
+ return;
132
+ delete headers["content-length"];
133
+ delete headers["content-encoding"];
134
+ delete headers["transfer-encoding"];
135
+ const chunks = [];
136
+ const end = res.end.bind(res);
137
+ res.write = ((chunk) => {
138
+ if (chunk)
139
+ chunks.push(Buffer.from(chunk));
140
+ return true;
141
+ });
142
+ res.end = ((chunk) => {
143
+ if (chunk && typeof chunk !== "function")
144
+ chunks.push(Buffer.from(chunk));
145
+ let body = Buffer.concat(chunks);
146
+ try {
147
+ if (encoding !== "identity")
148
+ body = DECODERS[encoding](body);
149
+ let text = (0, exports.rewriteOrigins)(body.toString("utf8"), pairs);
150
+ if (inject && /^text\/html/i.test(String(headers["content-type"])))
151
+ text = (0, passthrough_1.injectShim)(text);
152
+ body = Buffer.from(text, "utf8");
153
+ }
154
+ catch (error) {
155
+ logger_1.default.warn(`${(0, exports.hostOf)(req)}: could not rewrite response body: ${error}`);
156
+ res.statusCode = 502;
157
+ body = Buffer.alloc(0);
158
+ }
159
+ if (!res.headersSent)
160
+ res.setHeader("Content-Length", body.length);
161
+ return end(body);
162
+ });
163
+ };
164
+ exports.rewriteBody = rewriteBody;
165
+ const isPreflight = (req) => req.method === "OPTIONS" && !!req.headers.origin && !!req.headers["access-control-request-method"];
166
+ /** Headers that let the calling page read the response, credentials included. */
167
+ const allowOrigin = (req) => req.headers.origin
168
+ ? { "Access-Control-Allow-Origin": req.headers.origin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" }
169
+ : { "Access-Control-Allow-Origin": "*" };
170
+ /**
171
+ * Rewrites an upstream response for a --cors host: our CORS headers replace the upstream's,
172
+ * and cookies become SameSite=None so a page on another origin can send them back.
173
+ */
174
+ const applyCors = (req, headers) => {
175
+ for (const name of Object.keys(headers)) {
176
+ if (name.startsWith("access-control-"))
177
+ delete headers[name];
178
+ }
179
+ const exposed = Object.keys(headers).filter((name) => name !== "set-cookie" && name !== "vary");
180
+ headers["access-control-allow-origin"] = req.headers.origin || "*";
181
+ if (req.headers.origin) {
182
+ headers["access-control-allow-credentials"] = "true";
183
+ const vary = String(headers.vary || "");
184
+ if (!/(^|,)\s*(origin|\*)\s*(,|$)/i.test(vary))
185
+ headers.vary = vary ? `${vary}, Origin` : "Origin";
186
+ }
187
+ if (exposed.length)
188
+ headers["access-control-expose-headers"] = exposed.join(", ");
189
+ if (isTls(req) && headers["set-cookie"]) {
190
+ headers["set-cookie"] = headers["set-cookie"].map((cookie) => cookie.replace(/;\s*samesite=[^;]*/gi, "").replace(/;\s*secure\b(?!=)/gi, "") + "; SameSite=None; Secure");
191
+ }
192
+ };
193
+ exports.applyCors = applyCors;
39
194
  const record = (stats, host, status, ms, error) => {
40
195
  const current = stats.get(host) || { hits: 0, errors: 0 };
41
196
  current.hits += 1;
@@ -60,18 +215,51 @@ class HttpModule {
60
215
  res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
61
216
  return;
62
217
  }
218
+ // Answered here: upstreams often reject OPTIONS or don't send the headers the browser wants.
219
+ if (entry.cors && isPreflight(req)) {
220
+ const requested = req.headers["access-control-request-headers"];
221
+ res.writeHead(204, {
222
+ ...allowOrigin(req),
223
+ "Access-Control-Allow-Methods": String(req.headers["access-control-request-method"]),
224
+ ...(requested ? { "Access-Control-Allow-Headers": String(requested) } : {}),
225
+ ...(req.headers["access-control-request-private-network"] ? { "Access-Control-Allow-Private-Network": "true" } : {}),
226
+ "Access-Control-Max-Age": "600",
227
+ "Content-Length": "0",
228
+ });
229
+ res.end();
230
+ return;
231
+ }
232
+ if (entry.cors && req.url?.split("?")[0] === passthrough_1.SHIM_PATH) {
233
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store" });
234
+ res.end(passthrough_1.shimScript);
235
+ return;
236
+ }
237
+ const via = entry.cors ? (0, passthrough_1.parseVia)(req.url) : undefined;
238
+ if (via && !(0, passthrough_1.isSameOrigin)(req)) {
239
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
240
+ res.end("locadot: the pass-through only serves the page's own requests.\n");
241
+ return;
242
+ }
63
243
  const started = Date.now();
64
244
  res.once("finish", () => {
65
245
  record(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500);
66
246
  });
67
- ctx.proxy.web(req, res, proxyOptions(req, entry), (err) => {
68
- logger_1.default.warn(`${host} → ${entry.target}: ${err?.code || err?.message}`);
247
+ let options = proxyOptions(req, entry, ctx.lookup);
248
+ if (via) {
249
+ req.locadotVia = via;
250
+ options = viaOptions(req, entry, via);
251
+ req.url = via.path;
252
+ }
253
+ const upstream = via ? `${via.scheme}://${via.host}` : entry.target;
254
+ ctx.proxy.web(req, res, options, (err) => {
255
+ logger_1.default.warn(`${host} → ${upstream}: ${err?.code || err?.message}`);
69
256
  if (res.headersSent) {
70
257
  res.destroy();
71
258
  return;
72
259
  }
73
- res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
74
- res.end((0, template_1.upstreamDown)(host, entry.target, err?.code || err?.message || "error", dashboardUrl(req)));
260
+ // With --cors the page should see a 502, not a CORS error.
261
+ res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", ...(entry.cors ? allowOrigin(req) : {}) });
262
+ res.end((0, template_1.upstreamDown)(host, upstream, err?.code || err?.message || "error", dashboardUrl(req)));
75
263
  });
76
264
  }
77
265
  catch (error) {
@@ -90,9 +278,19 @@ class HttpModule {
90
278
  socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
91
279
  return;
92
280
  }
281
+ const via = entry.cors ? (0, passthrough_1.parseVia)(req.url) : undefined;
282
+ if (via && !(0, passthrough_1.isSameOrigin)(req)) {
283
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
284
+ return;
285
+ }
93
286
  logger_1.default.debug(`WebSocket upgrade for ${host}`);
94
- ctx.proxy.ws(req, socket, head, proxyOptions(req, entry), (err) => {
95
- logger_1.default.warn(`${host} WebSocket → ${entry.target}: ${err?.code || err?.message}`);
287
+ let options = proxyOptions(req, entry, ctx.lookup);
288
+ if (via) {
289
+ options = viaOptions(req, entry, via);
290
+ req.url = via.path;
291
+ }
292
+ ctx.proxy.ws(req, socket, head, options, (err) => {
293
+ logger_1.default.warn(`${host} WebSocket → ${via ? `${via.scheme}://${via.host}` : entry.target}: ${err?.code || err?.message}`);
96
294
  socket.destroy();
97
295
  });
98
296
  }
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rewriteViaResponse = exports.viaHeaders = exports.isSameSite = exports.shimScript = exports.injectShim = exports.shimTag = exports.isSameOrigin = exports.viaPath = exports.parseVia = exports.SHIM_PATH = exports.PREFIX = void 0;
4
+ /**
5
+ * --cors pass-through: a page on a --cors domain calls other origins (api.x.com, a CDN's JSON,
6
+ * a third-party API) by absolute URL, and the browser enforces *their* CORS policy. The shim
7
+ * injected into its HTML sends those calls to /__locadot/x/<scheme>/<host>/<path> on the page's
8
+ * own origin instead, so the browser sees a same-origin request, and locadot forwards it upstream
9
+ * as the real site. No per-domain mapping needed.
10
+ */
11
+ exports.PREFIX = "/__locadot/";
12
+ exports.SHIM_PATH = `${exports.PREFIX}shim.js`;
13
+ const VIA = `${exports.PREFIX}x/`;
14
+ /** Parses /__locadot/x/https/api.x.com:8443/v1?q → {https, api.x.com:8443, /v1?q}. */
15
+ const parseVia = (url) => {
16
+ const match = (url || "").match(/^\/__locadot\/x\/(https?|wss?)\/([a-z0-9.-]+(?::\d{1,5})?)(\/.*)?$/i);
17
+ if (!match)
18
+ return undefined;
19
+ return { scheme: match[1].toLowerCase(), host: match[2].toLowerCase(), path: match[3] || "/" };
20
+ };
21
+ exports.parseVia = parseVia;
22
+ /** The local path the shim uses for an absolute URL; the inverse of parseVia. */
23
+ const viaPath = (url) => `${VIA}${url.protocol.slice(0, -1)}/${url.host}${url.pathname}${url.search}`;
24
+ exports.viaPath = viaPath;
25
+ /**
26
+ * Only the page itself may use the pass-through. A link or form on another site would otherwise
27
+ * turn the proxy into a way to reach any host from the user's machine.
28
+ */
29
+ const isSameOrigin = (req) => {
30
+ const site = req.headers["sec-fetch-site"];
31
+ if (site)
32
+ return site === "same-origin";
33
+ const origin = req.headers.origin;
34
+ if (!origin)
35
+ return true;
36
+ try {
37
+ return new URL(origin).host === req.headers.host;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ };
43
+ exports.isSameOrigin = isSameOrigin;
44
+ exports.shimTag = `<script src="${exports.SHIM_PATH}"></script>`;
45
+ /** Adds the shim as the first script in <head>, so it runs before the app's own scripts. */
46
+ const injectShim = (html) => {
47
+ if (html.includes(exports.shimTag))
48
+ return html;
49
+ const head = html.match(/<head\b[^>]*>/i);
50
+ if (head)
51
+ return html.replace(head[0], head[0] + exports.shimTag);
52
+ return exports.shimTag + html;
53
+ };
54
+ exports.injectShim = injectShim;
55
+ exports.shimScript = `(() => {
56
+ if (window.__locadotShim) return;
57
+ window.__locadotShim = true;
58
+ const VIA = ${JSON.stringify(VIA)};
59
+ const route = (input) => {
60
+ try {
61
+ const url = new URL(String(input), location.href);
62
+ if (!/^(https?|wss?):$/.test(url.protocol)) return null;
63
+ const local = url.hostname === "localhost" || url.hostname.endsWith(".localhost") || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
64
+ if (local || url.host === location.host) return null;
65
+ const ws = url.protocol === "ws:" || url.protocol === "wss:";
66
+ const base = ws ? (location.protocol === "https:" ? "wss://" : "ws://") + location.host : location.origin;
67
+ return base + VIA + url.protocol.slice(0, -1) + "/" + url.host + url.pathname + url.search + url.hash;
68
+ } catch (e) {
69
+ return null;
70
+ }
71
+ };
72
+ const fetch0 = window.fetch;
73
+ if (fetch0) {
74
+ window.fetch = function (input, init) {
75
+ if (typeof Request !== "undefined" && input instanceof Request) {
76
+ const to = route(input.url);
77
+ if (to) input = new Request(to, input);
78
+ } else {
79
+ const to = route(input);
80
+ if (to) input = to;
81
+ }
82
+ return fetch0.call(this, input, init);
83
+ };
84
+ }
85
+ if (window.XMLHttpRequest) {
86
+ const open0 = XMLHttpRequest.prototype.open;
87
+ XMLHttpRequest.prototype.open = function (method, url) {
88
+ const args = Array.prototype.slice.call(arguments);
89
+ const to = route(url);
90
+ if (to) args[1] = to;
91
+ return open0.apply(this, args);
92
+ };
93
+ }
94
+ const wrap = (name) => {
95
+ const Original = window[name];
96
+ if (!Original) return;
97
+ const Wrapped = function (url, options) {
98
+ return new Original(route(url) || url, options);
99
+ };
100
+ Wrapped.prototype = Original.prototype;
101
+ Object.getOwnPropertyNames(Original).forEach((key) => {
102
+ if (!(key in Wrapped)) try { Wrapped[key] = Original[key]; } catch (e) {}
103
+ });
104
+ window[name] = Wrapped;
105
+ };
106
+ wrap("EventSource");
107
+ wrap("WebSocket");
108
+ if (navigator.sendBeacon) {
109
+ const beacon0 = navigator.sendBeacon.bind(navigator);
110
+ navigator.sendBeacon = (url, data) => beacon0(route(url) || url, data);
111
+ }
112
+ })();
113
+ `;
114
+ /** The site's own domain without a leading www: signalsant.com for https://www.signalsant.com. */
115
+ const siteDomain = (target) => new URL(target).hostname.replace(/^www\./, "");
116
+ /** api.signalsant.com is the same site as signalsant.com; a CDN or third-party API is not. */
117
+ const isSameSite = (host, target) => {
118
+ const hostname = host.replace(/:\d+$/, "");
119
+ const domain = siteDomain(target);
120
+ return hostname === domain || hostname.endsWith(`.${domain}`);
121
+ };
122
+ exports.isSameSite = isSameSite;
123
+ /** Upstream headers for a pass-through call: the request looks like it came from the real site. */
124
+ const viaHeaders = (req, via, target) => {
125
+ const site = new URL(target).origin;
126
+ const headers = { Origin: site };
127
+ try {
128
+ if (req.headers.referer) {
129
+ const referer = new URL(req.headers.referer);
130
+ headers.Referer = site + referer.pathname + referer.search;
131
+ }
132
+ }
133
+ catch { }
134
+ // The page's cookies belong to its own site; don't hand them to third parties.
135
+ if (!(0, exports.isSameSite)(via.host, target))
136
+ delete req.headers.cookie;
137
+ return headers;
138
+ };
139
+ exports.viaHeaders = viaHeaders;
140
+ /**
141
+ * Keeps a pass-through response on the pass-through: redirects are routed back through it,
142
+ * host-only cookies are scoped to the upstream's path, and third-party cookies are dropped.
143
+ */
144
+ const rewriteViaResponse = (headers, via, target) => {
145
+ const base = `${VIA}${via.scheme}/${via.host}`;
146
+ const location = headers.location;
147
+ if (location) {
148
+ if (location.startsWith("/") && !location.startsWith("//"))
149
+ headers.location = base + location;
150
+ else {
151
+ try {
152
+ const url = new URL(location, `${via.scheme}://${via.host}/`);
153
+ if (/^https?:$/.test(url.protocol) && !/(^|\.)localhost$/.test(url.hostname))
154
+ headers.location = (0, exports.viaPath)(url) + url.hash;
155
+ }
156
+ catch { }
157
+ }
158
+ }
159
+ const cookies = headers["set-cookie"];
160
+ if (!cookies)
161
+ return;
162
+ if (!(0, exports.isSameSite)(via.host, target)) {
163
+ delete headers["set-cookie"];
164
+ return;
165
+ }
166
+ headers["set-cookie"] = cookies.map((cookie) => {
167
+ // Domain=.signalsant.com is shared with the page on the real site, so keep its path.
168
+ if (/;\s*domain=/i.test(cookie))
169
+ return cookie;
170
+ const path = cookie.match(/;\s*path=([^;]*)/i)?.[1]?.trim() || "/";
171
+ return cookie.replace(/;\s*path=[^;]*/gi, "") + `; Path=${base}${path === "/" ? "" : path}`;
172
+ });
173
+ };
174
+ exports.rewriteViaResponse = rewriteViaResponse;
package/dist/proxy.js CHANGED
@@ -52,7 +52,13 @@ class ProxyHandler {
52
52
  const child = (0, child_process_1.spawn)(command, args, {
53
53
  detached: true,
54
54
  stdio: ["ignore", logFd, logFd],
55
- env: { ...process.env, LOCADOT_HOME: constants_1.default.paths.HOME, LOCADOT_ROLE: "proxy" },
55
+ env: {
56
+ ...process.env,
57
+ LOCADOT_HOME: constants_1.default.paths.HOME,
58
+ LOCADOT_ROLE: "proxy",
59
+ LOCADOT_HTTP_PORT: String(constants_1.default.server.httpPort),
60
+ LOCADOT_HTTPS_PORT: String(constants_1.default.server.httpsPort),
61
+ },
56
62
  windowsHide: true,
57
63
  });
58
64
  fs_1.default.closeSync(logFd);
package/dist/server.js CHANGED
@@ -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
  };
@@ -12,8 +45,9 @@ const http_proxy_1 = __importDefault(require("http-proxy"));
12
45
  const constants_1 = __importDefault(require("./constants"));
13
46
  const registry_1 = __importDefault(require("./lib/registry"));
14
47
  const locadot_file_1 = __importDefault(require("./lib/locadot-file"));
15
- const http_2 = __importDefault(require("./lib/http"));
48
+ const http_2 = __importStar(require("./lib/http"));
16
49
  const localhost_1 = __importDefault(require("./lib/localhost"));
50
+ const passthrough_1 = require("./lib/passthrough");
17
51
  const file_1 = __importDefault(require("./utils/file"));
18
52
  const logger_1 = __importDefault(require("./utils/logger"));
19
53
  const certs_1 = require("./utils/certs");
@@ -68,7 +102,7 @@ async function startCentralProxy() {
68
102
  // Hop-by-hop headers describe the upstream connection, not ours. Apache sends
69
103
  // `Connection: Upgrade, close` + `Upgrade: h2`, which made us close the browser's
70
104
  // socket after every response (ERR_TOO_MANY_RETRIES on asset-heavy pages).
71
- proxy.on("proxyRes", (proxyRes) => {
105
+ proxy.on("proxyRes", (proxyRes, req, res) => {
72
106
  if (proxyRes.statusCode === 101)
73
107
  return;
74
108
  const listed = String(proxyRes.headers.connection || "")
@@ -78,6 +112,17 @@ async function startCentralProxy() {
78
112
  for (const name of [...listed, "connection", "keep-alive", "upgrade", "proxy-connection"]) {
79
113
  delete proxyRes.headers[name];
80
114
  }
115
+ const entry = registry.hosts[(0, http_2.hostOf)(req)];
116
+ if (!entry?.cors)
117
+ return;
118
+ (0, http_2.applyCors)(req, proxyRes.headers);
119
+ const via = req.locadotVia;
120
+ const pairs = (0, http_2.originMap)(req, registry.hosts);
121
+ if (via)
122
+ (0, passthrough_1.rewriteViaResponse)(proxyRes.headers, via, entry.target);
123
+ else if (proxyRes.headers.location)
124
+ proxyRes.headers.location = (0, http_2.rewriteOrigins)(proxyRes.headers.location, pairs);
125
+ (0, http_2.rewriteBody)(req, res, proxyRes, pairs, !via);
81
126
  });
82
127
  const info = {
83
128
  pid: process.pid,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "1.6.0-beta.2",
3
+ "version": "1.6.0-beta.4",
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",