locadot 1.6.0-beta.1 → 1.6.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- The version in `package.json` is still 1.5.7. The owner decides the bump. **2.0.0** is suggested because of the
5
+ The version in `package.json` is 1.6.0-beta.3 (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,7 @@ breaking items below.
13
13
  - Bare `localhost` is reserved for the dashboard and can't be mapped.
14
14
 
15
15
  ### Added
16
+ - `--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. (ENH-12)
16
17
  - 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
18
  tail and clear logs, and stop the proxy. (FEAT-05)
18
19
  - A token-protected JSON API for scripts and AI agents (`POST/PUT/DELETE /api/hosts`, `/api/startup`, `/api/trust`, `/api/logs`, `/api/proxy/stop`),
@@ -28,6 +29,8 @@ breaking items below.
28
29
  - Test suite (`pnpm test`) and a `prepublishOnly` build.
29
30
 
30
31
  ### Fixed
32
+ - Windows: `startup:enable` no longer needs admin rights. It used to fail with "Access is denied". Start at logon now uses the user's Startup folder instead of a scheduled task. (BUG-14)
33
+ - Windows: console windows no longer flash up from the background proxy's `certutil`/`schtasks` checks. Start at logon now runs hidden and writes to the log. (BUG-13)
31
34
  - Upstream `Connection`/`Upgrade` headers are no longer forwarded. Apache sites proxied through locadot kept dropping the connection and lost assets. (BUG-12)
32
35
  - `stop`, `restart` and `kill` now actually stop the proxy. (BUG-01)
33
36
  - A failed start is reported with the log tail and exits 1. (BUG-07)
package/README.md CHANGED
@@ -54,6 +54,7 @@ Options for `add` / `update`:
54
54
  | `-p, --port <port>` | Shorthand for `--target http://localhost:<port>`. |
55
55
  | `-t, --target <url>` | Any http(s) upstream. A bare `host:port` means `http://host:port`. |
56
56
  | `-k, --insecure` | Don't verify the TLS certificate of an `https` target (self-signed upstreams). |
57
+ | `--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. URLs hard-coded in pages still go to the real domain. |
57
58
  | `--no-start` | Save the mapping without starting the proxy. |
58
59
 
59
60
  ### Proxy
@@ -82,7 +83,7 @@ Options for `add` / `update`:
82
83
  | `locadot clear:logs` | Clear the log file. |
83
84
  | `locadot path` | Show every file locadot uses. `path:logs` and `path:hosts` print a single path. |
84
85
  | `locadot token` | Print the dashboard API token, for scripts and AI agents. |
85
- | `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron, macOS LaunchAgent) or logon (Windows scheduled task). |
86
+ | `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron, macOS LaunchAgent) or logon (a hidden script in the Windows Startup folder, no admin needed). |
86
87
 
87
88
  ---
88
89
 
@@ -93,7 +94,7 @@ are answered by locadot itself, not proxied. The page is a dark control panel:
93
94
 
94
95
  - **System:** whether the proxy runs as root/admin, whether ports below 1024 can be bound (with the Linux sysctl fix when they
95
96
  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
97
+ - **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
98
  each source → destination with up/down, status, latency, hits, errors, last access and average ms. It refreshes every 5 s.
98
99
  - **Logs:** a live tail with refresh and clear.
99
100
  - **CLI / API snippets** you can copy.
@@ -113,8 +114,8 @@ each time the proxy starts.
113
114
  | `GET /api/status` | | Proxy info, uptime, host count, and `system` (`platform`, `isRoot`, `canBindPrivileged`, `unprivilegedPortStart`, `caTrusted`, `startup`, `stateDir`). |
114
115
  | `GET /api/hosts` | | Every mapping with urls, probe (`up`, `status`, `ms`) and stats. |
115
116
  | `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). |
117
+ | `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false, "cors": false }` | Add a mapping (201; 409 if it exists). |
118
+ | `PUT /api/hosts/:host` | `{ "target": "https://example.com", "insecure": false, "cors": true }` | Change a mapping (404 if unknown). |
118
119
  | `DELETE /api/hosts/:host` | | Remove a mapping. |
119
120
  | `POST /api/startup` | `{ "enabled": true }` | Start at boot on/off. |
120
121
  | `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
@@ -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")
@@ -90,7 +90,7 @@ const warnIfDown = async (entry) => {
90
90
  };
91
91
  class Commands {
92
92
  static async add(options) {
93
- const { host, entry } = await hosts_1.default.add({ host: options.host, target: resolveTarget(options), insecure: options.insecure });
93
+ const { host, entry } = await hosts_1.default.add({ host: options.host, target: resolveTarget(options), insecure: options.insecure, cors: options.cors });
94
94
  const { target } = entry;
95
95
  await ensureRunning(options);
96
96
  print(`✅ ${(0, exports.urlFor)(host)} → ${target}`);
@@ -100,7 +100,7 @@ class Commands {
100
100
  await warnIfDown(entry);
101
101
  }
102
102
  static async update(options) {
103
- const { host, entry } = await hosts_1.default.update({ host: options.host, target: resolveTarget(options), insecure: options.insecure });
103
+ const { host, entry } = await hosts_1.default.update({ host: options.host, target: resolveTarget(options), insecure: options.insecure, cors: options.cors });
104
104
  const { target } = entry;
105
105
  await ensureRunning(options);
106
106
  print(`✅ Updated ${(0, exports.urlFor)(host)} → ${target}`);
@@ -122,7 +122,7 @@ class Commands {
122
122
  }
123
123
  const width = Math.max(...hosts.map(([host]) => (0, exports.urlFor)(host).length));
124
124
  for (const [host, entry] of hosts) {
125
- print(`${(0, exports.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}`);
125
+ print(`${(0, exports.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}`);
126
126
  }
127
127
  print(`☑️ Total: ${hosts.length}.`);
128
128
  }
@@ -280,6 +280,7 @@ class Commands {
280
280
  ? ["open", [url]]
281
281
  : ["xdg-open", [url]];
282
282
  try {
283
+ // windows-hide-exempt: run from the user's terminal (no popup), and hiding `cmd /c start` can hide the browser too.
283
284
  (0, child_process_1.spawn)(command, args, { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
284
285
  }
285
286
  catch { }
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,7 +3,7 @@ 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.hostOf = void 0;
7
7
  const template_1 = require("../constants/template");
8
8
  const constants_1 = __importDefault(require("../constants"));
9
9
  const logger_1 = __importDefault(require("../utils/logger"));
@@ -34,8 +34,54 @@ const proxyOptions = (req, entry) => ({
34
34
  hostRewrite: req.headers.host,
35
35
  protocolRewrite: isTls(req) ? "https" : "http",
36
36
  cookieDomainRewrite: { "*": "" },
37
- headers: { "X-Original-Host": req.headers.host || "" },
37
+ headers: { "X-Original-Host": req.headers.host || "", ...(entry.cors ? sameOriginHeaders(req, entry) : {}) },
38
38
  });
39
+ // --cors: the upstream should see a request from its own site, so origin/CSRF checks pass.
40
+ const sameOriginHeaders = (req, entry) => {
41
+ const origin = new URL(entry.target).origin;
42
+ const headers = {};
43
+ if (req.headers.origin)
44
+ headers.Origin = origin;
45
+ if (req.headers.referer) {
46
+ try {
47
+ const referer = new URL(req.headers.referer);
48
+ headers.Referer = origin + referer.pathname + referer.search;
49
+ }
50
+ catch {
51
+ headers.Referer = origin + "/";
52
+ }
53
+ }
54
+ return headers;
55
+ };
56
+ const isPreflight = (req) => req.method === "OPTIONS" && !!req.headers.origin && !!req.headers["access-control-request-method"];
57
+ /** Headers that let the calling page read the response, credentials included. */
58
+ const allowOrigin = (req) => req.headers.origin
59
+ ? { "Access-Control-Allow-Origin": req.headers.origin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" }
60
+ : { "Access-Control-Allow-Origin": "*" };
61
+ /**
62
+ * Rewrites an upstream response for a --cors host: our CORS headers replace the upstream's,
63
+ * and cookies become SameSite=None so a page on another origin can send them back.
64
+ */
65
+ const applyCors = (req, headers) => {
66
+ for (const name of Object.keys(headers)) {
67
+ if (name.startsWith("access-control-"))
68
+ delete headers[name];
69
+ }
70
+ const exposed = Object.keys(headers).filter((name) => name !== "set-cookie" && name !== "vary");
71
+ headers["access-control-allow-origin"] = req.headers.origin || "*";
72
+ if (req.headers.origin) {
73
+ headers["access-control-allow-credentials"] = "true";
74
+ const vary = String(headers.vary || "");
75
+ if (!/(^|,)\s*(origin|\*)\s*(,|$)/i.test(vary))
76
+ headers.vary = vary ? `${vary}, Origin` : "Origin";
77
+ }
78
+ if (exposed.length)
79
+ headers["access-control-expose-headers"] = exposed.join(", ");
80
+ if (isTls(req) && headers["set-cookie"]) {
81
+ headers["set-cookie"] = headers["set-cookie"].map((cookie) => cookie.replace(/;\s*samesite=[^;]*/gi, "").replace(/;\s*secure\b(?!=)/gi, "") + "; SameSite=None; Secure");
82
+ }
83
+ };
84
+ exports.applyCors = applyCors;
39
85
  const record = (stats, host, status, ms, error) => {
40
86
  const current = stats.get(host) || { hits: 0, errors: 0 };
41
87
  current.hits += 1;
@@ -60,6 +106,20 @@ class HttpModule {
60
106
  res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
61
107
  return;
62
108
  }
109
+ // Answered here: upstreams often reject OPTIONS or don't send the headers the browser wants.
110
+ if (entry.cors && isPreflight(req)) {
111
+ const requested = req.headers["access-control-request-headers"];
112
+ res.writeHead(204, {
113
+ ...allowOrigin(req),
114
+ "Access-Control-Allow-Methods": String(req.headers["access-control-request-method"]),
115
+ ...(requested ? { "Access-Control-Allow-Headers": String(requested) } : {}),
116
+ ...(req.headers["access-control-request-private-network"] ? { "Access-Control-Allow-Private-Network": "true" } : {}),
117
+ "Access-Control-Max-Age": "600",
118
+ "Content-Length": "0",
119
+ });
120
+ res.end();
121
+ return;
122
+ }
63
123
  const started = Date.now();
64
124
  res.once("finish", () => {
65
125
  record(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500);
@@ -70,7 +130,8 @@ class HttpModule {
70
130
  res.destroy();
71
131
  return;
72
132
  }
73
- res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
133
+ // With --cors the page should see a 502, not a CORS error.
134
+ res.writeHead(502, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", ...(entry.cors ? allowOrigin(req) : {}) });
74
135
  res.end((0, template_1.upstreamDown)(host, entry.target, err?.code || err?.message || "error", dashboardUrl(req)));
75
136
  });
76
137
  }
@@ -46,7 +46,7 @@ const canBindPrivileged = () => {
46
46
  return start !== null && start <= Math.min(constants_1.default.server.httpPort, constants_1.default.server.httpsPort);
47
47
  };
48
48
  exports.canBindPrivileged = canBindPrivileged;
49
- // Startup.info shells out (crontab/schtasks); don't do that on every 5 s dashboard poll.
49
+ // Startup.info may shell out (crontab); don't do that on every 5 s dashboard poll.
50
50
  const STARTUP_TTL_MS = 30000;
51
51
  let startupCache;
52
52
  const invalidateSystemStatus = () => {
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,7 +45,7 @@ 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"));
17
50
  const file_1 = __importDefault(require("./utils/file"));
18
51
  const logger_1 = __importDefault(require("./utils/logger"));
@@ -68,7 +101,7 @@ async function startCentralProxy() {
68
101
  // Hop-by-hop headers describe the upstream connection, not ours. Apache sends
69
102
  // `Connection: Upgrade, close` + `Upgrade: h2`, which made us close the browser's
70
103
  // socket after every response (ERR_TOO_MANY_RETRIES on asset-heavy pages).
71
- proxy.on("proxyRes", (proxyRes) => {
104
+ proxy.on("proxyRes", (proxyRes, req) => {
72
105
  if (proxyRes.statusCode === 101)
73
106
  return;
74
107
  const listed = String(proxyRes.headers.connection || "")
@@ -78,6 +111,8 @@ async function startCentralProxy() {
78
111
  for (const name of [...listed, "connection", "keep-alive", "upgrade", "proxy-connection"]) {
79
112
  delete proxyRes.headers[name];
80
113
  }
114
+ if (registry.hosts[(0, http_2.hostOf)(req)]?.cors)
115
+ (0, http_2.applyCors)(req, proxyRes.headers);
81
116
  });
82
117
  const info = {
83
118
  pid: process.pid,
@@ -3,6 +3,7 @@ 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.windowsLauncherScript = void 0;
6
7
  const path_1 = __importDefault(require("path"));
7
8
  const os_1 = __importDefault(require("os"));
8
9
  const fs_1 = __importDefault(require("fs"));
@@ -15,6 +16,10 @@ const SERVICE_NAME = "locadot-proxy";
15
16
  const MAC_LABEL = "com.locadot.proxy";
16
17
  const CRON_MARK = "# locadot-proxy";
17
18
  const MARKER = path_1.default.join(constants_1.default.paths.HOME, "startup.json");
19
+ // Per-user Startup folder: unlike an ONLOGON scheduled task it needs no admin rights.
20
+ const windowsLauncher = () => path_1.default.join(process.env.APPDATA || path_1.default.join(os_1.default.homedir(), "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${SERVICE_NAME}.vbs`);
21
+ // Earlier versions registered an ONLOGON scheduled task and kept the script in the state dir.
22
+ const LEGACY_LAUNCHER = path_1.default.join(constants_1.default.paths.HOME, "startup-hidden.vbs");
18
23
  const plistPath = () => path_1.default.join(os_1.default.homedir(), "Library", "LaunchAgents", `${MAC_LABEL}.plist`);
19
24
  const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
20
25
  // Resolved on use, not import: the entry path depends on how locadot was launched.
@@ -22,6 +27,25 @@ const launchArgs = () => {
22
27
  const { command, path: args } = locadot_file_1.default.getStartProxyFile();
23
28
  return { command, args: [...args, "--home", constants_1.default.paths.HOME] };
24
29
  };
30
+ /**
31
+ * Launching node.exe directly at logon opens a console window, and closing it kills the
32
+ * proxy. Explorer runs the .vbs with wscript, which has no console, and Run(…, 0) keeps cmd
33
+ * and node hidden too. cmd also appends the output to the log, like the cron line does.
34
+ */
35
+ const windowsLauncherScript = (command, args) => {
36
+ const env = `set LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort}&& set LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort}&&`;
37
+ const line = `cmd /d /c ${env} ${[command, ...args].map((a) => `"${a}"`).join(" ")} >> "${constants_1.default.paths.LOGS}" 2>&1`;
38
+ return `CreateObject("WScript.Shell").Run "${line.replace(/"/g, '""')}", 0, False\r\n`;
39
+ };
40
+ exports.windowsLauncherScript = windowsLauncherScript;
41
+ /** Best effort: deleting a task the user created without elevation needs none. */
42
+ const removeLegacyTask = () => {
43
+ try {
44
+ (0, child_process_1.execFileSync)("schtasks", ["/Delete", "/TN", SERVICE_NAME, "/F"], { stdio: "ignore", windowsHide: true });
45
+ }
46
+ catch { }
47
+ fs_1.default.rmSync(LEGACY_LAUNCHER, { force: true });
48
+ };
25
49
  function execSudo(cmd) {
26
50
  return new Promise((resolve, reject) => {
27
51
  sudo_prompt_1.default.exec(cmd, { name: "Locadot" }, (error, stdout, stderr) => {
@@ -56,10 +80,11 @@ class Startup {
56
80
  let method = "";
57
81
  switch (platform) {
58
82
  case "windows": {
59
- // Per-user logon task: keeps the user's app-data dir; ports 80/443 need no elevation on Windows.
60
- const tr = [command, ...args].map((a) => `"${a}"`).join(" ");
61
- (0, child_process_1.execFileSync)("schtasks", ["/Create", "/TN", SERVICE_NAME, "/TR", tr, "/SC", "ONLOGON", "/F"], { stdio: "inherit" });
62
- method = "schtasks";
83
+ // Runs at the user's logon; ports 80/443 need no elevation on Windows.
84
+ removeLegacyTask();
85
+ fs_1.default.mkdirSync(path_1.default.dirname(windowsLauncher()), { recursive: true });
86
+ fs_1.default.writeFileSync(windowsLauncher(), (0, exports.windowsLauncherScript)(command, args));
87
+ method = "startup-folder";
63
88
  break;
64
89
  }
65
90
  case "linux": {
@@ -71,7 +96,7 @@ class Startup {
71
96
  method = "root-crontab";
72
97
  }
73
98
  else {
74
- (0, child_process_1.execFileSync)("sh", ["-c", edit], { stdio: "inherit" });
99
+ (0, child_process_1.execFileSync)("sh", ["-c", edit], { stdio: "inherit", windowsHide: true });
75
100
  method = "user-crontab";
76
101
  }
77
102
  break;
@@ -98,7 +123,7 @@ class Startup {
98
123
  </plist>`;
99
124
  fs_1.default.mkdirSync(path_1.default.dirname(plistPath()), { recursive: true });
100
125
  fs_1.default.writeFileSync(plistPath(), plist);
101
- (0, child_process_1.execFileSync)("launchctl", ["load", "-w", plistPath()], { stdio: "inherit" });
126
+ (0, child_process_1.execFileSync)("launchctl", ["load", "-w", plistPath()], { stdio: "inherit", windowsHide: true });
102
127
  method = "launch-agent";
103
128
  break;
104
129
  }
@@ -114,21 +139,19 @@ class Startup {
114
139
  const method = Startup.marker()?.method;
115
140
  switch (platform) {
116
141
  case "windows":
117
- try {
118
- (0, child_process_1.execFileSync)("schtasks", ["/Delete", "/TN", SERVICE_NAME, "/F"], { stdio: "ignore" });
119
- }
120
- catch { }
142
+ removeLegacyTask();
143
+ fs_1.default.rmSync(windowsLauncher(), { force: true });
121
144
  break;
122
145
  case "linux":
123
146
  if (method === "root-crontab")
124
147
  await execSudo(`sh -c ${quote(cronEdit())}`);
125
148
  else
126
- (0, child_process_1.execFileSync)("sh", ["-c", cronEdit()], { stdio: "inherit" });
149
+ (0, child_process_1.execFileSync)("sh", ["-c", cronEdit()], { stdio: "inherit", windowsHide: true });
127
150
  break;
128
151
  case "mac":
129
152
  if (fs_1.default.existsSync(plistPath())) {
130
153
  try {
131
- (0, child_process_1.execFileSync)("launchctl", ["unload", "-w", plistPath()], { stdio: "ignore" });
154
+ (0, child_process_1.execFileSync)("launchctl", ["unload", "-w", plistPath()], { stdio: "ignore", windowsHide: true });
132
155
  }
133
156
  catch { }
134
157
  fs_1.default.rmSync(plistPath(), { force: true });
@@ -163,12 +186,11 @@ class Startup {
163
186
  case "linux":
164
187
  if (marker?.method === "root-crontab")
165
188
  return true;
166
- return (0, child_process_1.execFileSync)("sh", ["-c", "crontab -l 2>/dev/null || true"], { encoding: "utf8" }).includes(CRON_MARK);
189
+ return (0, child_process_1.execFileSync)("sh", ["-c", "crontab -l 2>/dev/null || true"], { encoding: "utf8", windowsHide: true }).includes(CRON_MARK);
167
190
  case "mac":
168
191
  return fs_1.default.existsSync(plistPath());
169
192
  case "windows":
170
- (0, child_process_1.execFileSync)("schtasks", ["/Query", "/TN", SERVICE_NAME], { stdio: "ignore" });
171
- return true;
193
+ return fs_1.default.existsSync(windowsLauncher());
172
194
  default:
173
195
  return false;
174
196
  }
@@ -32,7 +32,7 @@ function execSudo(cmd) {
32
32
  }
33
33
  function hasBinary(bin) {
34
34
  try {
35
- (0, child_process_1.execSync)(`command -v ${bin}`, { stdio: "ignore" });
35
+ (0, child_process_1.execSync)(`command -v ${bin}`, { stdio: "ignore", windowsHide: true });
36
36
  return true;
37
37
  }
38
38
  catch {
@@ -75,6 +75,7 @@ function trustNssDbs(certPath) {
75
75
  try {
76
76
  (0, child_process_1.execSync)(`certutil -d "${db}" -A -t "C,," -n "${CA_NAME}" -i "${certPath}"`, {
77
77
  stdio: "ignore",
78
+ windowsHide: true,
78
79
  });
79
80
  }
80
81
  catch {
@@ -87,7 +88,7 @@ function untrustNssDbs() {
87
88
  return;
88
89
  for (const db of nssProfileDirs()) {
89
90
  try {
90
- (0, child_process_1.execSync)(`certutil -d "${db}" -D -n "${CA_NAME}"`, { stdio: "ignore" });
91
+ (0, child_process_1.execSync)(`certutil -d "${db}" -D -n "${CA_NAME}"`, { stdio: "ignore", windowsHide: true });
91
92
  }
92
93
  catch {
93
94
  // Not present in this profile — nothing to remove.
@@ -133,7 +134,7 @@ async function untrustMac() {
133
134
  }
134
135
  function isTrustedMac(certPath) {
135
136
  try {
136
- (0, child_process_1.execSync)(`security verify-cert -c "${certPath}"`, { stdio: "ignore" });
137
+ (0, child_process_1.execSync)(`security verify-cert -c "${certPath}"`, { stdio: "ignore", windowsHide: true });
137
138
  return true;
138
139
  }
139
140
  catch {
@@ -148,7 +149,7 @@ async function untrustWindows() {
148
149
  }
149
150
  function isTrustedWindows() {
150
151
  try {
151
- (0, child_process_1.execSync)(`certutil -store Root "${CA_NAME}"`, { stdio: "ignore" });
152
+ (0, child_process_1.execSync)(`certutil -store Root "${CA_NAME}"`, { stdio: "ignore", windowsHide: true });
152
153
  return true;
153
154
  }
154
155
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "1.6.0-beta.1",
3
+ "version": "1.6.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",