localdeck 1.2.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3414,15 +3414,16 @@ function stopOrder(commands) {
3414
3414
  }
3415
3415
 
3416
3416
  // ../daemon/src/index.ts
3417
- import fs10 from "node:fs";
3417
+ import fs11 from "node:fs";
3418
3418
  import crypto2 from "node:crypto";
3419
3419
  import path11 from "node:path";
3420
3420
 
3421
3421
  // ../daemon/src/api.ts
3422
3422
  import http5 from "node:http";
3423
- import { timingSafeEqual, randomUUID } from "node:crypto";
3423
+ import { timingSafeEqual, randomBytes, randomUUID } from "node:crypto";
3424
3424
 
3425
3425
  // ../daemon/src/clean-urls.ts
3426
+ import fs3 from "node:fs";
3426
3427
  import http from "node:http";
3427
3428
  import path3 from "node:path";
3428
3429
 
@@ -3501,10 +3502,11 @@ import os2 from "node:os";
3501
3502
  import { promisify as promisify2 } from "node:util";
3502
3503
  var execute2 = promisify2(execFile2);
3503
3504
  var label = "dev.localdeck.port80";
3504
- var destination = `/Library/LaunchDaemons/${label}.plist`;
3505
+ var SYSTEM_FORWARDER_PLIST = `/Library/LaunchDaemons/${label}.plist`;
3506
+ var destination = SYSTEM_FORWARDER_PLIST;
3505
3507
  var marker = "LocalDeck managed loopback relay v1";
3506
3508
  function forwarderPlist(user, port) {
3507
- if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user) || user === "root")
3509
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(user) || user === "root")
3508
3510
  throw new Error("Forwarding must run as a regular macOS user.");
3509
3511
  if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
3510
3512
  throw new Error("Invalid forwarding target port.");
@@ -3579,16 +3581,18 @@ class CleanUrls {
3579
3581
  platform;
3580
3582
  preferenceFile;
3581
3583
  configureSystem;
3584
+ systemRelayInstalled;
3582
3585
  bridge;
3583
3586
  enabling;
3584
3587
  closed = false;
3585
- constructor(targetPort, identity, port = 80, platform = process.platform, preferenceFile = path3.join(HOME_DIR, "forwarder.json"), configureSystem = configureSystemForwarder) {
3588
+ constructor(targetPort, identity, port = 80, platform = process.platform, preferenceFile = path3.join(HOME_DIR, "forwarder.json"), configureSystem = configureSystemForwarder, systemRelayInstalled = () => fs3.existsSync(SYSTEM_FORWARDER_PLIST)) {
3586
3589
  this.targetPort = targetPort;
3587
3590
  this.identity = identity;
3588
3591
  this.port = port;
3589
3592
  this.platform = platform;
3590
3593
  this.preferenceFile = preferenceFile;
3591
3594
  this.configureSystem = configureSystem;
3595
+ this.systemRelayInstalled = systemRelayInstalled;
3592
3596
  }
3593
3597
  async status() {
3594
3598
  const ready = await new Promise((resolve) => {
@@ -3623,6 +3627,12 @@ class CleanUrls {
3623
3627
  if (this.closed)
3624
3628
  return { ready: false, error: "LocalDeck is shutting down." };
3625
3629
  const preference = this.preferenceFile;
3630
+ const saved = readJson(preference, {});
3631
+ if (saved.removed) {
3632
+ if (options.automatic)
3633
+ return { ready: false, error: "Clean URLs are turned off; enable them in the dashboard to use them again." };
3634
+ writeJson(preference, { declined: saved.declined });
3635
+ }
3626
3636
  if (this.platform === "win32" && options.install && this.port === 80 && !(options.automatic && readJson(preference, {}).declined)) {
3627
3637
  if (!this.bridge && (await this.status()).ready)
3628
3638
  return { ready: true };
@@ -3686,13 +3696,14 @@ class CleanUrls {
3686
3696
  if (this.enabling)
3687
3697
  return { ready: false, error: "Wait for the current setup to finish." };
3688
3698
  try {
3689
- if (this.platform === "darwin" || this.platform === "win32" && this.port === 80 && !this.bridge)
3690
- await this.configureSystem(this.targetPort(), true);
3699
+ const hadBridge = Boolean(this.bridge);
3691
3700
  if (this.bridge) {
3692
3701
  await this.bridge.close();
3693
3702
  this.bridge = undefined;
3694
3703
  }
3695
- writeJson(this.preferenceFile, { declined: true });
3704
+ if (this.platform === "darwin" ? this.systemRelayInstalled() : this.platform === "win32" && this.port === 80 && !hadBridge)
3705
+ await this.configureSystem(this.targetPort(), true);
3706
+ writeJson(this.preferenceFile, { declined: true, removed: true });
3696
3707
  return { ready: false, removed: true };
3697
3708
  } catch (error) {
3698
3709
  return { ready: false, error: error instanceof Error ? error.message : "Removal failed." };
@@ -3709,7 +3720,7 @@ class CleanUrls {
3709
3720
  }
3710
3721
 
3711
3722
  // ../daemon/src/api.ts
3712
- import fs6 from "node:fs";
3723
+ import fs7 from "node:fs";
3713
3724
  import path7 from "node:path";
3714
3725
 
3715
3726
  // ../daemon/src/request-body.ts
@@ -3930,6 +3941,7 @@ function proxyRequest(req, res, target, hooks) {
3930
3941
  }
3931
3942
  function proxyUpgrade(req, socket, head, target, hooks) {
3932
3943
  const started = Date.now();
3944
+ socket.on("error", () => socket.destroy());
3933
3945
  if (!target.upstreamReady) {
3934
3946
  socket.end(`HTTP/1.1 503 Service Unavailable\r
3935
3947
  Connection: close\r
@@ -3979,8 +3991,80 @@ Connection: close\r
3979
3991
 
3980
3992
  // ../daemon/src/projects.ts
3981
3993
  import { createHash } from "node:crypto";
3982
- import fs3 from "node:fs";
3994
+ import fs4 from "node:fs";
3983
3995
  import path4 from "node:path";
3996
+
3997
+ // ../daemon/src/ports.ts
3998
+ import net3 from "node:net";
3999
+ function canBind(port, host, required) {
4000
+ return new Promise((resolve) => {
4001
+ const srv = net3.createServer();
4002
+ srv.once("error", (error) => resolve(!required && error.code !== "EADDRINUSE" && error.code !== "EACCES" ? undefined : false));
4003
+ srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve(true)));
4004
+ });
4005
+ }
4006
+ async function isPortFree(port, host) {
4007
+ for (const candidate of host ? [host] : ["127.0.0.1", "::1", "0.0.0.0", "::"]) {
4008
+ if (await canBind(port, candidate, candidate === (host ?? "127.0.0.1")) === false)
4009
+ return false;
4010
+ }
4011
+ return true;
4012
+ }
4013
+ function getFreePort(host = "127.0.0.1") {
4014
+ return new Promise((resolve, reject) => {
4015
+ const srv = net3.createServer();
4016
+ srv.once("error", reject);
4017
+ srv.listen(0, host, () => {
4018
+ const { port } = srv.address();
4019
+ srv.close(() => resolve(port));
4020
+ });
4021
+ });
4022
+ }
4023
+ function serviceName(key) {
4024
+ const separator = key.lastIndexOf(":");
4025
+ return separator === -1 ? key : key.slice(separator + 1);
4026
+ }
4027
+ function rememberedPortFromFile(key, file) {
4028
+ const map = readJson(file, {});
4029
+ if (map[key] !== undefined)
4030
+ return map[key];
4031
+ const name = serviceName(key);
4032
+ const hasOtherQualifiedKey = Object.keys(map).some((candidate) => candidate !== key && candidate.includes(":") && candidate.endsWith(`:${name}`));
4033
+ return hasOtherQualifiedKey ? undefined : map[name];
4034
+ }
4035
+ function rememberPort(name, port, file = PORTS_FILE) {
4036
+ const map = readJson(file, {});
4037
+ map[name] = port;
4038
+ writeJson(file, map);
4039
+ }
4040
+ function forgetProjectPorts(projectId, file = PORTS_FILE) {
4041
+ const map = readJson(file, {});
4042
+ const kept = Object.fromEntries(Object.entries(map).filter(([key]) => !key.startsWith(`${projectId}:`)));
4043
+ if (Object.keys(kept).length !== Object.keys(map).length)
4044
+ writeJson(file, kept);
4045
+ }
4046
+ async function pickStablePort(name, taken, start = 4000, end = 4999, file = PORTS_FILE) {
4047
+ const map = readJson(file, {});
4048
+ const remembered = rememberedPortFromFile(name, file);
4049
+ if (remembered) {
4050
+ if (taken.has(remembered) || !await isPortFree(remembered)) {
4051
+ throw new Error(`remembered stable port ${remembered} for "${name}" is already in use; stop the conflicting listener or explicitly choose a different port`);
4052
+ }
4053
+ return remembered;
4054
+ }
4055
+ const bareName = serviceName(name);
4056
+ const usingLegacy = map[name] === undefined && remembered === map[bareName];
4057
+ const reserved = new Set(Object.entries(map).filter(([n]) => n !== name && !(usingLegacy && n === bareName)).map(([, p]) => p));
4058
+ for (let p = start;p <= end; p++) {
4059
+ if (taken.has(p) || reserved.has(p))
4060
+ continue;
4061
+ if (await isPortFree(p))
4062
+ return p;
4063
+ }
4064
+ throw new Error(`no free port between ${start} and ${end}`);
4065
+ }
4066
+
4067
+ // ../daemon/src/projects.ts
3984
4068
  class ProjectError extends Error {
3985
4069
  code;
3986
4070
  constructor(code, message) {
@@ -3991,15 +4075,17 @@ class ProjectError extends Error {
3991
4075
 
3992
4076
  class ProjectRegistry {
3993
4077
  catalogFile;
4078
+ portsFile;
3994
4079
  now;
3995
4080
  projects;
3996
- constructor({ catalogFile = PROJECTS_FILE, now = Date.now } = {}) {
4081
+ constructor({ catalogFile = PROJECTS_FILE, portsFile = PORTS_FILE, now = Date.now } = {}) {
3997
4082
  this.catalogFile = catalogFile;
4083
+ this.portsFile = portsFile;
3998
4084
  this.now = now;
3999
4085
  this.projects = this.readCatalog();
4000
4086
  }
4001
4087
  remember(file, now = this.now()) {
4002
- const realFile = fs3.realpathSync(file);
4088
+ const realFile = fs4.realpathSync(file);
4003
4089
  const config = loadProjectFile(realFile);
4004
4090
  const id = projectId(realFile);
4005
4091
  const name = path4.basename(path4.dirname(realFile));
@@ -4039,13 +4125,13 @@ class ProjectRegistry {
4039
4125
  return { ...project, available: false, error: errorMessage(error), commands: [] };
4040
4126
  }
4041
4127
  }
4042
- resolve(projectId, serviceName) {
4128
+ resolve(projectId, serviceName2) {
4043
4129
  const details = this.details(projectId);
4044
4130
  if (!details.available)
4045
4131
  throw new ProjectError("invalid", details.error);
4046
- const command = details.commands.find((candidate) => candidate.name === serviceName.toLowerCase());
4132
+ const command = details.commands.find((candidate) => candidate.name === serviceName2.toLowerCase());
4047
4133
  if (!command)
4048
- throw new ProjectError("not_found", `Project ${projectId} has no service named ${serviceName}`);
4134
+ throw new ProjectError("not_found", `Project ${projectId} has no service named ${serviceName2}`);
4049
4135
  return command;
4050
4136
  }
4051
4137
  forget(projectId, activeKeys) {
@@ -4055,6 +4141,7 @@ class ProjectRegistry {
4055
4141
  }
4056
4142
  this.projects = this.projects.filter((project) => project.id !== projectId);
4057
4143
  this.persist();
4144
+ forgetProjectPorts(projectId, this.portsFile);
4058
4145
  }
4059
4146
  refresh(projectId) {
4060
4147
  return this.details(projectId);
@@ -4102,34 +4189,34 @@ class ProjectRegistry {
4102
4189
  project.configNickname = nickname || undefined;
4103
4190
  return this.updateNickname(projectId, "");
4104
4191
  }
4105
- updateDependencies(projectId, serviceName, after) {
4192
+ updateDependencies(projectId, serviceName2, after) {
4106
4193
  const project = this.require(projectId);
4107
4194
  try {
4108
- updateProjectDependencies(project.file, serviceName, after);
4195
+ updateProjectDependencies(project.file, serviceName2, after);
4109
4196
  return this.details(projectId);
4110
4197
  } catch (error) {
4111
4198
  throw new ProjectError("invalid", errorMessage(error));
4112
4199
  }
4113
4200
  }
4114
- updateServiceConfig(projectId, serviceName, settings) {
4201
+ updateServiceConfig(projectId, serviceName2, settings) {
4115
4202
  const project = this.require(projectId);
4116
4203
  try {
4117
- updateProjectServiceConfig(project.file, serviceName, settings);
4204
+ updateProjectServiceConfig(project.file, serviceName2, settings);
4118
4205
  return this.details(projectId);
4119
4206
  } catch (error) {
4120
4207
  throw new ProjectError("invalid", errorMessage(error));
4121
4208
  }
4122
4209
  }
4123
4210
  readCatalog() {
4124
- if (!fs3.existsSync(this.catalogFile))
4211
+ if (!fs4.existsSync(this.catalogFile))
4125
4212
  return [];
4126
4213
  try {
4127
- const parsed = JSON.parse(fs3.readFileSync(this.catalogFile, "utf8"));
4214
+ const parsed = JSON.parse(fs4.readFileSync(this.catalogFile, "utf8"));
4128
4215
  if (!isCatalog(parsed))
4129
4216
  throw new Error("invalid project catalog");
4130
4217
  return parsed.projects;
4131
4218
  } catch {
4132
- fs3.renameSync(this.catalogFile, this.nextCorruptFile());
4219
+ fs4.renameSync(this.catalogFile, this.nextCorruptFile());
4133
4220
  return [];
4134
4221
  }
4135
4222
  }
@@ -4142,7 +4229,7 @@ class ProjectRegistry {
4142
4229
  const base = `${this.catalogFile}.corrupt-${this.now()}`;
4143
4230
  let candidate = base;
4144
4231
  let suffix = 2;
4145
- while (fs3.existsSync(candidate))
4232
+ while (fs4.existsSync(candidate))
4146
4233
  candidate = `${base}-${suffix++}`;
4147
4234
  return candidate;
4148
4235
  }
@@ -4164,7 +4251,7 @@ class ProjectRegistry {
4164
4251
  }
4165
4252
  }
4166
4253
  load(project) {
4167
- if (!fs3.existsSync(project.file))
4254
+ if (!fs4.existsSync(project.file))
4168
4255
  throw new Error(`${project.file} does not exist`);
4169
4256
  const loaded = loadProjectFile(project.file);
4170
4257
  if (loaded.nickname)
@@ -4250,72 +4337,59 @@ async function waitForShare(url, nonce, signal, options = {}) {
4250
4337
  // ../daemon/src/share.ts
4251
4338
  import crypto from "node:crypto";
4252
4339
  import { spawn } from "node:child_process";
4253
-
4254
- // ../daemon/src/ports.ts
4255
- import net3 from "node:net";
4256
- function isPortFree(port, host = "127.0.0.1") {
4257
- return new Promise((resolve) => {
4258
- const srv = net3.createServer();
4259
- srv.once("error", () => resolve(false));
4260
- srv.listen(port, host, () => srv.close(() => resolve(true)));
4261
- });
4262
- }
4263
- function getFreePort(host = "127.0.0.1") {
4264
- return new Promise((resolve, reject) => {
4265
- const srv = net3.createServer();
4266
- srv.once("error", reject);
4267
- srv.listen(0, host, () => {
4268
- const { port } = srv.address();
4269
- srv.close(() => resolve(port));
4270
- });
4271
- });
4272
- }
4273
- function serviceName(key) {
4274
- const separator = key.lastIndexOf(":");
4275
- return separator === -1 ? key : key.slice(separator + 1);
4276
- }
4277
- function rememberedPortFromFile(key, file) {
4278
- const map = readJson(file, {});
4279
- if (map[key] !== undefined)
4280
- return map[key];
4281
- const name = serviceName(key);
4282
- const hasOtherQualifiedKey = Object.keys(map).some((candidate) => candidate !== key && candidate.includes(":") && candidate.endsWith(`:${name}`));
4283
- return hasOtherQualifiedKey ? undefined : map[name];
4284
- }
4285
- function rememberPort(name, port, file = PORTS_FILE) {
4286
- const map = readJson(file, {});
4287
- map[name] = port;
4288
- writeJson(file, map);
4289
- }
4290
- async function pickStablePort(name, taken, start = 4000, end = 4999, file = PORTS_FILE) {
4291
- const map = readJson(file, {});
4292
- const remembered = rememberedPortFromFile(name, file);
4293
- if (remembered) {
4294
- if (taken.has(remembered) || !await isPortFree(remembered)) {
4295
- throw new Error(`remembered stable port ${remembered} for "${name}" is already in use; stop the conflicting listener or explicitly choose a different port`);
4296
- }
4297
- return remembered;
4298
- }
4299
- const bareName = serviceName(name);
4300
- const usingLegacy = map[name] === undefined && remembered === map[bareName];
4301
- const reserved = new Set(Object.entries(map).filter(([n]) => n !== name && !(usingLegacy && n === bareName)).map(([, p]) => p));
4302
- for (let p = start;p <= end; p++) {
4303
- if (taken.has(p) || reserved.has(p))
4304
- continue;
4305
- if (await isPortFree(p))
4306
- return p;
4307
- }
4308
- throw new Error(`no free port between ${start} and ${end}`);
4309
- }
4310
-
4311
- // ../daemon/src/share.ts
4312
4340
  class ShareStartError extends Error {
4313
4341
  }
4314
4342
  var COOKIE = "localdeck_share";
4315
4343
  var URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
4316
4344
  var MAX_LOGIN_BYTES = 65536;
4317
4345
  var DEFAULT_CLEANUP_AFTER_MS = 1000;
4318
- function loginPage(name, wrong) {
4346
+ var HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
4347
+ function escapeHtml(value) {
4348
+ return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
4349
+ }
4350
+
4351
+ class LoginThrottle {
4352
+ now;
4353
+ clients = new Map;
4354
+ all = [];
4355
+ windowMs;
4356
+ perClient;
4357
+ perShare;
4358
+ constructor(now = Date.now, limits = {}) {
4359
+ this.now = now;
4360
+ this.windowMs = limits.windowMs ?? 15 * 60000;
4361
+ this.perClient = limits.perClient ?? 10;
4362
+ this.perShare = limits.perShare ?? 100;
4363
+ }
4364
+ retryAfterSeconds(client) {
4365
+ this.prune();
4366
+ const own = this.clients.get(client) ?? [];
4367
+ const blockedUntil = Math.max(own.length >= this.perClient ? own[own.length - this.perClient] + this.windowMs : 0, this.all.length >= this.perShare ? this.all[this.all.length - this.perShare] + this.windowMs : 0);
4368
+ return blockedUntil ? Math.max(1, Math.ceil((blockedUntil - this.now()) / 1000)) : 0;
4369
+ }
4370
+ fail(client) {
4371
+ const at = this.now();
4372
+ this.all.push(at);
4373
+ this.clients.set(client, [...this.clients.get(client) ?? [], at]);
4374
+ }
4375
+ prune() {
4376
+ const cutoff = this.now() - this.windowMs;
4377
+ this.all = this.all.filter((at) => at > cutoff);
4378
+ for (const [client, times] of this.clients) {
4379
+ const recent = times.filter((at) => at > cutoff);
4380
+ if (recent.length)
4381
+ this.clients.set(client, recent);
4382
+ else
4383
+ this.clients.delete(client);
4384
+ }
4385
+ }
4386
+ }
4387
+ function loginClient(req) {
4388
+ const forwarded = req.headers["cf-connecting-ip"];
4389
+ return typeof forwarded === "string" && forwarded || req.socket.remoteAddress || "unknown";
4390
+ }
4391
+ function loginPage(rawName, wrong, retryAfter = 0) {
4392
+ const name = escapeHtml(rawName);
4319
4393
  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">
4320
4394
  <title>${name} — LocalDeck</title>
4321
4395
  <style>body{font:15px/1.5 system-ui;margin:0;display:grid;place-items:center;height:100vh;background:#0f1115;color:#e6e8ef}
@@ -4323,7 +4397,7 @@ form{background:#171a21;padding:28px;border-radius:12px;width:300px}input{width:
4323
4397
  button{width:100%;padding:10px;border:0;border-radius:8px;background:#5b8cff;color:#fff;font-weight:600}.e{color:#ff7b7b;margin:0 0 8px}</style>
4324
4398
  <form method="post" action="/__localdeck/login"><h2 style="margin:0 0 6px;font-weight:600">${name}</h2>
4325
4399
  <p style="margin:0;opacity:.7">Shared with LocalDeck. Enter the password you were given.</p>
4326
- ${wrong ? '<p class="e">Wrong password.</p>' : ""}
4400
+ ${retryAfter ? `<p class="e">Too many attempts. Try again in ${Math.ceil(retryAfter / 60)} min.</p>` : wrong ? '<p class="e">Wrong password.</p>' : ""}
4327
4401
  <input type="password" name="password" autofocus placeholder="Password"><button>Open</button></form>`;
4328
4402
  }
4329
4403
  function parseCookies(header) {
@@ -4338,6 +4412,25 @@ function parseCookies(header) {
4338
4412
  }
4339
4413
  return out;
4340
4414
  }
4415
+ function stripShareCookie(req) {
4416
+ const strip = (value) => value.split(";").map((part) => part.trim()).filter((part) => part && part.split("=", 1)[0] !== COOKIE).join("; ");
4417
+ if (typeof req.headers.cookie === "string") {
4418
+ const cookie = strip(req.headers.cookie);
4419
+ if (cookie)
4420
+ req.headers.cookie = cookie;
4421
+ else
4422
+ delete req.headers.cookie;
4423
+ }
4424
+ const raw = [];
4425
+ for (let i = 0;i + 1 < req.rawHeaders.length; i += 2) {
4426
+ const name = req.rawHeaders[i];
4427
+ const isCookie = name.toLowerCase() === "cookie";
4428
+ const value = isCookie ? strip(req.rawHeaders[i + 1]) : req.rawHeaders[i + 1];
4429
+ if (value || !isCookie)
4430
+ raw.push(name, value);
4431
+ }
4432
+ req.rawHeaders.splice(0, req.rawHeaders.length, ...raw);
4433
+ }
4341
4434
  function passwordsMatch(given, expected) {
4342
4435
  const a = Buffer.from(given);
4343
4436
  const b = Buffer.from(expected);
@@ -4418,7 +4511,8 @@ class ShareManager {
4418
4511
  const startupSignal = AbortSignal.any([signal, deadline, disconnected.signal]);
4419
4512
  const gatePort = await getFreePort();
4420
4513
  signal.throwIfAborted();
4421
- const authed = (req) => !password || parseCookies(req.headers.cookie)[COOKIE] === token;
4514
+ const authed = (req) => !password || passwordsMatch(parseCookies(req.headers.cookie)[COOKIE] ?? "", token);
4515
+ const throttle = new LoginThrottle;
4422
4516
  let publicHost;
4423
4517
  let confirmPublicRequest;
4424
4518
  const publicRequest = new Promise((resolve) => {
@@ -4437,6 +4531,13 @@ class ShareManager {
4437
4531
  return;
4438
4532
  }
4439
4533
  if (password && req.url === "/__localdeck/login" && req.method === "POST") {
4534
+ const client = loginClient(req);
4535
+ const retryAfter = throttle.retryAfterSeconds(client);
4536
+ if (retryAfter) {
4537
+ res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": String(retryAfter) }).end(loginPage(name, false, retryAfter));
4538
+ req.resume();
4539
+ return;
4540
+ }
4440
4541
  let body = "";
4441
4542
  let bytes = 0;
4442
4543
  let tooLarge = false;
@@ -4464,8 +4565,10 @@ class ShareManager {
4464
4565
  this.registry.setShare(key, s.info);
4465
4566
  }
4466
4567
  res.writeHead(303, { "set-cookie": `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Secure`, location: "/" }).end();
4467
- } else
4568
+ } else {
4569
+ throttle.fail(client);
4468
4570
  res.writeHead(401, { "content-type": "text/html; charset=utf-8" }).end(loginPage(name, true));
4571
+ }
4469
4572
  });
4470
4573
  return;
4471
4574
  }
@@ -4473,12 +4576,14 @@ class ShareManager {
4473
4576
  res.writeHead(401, { "content-type": "text/html; charset=utf-8" }).end(loginPage(name, false));
4474
4577
  return;
4475
4578
  }
4579
+ stripShareCookie(req);
4476
4580
  proxyRequest(req, res, target, { responseBodies: this.store.responseBodies, onRequest: (r) => this.store.request(r) });
4477
4581
  });
4478
4582
  gate.on("upgrade", (req, socket, head) => {
4479
4583
  const target = this.registry.target(key, "share");
4480
4584
  if (!target || !authed(req))
4481
4585
  return socket.destroy();
4586
+ stripShareCookie(req);
4482
4587
  proxyUpgrade(req, socket, head, target, { responseBodies: this.store.responseBodies, onRequest: (r) => this.store.request(r) });
4483
4588
  });
4484
4589
  const sockets = new Set;
@@ -4838,10 +4943,13 @@ function normalizeHost(host) {
4838
4943
  const normalized = host.replace(/^\[|\]$/g, "");
4839
4944
  return normalized === "*" || normalized === "0.0.0.0" || normalized === "::" || normalized === "" ? "*" : normalized;
4840
4945
  }
4946
+ function reachable(listener) {
4947
+ return listener.host === "*" || listener.host === "127.0.0.1" || listener.host === "::1";
4948
+ }
4841
4949
  function chooseUpstream(listeners, assignedPort) {
4842
4950
  let lowest;
4843
4951
  for (const listener of listeners) {
4844
- if (IGNORED_PORTS.has(listener.port))
4952
+ if (IGNORED_PORTS.has(listener.port) || !reachable(listener))
4845
4953
  continue;
4846
4954
  if (listener.port === assignedPort)
4847
4955
  return listener;
@@ -4850,6 +4958,16 @@ function chooseUpstream(listeners, assignedPort) {
4850
4958
  }
4851
4959
  return lowest;
4852
4960
  }
4961
+ function unreachableListenerMessage(listeners) {
4962
+ const candidates = listeners.filter((listener2) => !IGNORED_PORTS.has(listener2.port));
4963
+ if (candidates.some(reachable))
4964
+ return;
4965
+ const listener = candidates[0];
4966
+ if (!listener)
4967
+ return;
4968
+ const address = listener.host.includes(":") ? `[${listener.host}]:${listener.port}` : `${listener.host}:${listener.port}`;
4969
+ return `the process is listening only on ${address}, which LocalDeck cannot reach. Bind it to localhost (127.0.0.1) or all interfaces (0.0.0.0) instead.`;
4970
+ }
4853
4971
  function connectHost(listener) {
4854
4972
  return listener.host === "*" ? "127.0.0.1" : listener.host;
4855
4973
  }
@@ -4940,7 +5058,7 @@ function stripAnsi(value) {
4940
5058
  }
4941
5059
 
4942
5060
  // ../daemon/src/diagnostics.ts
4943
- import fs4 from "node:fs";
5061
+ import fs5 from "node:fs";
4944
5062
  import os3 from "node:os";
4945
5063
  import path5 from "node:path";
4946
5064
  function redactDiagnostic(text, secrets = []) {
@@ -4964,10 +5082,10 @@ function environmentValues(directory) {
4964
5082
  for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) {
4965
5083
  const file = path5.join(directory, name);
4966
5084
  try {
4967
- const stat = fs4.lstatSync(file);
5085
+ const stat = fs5.lstatSync(file);
4968
5086
  if (!stat.isFile() || stat.size > 65536)
4969
5087
  continue;
4970
- const text = fs4.readFileSync(file, "utf8");
5088
+ const text = fs5.readFileSync(file, "utf8");
4971
5089
  for (const line of text.split(/\r?\n/)) {
4972
5090
  const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)\s*=\s*(.*)$/);
4973
5091
  if (match) {
@@ -5056,7 +5174,7 @@ function replayRequest(service, record, options = {}) {
5056
5174
  }
5057
5175
 
5058
5176
  // ../daemon/src/project-secrets.ts
5059
- import fs5 from "node:fs";
5177
+ import fs6 from "node:fs";
5060
5178
  import path6 from "node:path";
5061
5179
  var ID = /^[a-f0-9]{16}$/;
5062
5180
  var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -5079,7 +5197,7 @@ function createProjectSecretStore(home = HOME_DIR) {
5079
5197
  const file = secretPath(projectId2);
5080
5198
  let text;
5081
5199
  try {
5082
- text = fs5.readFileSync(file, "utf8");
5200
+ text = fs6.readFileSync(file, "utf8");
5083
5201
  } catch (error) {
5084
5202
  if (error.code === "ENOENT")
5085
5203
  return Object.create(null);
@@ -5106,9 +5224,9 @@ function createProjectSecretStore(home = HOME_DIR) {
5106
5224
  }
5107
5225
  function write(projectId2, values) {
5108
5226
  const file = secretPath(projectId2);
5109
- fs5.mkdirSync(directory, { recursive: true, mode: 448 });
5227
+ fs6.mkdirSync(directory, { recursive: true, mode: 448 });
5110
5228
  if (process.platform !== "win32")
5111
- fs5.chmodSync(directory, 448);
5229
+ fs6.chmodSync(directory, 448);
5112
5230
  writePrivateJson(file, values);
5113
5231
  }
5114
5232
  function listProjectSecrets(projectId2) {
@@ -5384,6 +5502,7 @@ function createApiServer(deps) {
5384
5502
  });
5385
5503
  attach.on("connection", (ws) => handleAttach(ws, deps));
5386
5504
  server.on("upgrade", (req, socket, head) => {
5505
+ socket.on("error", () => socket.destroy());
5387
5506
  const svc = serviceFromHost(req.headers.host);
5388
5507
  if (svc) {
5389
5508
  const resolved = registry.resolveHostname(svc);
@@ -5699,11 +5818,16 @@ async function handleApi(req, res, url, parts, deps) {
5699
5818
  requireMutationAllowed(deps);
5700
5819
  if (!body || typeof body !== "object" || Array.isArray(body))
5701
5820
  throw new HttpError(400, "request body must be a JSON object");
5702
- const password = body.password;
5821
+ const { password, public: isPublic } = body;
5703
5822
  if (password !== undefined && typeof password !== "string")
5704
5823
  throw new HttpError(400, "password must be a string");
5824
+ if (isPublic !== undefined && typeof isPublic !== "boolean")
5825
+ throw new HttpError(400, "public must be a boolean");
5826
+ if (isPublic && password)
5827
+ throw new HttpError(400, "a public link cannot have a password");
5828
+ const sharePassword = isPublic ? undefined : password || randomBytes(12).toString("base64url");
5705
5829
  try {
5706
- return json(res, 200, await shares.start(key, password || undefined, { exclusive: createShare }));
5830
+ return json(res, 200, await shares.start(key, sharePassword, { exclusive: createShare }));
5707
5831
  } catch (error) {
5708
5832
  if (error instanceof ShareStartError)
5709
5833
  return json(res, 503, { error: error.message });
@@ -5730,14 +5854,17 @@ function serveStatic(pathname, res, dir) {
5730
5854
  let file = path7.join(dir, path7.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
5731
5855
  if (!file.startsWith(dir))
5732
5856
  file = path7.join(dir, "index.html");
5733
- if (!fs6.existsSync(file) || fs6.statSync(file).isDirectory())
5857
+ if (!fs7.existsSync(file) || fs7.statSync(file).isDirectory())
5734
5858
  file = path7.join(dir, "index.html");
5735
5859
  const hashedAsset = path7.dirname(file) === path7.join(dir, "assets") && /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/i.test(path7.basename(file));
5736
5860
  res.writeHead(200, {
5737
5861
  "content-type": MIME[path7.extname(file)] ?? "application/octet-stream",
5738
- "cache-control": hashedAsset ? "public, max-age=31536000, immutable" : "no-cache"
5862
+ "cache-control": hashedAsset ? "public, max-age=31536000, immutable" : "no-cache",
5863
+ "x-frame-options": "DENY",
5864
+ "content-security-policy": "frame-ancestors 'none'",
5865
+ "x-content-type-options": "nosniff"
5739
5866
  });
5740
- fs6.createReadStream(file).pipe(res);
5867
+ fs7.createReadStream(file).pipe(res);
5741
5868
  }
5742
5869
  function handleAttach(ws, deps) {
5743
5870
  const { registry, store } = deps;
@@ -5968,7 +6095,7 @@ function buildRegistration(message, deps) {
5968
6095
  }
5969
6096
  function requireDirectory(cwd) {
5970
6097
  try {
5971
- if (!fs6.statSync(cwd).isDirectory())
6098
+ if (!fs7.statSync(cwd).isDirectory())
5972
6099
  throw new Error("not a directory");
5973
6100
  } catch {
5974
6101
  throw new Error(`working directory does not exist or is not a directory: ${cwd}`);
@@ -6243,8 +6370,12 @@ class Registry {
6243
6370
  });
6244
6371
  await new Promise((resolve, reject) => {
6245
6372
  entry.server.once("error", reject);
6246
- entry.server.listen(stablePort, "127.0.0.1", resolve);
6373
+ entry.server.listen(stablePort, "127.0.0.1", () => {
6374
+ entry.server.off("error", reject);
6375
+ resolve();
6376
+ });
6247
6377
  });
6378
+ entry.server.on("error", (error) => this.store.log({ ts: Date.now(), service: key, stream: "system", line: `LocalDeck: proxy listener error: ${error.message}` }));
6248
6379
  rememberPort(key, stablePort);
6249
6380
  this.store.log({ ts: Date.now(), service: key, stream: "system", line: `LocalDeck: ${name} on http://localhost:${stablePort} → waiting for the process to open a port (assigned PORT=${opts.upstreamPort})` });
6250
6381
  this.startHealthPolling(entry);
@@ -6433,7 +6564,7 @@ class Registry {
6433
6564
  }
6434
6565
 
6435
6566
  // ../daemon/src/store.ts
6436
- import fs7 from "node:fs";
6567
+ import fs8 from "node:fs";
6437
6568
 
6438
6569
  // ../daemon/src/history.ts
6439
6570
  function isAfterHistoryBoundary(id, boundary) {
@@ -6524,17 +6655,33 @@ var ERROR_RE = /\b(error|exception|unhandled|fatal|EADDRINUSE|ECONNREFUSED)\b/i;
6524
6655
 
6525
6656
  class Ring {
6526
6657
  cap;
6658
+ maxBytes;
6527
6659
  buf;
6660
+ sizes;
6528
6661
  size = 0;
6529
6662
  next = 0;
6530
- constructor(cap) {
6663
+ bytes = 0;
6664
+ constructor(cap, maxBytes = Infinity) {
6531
6665
  this.cap = cap;
6666
+ this.maxBytes = maxBytes;
6532
6667
  this.buf = new Array(cap);
6668
+ this.sizes = new Array(cap).fill(0);
6533
6669
  }
6534
- push(v) {
6670
+ push(v, bytes = 0) {
6671
+ if (this.size === this.cap)
6672
+ this.bytes -= this.sizes[this.next];
6535
6673
  this.buf[this.next] = v;
6674
+ this.sizes[this.next] = bytes;
6675
+ this.bytes += bytes;
6536
6676
  this.next = (this.next + 1) % this.cap;
6537
6677
  this.size = Math.min(this.size + 1, this.cap);
6678
+ while (this.bytes > this.maxBytes && this.size > 1) {
6679
+ const oldest = (this.next - this.size + this.cap) % this.cap;
6680
+ this.bytes -= this.sizes[oldest];
6681
+ this.buf[oldest] = undefined;
6682
+ this.sizes[oldest] = 0;
6683
+ this.size--;
6684
+ }
6538
6685
  }
6539
6686
  last(n) {
6540
6687
  const count = Math.min(Math.max(0, n), this.size);
@@ -6547,8 +6694,10 @@ class Ring {
6547
6694
  }
6548
6695
  clear() {
6549
6696
  this.buf.fill(undefined);
6697
+ this.sizes.fill(0);
6550
6698
  this.size = 0;
6551
6699
  this.next = 0;
6700
+ this.bytes = 0;
6552
6701
  }
6553
6702
  }
6554
6703
  function emptyStats() {
@@ -6566,17 +6715,19 @@ class Store extends EventEmitter {
6566
6715
  closeAfterMs;
6567
6716
  streamFactory;
6568
6717
  maxFileBytes;
6718
+ maxHistoryBytes;
6569
6719
  constructor(options = {}) {
6570
6720
  super();
6571
6721
  this.logsDir = options.logsDir ?? LOGS_DIR;
6572
6722
  this.daemonStartedAt = options.daemonStartedAt ?? Date.now();
6573
6723
  this.closeAfterMs = options.closeAfterMs ?? DEFAULT_CLOSE_AFTER_MS;
6574
- this.streamFactory = options.streamFactory ?? ((file, streamOptions) => fs7.createWriteStream(file, streamOptions));
6724
+ this.streamFactory = options.streamFactory ?? ((file, streamOptions) => fs8.createWriteStream(file, streamOptions));
6575
6725
  this.maxFileBytes = Math.max(1, options.maxFileBytes ?? 5 * 1024 * 1024);
6726
+ this.maxHistoryBytes = Math.max(1, options.maxHistoryBytes ?? 8 * 1024 * 1024);
6576
6727
  if (options.logsDir === undefined)
6577
6728
  ensureDirs();
6578
6729
  else
6579
- fs7.mkdirSync(this.logsDir, { recursive: true });
6730
+ fs8.mkdirSync(this.logsDir, { recursive: true });
6580
6731
  }
6581
6732
  nextId() {
6582
6733
  return `${this.daemonStartedAt.toString(36)}-${(++this.sequence).toString(36)}`;
@@ -6596,12 +6747,12 @@ class Store extends EventEmitter {
6596
6747
  }
6597
6748
  loadHistory(file, type, service, ring, legacyService, budget = this.maxFileBytes) {
6598
6749
  const marker2 = `${this.historyFile(service, type)}.cleared`;
6599
- const boundary = fs7.existsSync(marker2) ? fs7.readFileSync(marker2, "utf8") : undefined;
6750
+ const boundary = fs8.existsSync(marker2) ? fs8.readFileSync(marker2, "utf8") : undefined;
6600
6751
  let fd;
6601
6752
  let size;
6602
6753
  try {
6603
- fd = fs7.openSync(file, "r");
6604
- size = fs7.fstatSync(fd).size;
6754
+ fd = fs8.openSync(file, "r");
6755
+ size = fs8.fstatSync(fd).size;
6605
6756
  } catch (error) {
6606
6757
  if (error.code === "ENOENT")
6607
6758
  return 0;
@@ -6612,9 +6763,9 @@ class Store extends EventEmitter {
6612
6763
  const bytes = Buffer.alloc(count);
6613
6764
  try {
6614
6765
  if (count)
6615
- fs7.readSync(fd, bytes, 0, count, start);
6766
+ fs8.readSync(fd, bytes, 0, count, start);
6616
6767
  } finally {
6617
- fs7.closeSync(fd);
6768
+ fs8.closeSync(fd);
6618
6769
  }
6619
6770
  let lines = bytes.toString("binary").split(`
6620
6771
  `);
@@ -6635,7 +6786,7 @@ class Store extends EventEmitter {
6635
6786
  if (input.service === service || input.service === legacyService) {
6636
6787
  const id = typeof input.id === "string" ? input.id : this.compatibilityId(type, service, offset, serialized);
6637
6788
  if (!boundary || isAfterHistoryBoundary(id, boundary))
6638
- ring.push({ ...input, service, id });
6789
+ ring.push({ ...input, service, id }, serialized.length);
6639
6790
  }
6640
6791
  }
6641
6792
  } catch {}
@@ -6652,8 +6803,8 @@ class Store extends EventEmitter {
6652
6803
  const legacyLogFile = this.legacyHistoryFile(service, "log");
6653
6804
  const legacyReqFile = this.legacyHistoryFile(service, "request");
6654
6805
  b = {
6655
- logs: new Ring(LOG_RING),
6656
- requests: new Ring(REQ_RING),
6806
+ logs: new Ring(LOG_RING, this.maxHistoryBytes),
6807
+ requests: new Ring(REQ_RING, this.maxHistoryBytes),
6657
6808
  stats: emptyStats(),
6658
6809
  latencyTotal: 0,
6659
6810
  logFile: undefined,
@@ -6677,14 +6828,14 @@ class Store extends EventEmitter {
6677
6828
  const rotatedReq = `${reqFile}.1`;
6678
6829
  const logCurrentSize = (() => {
6679
6830
  try {
6680
- return fs7.statSync(logFile).size;
6831
+ return fs8.statSync(logFile).size;
6681
6832
  } catch {
6682
6833
  return 0;
6683
6834
  }
6684
6835
  })();
6685
6836
  const reqCurrentSize = (() => {
6686
6837
  try {
6687
- return fs7.statSync(reqFile).size;
6838
+ return fs8.statSync(reqFile).size;
6688
6839
  } catch {
6689
6840
  return 0;
6690
6841
  }
@@ -6714,11 +6865,12 @@ class Store extends EventEmitter {
6714
6865
  log(log) {
6715
6866
  const record = { ...log, id: this.nextId() };
6716
6867
  const b = this.bucket(record.service);
6717
- b.logs.push(record);
6868
+ const serialized = JSON.stringify(record);
6869
+ b.logs.push(record, serialized.length);
6718
6870
  b.stats.logLines++;
6719
6871
  if (record.stream === "stderr" || ERROR_RE.test(record.line))
6720
6872
  b.stats.errorLines++;
6721
- this.persist(record.service, b, b.logFile, JSON.stringify(record) + `
6873
+ this.persist(record.service, b, b.logFile, serialized + `
6722
6874
  `);
6723
6875
  this.emitEvent({ type: "log", log: record });
6724
6876
  return record;
@@ -6726,7 +6878,8 @@ class Store extends EventEmitter {
6726
6878
  request(request) {
6727
6879
  const record = { ...request, id: this.nextId() };
6728
6880
  const b = this.bucket(record.service);
6729
- b.requests.push(record);
6881
+ const serialized = JSON.stringify(record);
6882
+ b.requests.push(record, serialized.length);
6730
6883
  const s = b.stats;
6731
6884
  s.requests++;
6732
6885
  s.lastRequestAt = record.ts;
@@ -6740,7 +6893,7 @@ class Store extends EventEmitter {
6740
6893
  s.ok++;
6741
6894
  b.latencyTotal += record.durationMs;
6742
6895
  s.avgLatencyMs = Math.round(b.latencyTotal / s.requests);
6743
- this.persist(record.service, b, b.reqFile, JSON.stringify(record) + `
6896
+ this.persist(record.service, b, b.reqFile, serialized + `
6744
6897
  `);
6745
6898
  this.emitEvent({ type: "request", request: record });
6746
6899
  return record;
@@ -6753,7 +6906,7 @@ class Store extends EventEmitter {
6753
6906
  }
6754
6907
  historyBoundary(service, kind) {
6755
6908
  const marker2 = `${this.historyFile(service, kind === "logs" ? "log" : "request")}.cleared`;
6756
- return fs7.existsSync(marker2) ? fs7.readFileSync(marker2, "utf8") : undefined;
6909
+ return fs8.existsSync(marker2) ? fs8.readFileSync(marker2, "utf8") : undefined;
6757
6910
  }
6758
6911
  async clearHistory(service, kind) {
6759
6912
  if (this.closing)
@@ -6762,15 +6915,15 @@ class Store extends EventEmitter {
6762
6915
  const state = kind === "logs" ? bucket.logFile : bucket.reqFile;
6763
6916
  const beforeId = this.nextId();
6764
6917
  const marker2 = `${state.file}.cleared`;
6765
- fs7.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
6766
- fs7.renameSync(`${marker2}.tmp`, marker2);
6918
+ fs8.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
6919
+ fs8.renameSync(`${marker2}.tmp`, marker2);
6767
6920
  bucket[kind].clear();
6768
6921
  if (kind === "requests")
6769
6922
  this.responseBodies.clear(service);
6770
6923
  const operation = state.queue.then(async () => {
6771
6924
  await this.closeStream(state.stream);
6772
- fs7.writeFileSync(state.file, "", { mode: 384 });
6773
- fs7.rmSync(`${state.file}.1`, { force: true });
6925
+ fs8.writeFileSync(state.file, "", { mode: 384 });
6926
+ fs8.rmSync(`${state.file}.1`, { force: true });
6774
6927
  state.bytes = 0;
6775
6928
  state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
6776
6929
  state.disabled = false;
@@ -6857,12 +7010,12 @@ class Store extends EventEmitter {
6857
7010
  } });
6858
7011
  }
6859
7012
  try {
6860
- if (fs7.existsSync(file))
6861
- fs7.chmodSync(file, 384);
7013
+ if (fs8.existsSync(file))
7014
+ fs8.chmodSync(file, 384);
6862
7015
  } catch {}
6863
7016
  const state = { stream, file, bytes: (() => {
6864
7017
  try {
6865
- return fs7.statSync(file).size;
7018
+ return fs8.statSync(file).size;
6866
7019
  } catch {
6867
7020
  return 0;
6868
7021
  }
@@ -6936,8 +7089,8 @@ class Store extends EventEmitter {
6936
7089
  }
6937
7090
  const rotated = `${state.file}.1`;
6938
7091
  try {
6939
- fs7.rmSync(rotated, { force: true });
6940
- fs7.renameSync(state.file, rotated);
7092
+ fs8.rmSync(rotated, { force: true });
7093
+ fs8.renameSync(state.file, rotated);
6941
7094
  } catch {
6942
7095
  state.disabled = true;
6943
7096
  this.reportPersistenceFailure(service, bucket, "history rotation");
@@ -6945,7 +7098,7 @@ class Store extends EventEmitter {
6945
7098
  }
6946
7099
  state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
6947
7100
  try {
6948
- fs7.chmodSync(state.file, 384);
7101
+ fs8.chmodSync(state.file, 384);
6949
7102
  } catch {}
6950
7103
  state.bytes = 0;
6951
7104
  state.stream.on("error", () => {
@@ -6992,7 +7145,7 @@ function settlesWithin2(promise, timeoutMs) {
6992
7145
  // ../daemon/src/supervisor.ts
6993
7146
  import { spawn as spawn3 } from "node:child_process";
6994
7147
  import path10 from "node:path";
6995
- import fs9 from "node:fs";
7148
+ import fs10 from "node:fs";
6996
7149
 
6997
7150
  // ../daemon/src/startup-guidance.ts
6998
7151
  function startupGuidance(message) {
@@ -7017,7 +7170,7 @@ function startupGuidance(message) {
7017
7170
  }
7018
7171
 
7019
7172
  // ../daemon/src/port-environment.ts
7020
- import fs8 from "node:fs";
7173
+ import fs9 from "node:fs";
7021
7174
  import path9 from "node:path";
7022
7175
  function inferPortEnvironment(command, cwd, port) {
7023
7176
  const visited = new Set;
@@ -7121,7 +7274,7 @@ function shellWords(command) {
7121
7274
  }
7122
7275
  function manifest(directory) {
7123
7276
  try {
7124
- const value = JSON.parse(fs8.readFileSync(path9.join(directory, "package.json"), "utf8"));
7277
+ const value = JSON.parse(fs9.readFileSync(path9.join(directory, "package.json"), "utf8"));
7125
7278
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
7126
7279
  } catch {
7127
7280
  return;
@@ -7145,7 +7298,7 @@ function workspaceDirectory(root, selector) {
7145
7298
  const next = [];
7146
7299
  for (const candidate of candidates) {
7147
7300
  try {
7148
- for (const entry of fs8.readdirSync(candidate, { withFileTypes: true })) {
7301
+ for (const entry of fs9.readdirSync(candidate, { withFileTypes: true })) {
7149
7302
  if (entry.isDirectory() && !["node_modules", ".git"].includes(entry.name) && matcher.test(entry.name))
7150
7303
  next.push(path9.join(candidate, entry.name));
7151
7304
  if (next.length > 100)
@@ -7639,7 +7792,7 @@ class ProcessSupervisor {
7639
7792
  throw new ProjectError("invalid", project.error);
7640
7793
  const cwd = command.cwd ? path10.resolve(project.directory, command.cwd) : project.directory;
7641
7794
  try {
7642
- if (!fs9.statSync(cwd).isDirectory())
7795
+ if (!fs10.statSync(cwd).isDirectory())
7643
7796
  throw new Error("not a directory");
7644
7797
  } catch {
7645
7798
  throw new ProjectError("invalid", `configured working directory does not exist or is not a directory: ${cwd}`);
@@ -8002,6 +8155,7 @@ class ProcessSupervisor {
8002
8155
  }
8003
8156
  scheduleDetection(managed, assignedPort) {
8004
8157
  let reported;
8158
+ let unreachable;
8005
8159
  let idle = 0;
8006
8160
  const tick = async () => {
8007
8161
  if (this.active.get(managed.key) !== managed || managed.child.exitCode !== null || managed.child.signalCode)
@@ -8009,6 +8163,14 @@ class ProcessSupervisor {
8009
8163
  try {
8010
8164
  const listeners = await listeningPorts(await processTree(managed.child.pid));
8011
8165
  const listener = chooseUpstream(listeners, assignedPort);
8166
+ const problem = listener ? undefined : unreachableListenerMessage(listeners);
8167
+ if (problem && problem !== unreachable) {
8168
+ this.store.log({ ts: Date.now(), service: managed.key, stream: "system", line: `LocalDeck: ${problem}` });
8169
+ this.setLastError(managed.key, problem);
8170
+ } else if (listener && unreachable && this.registry.get(managed.key)?.lastError === unreachable) {
8171
+ this.setLastError(managed.key, undefined);
8172
+ }
8173
+ unreachable = problem;
8012
8174
  if (listener) {
8013
8175
  const host = connectHost(listener);
8014
8176
  const target = `${host}:${listener.port}`;
@@ -8148,7 +8310,7 @@ async function waitForProcessGroupExit(groupId, timeoutMs) {
8148
8310
  return true;
8149
8311
  }
8150
8312
  // ../daemon/package.json
8151
- var version = "1.2.3";
8313
+ var version = "1.4.0";
8152
8314
 
8153
8315
  // ../daemon/src/index.ts
8154
8316
  var VERSION = version;
@@ -8226,16 +8388,16 @@ async function startDaemon(opts = {}) {
8226
8388
  if (stateCleanup === "failed-write") {
8227
8389
  if (!stateWriteAttempted)
8228
8390
  return;
8229
- fs10.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
8391
+ fs11.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
8230
8392
  if (priorState)
8231
8393
  restorePrivateState(stateFile, priorState.contents, priorState.mode);
8232
8394
  else
8233
- fs10.rmSync(stateFile, { force: true });
8395
+ fs11.rmSync(stateFile, { force: true });
8234
8396
  return;
8235
8397
  }
8236
8398
  const state = readJson(stateFile, null);
8237
8399
  if (state?.pid === process.pid)
8238
- fs10.unlinkSync(stateFile);
8400
+ fs11.unlinkSync(stateFile);
8239
8401
  }));
8240
8402
  return errors;
8241
8403
  })();
@@ -8255,11 +8417,15 @@ async function startDaemon(opts = {}) {
8255
8417
  try {
8256
8418
  await new Promise((resolve, reject) => {
8257
8419
  server.once("error", reject);
8258
- server.listen(apiPort, "127.0.0.1", resolve);
8420
+ server.listen(apiPort, "127.0.0.1", () => {
8421
+ server.off("error", reject);
8422
+ resolve();
8423
+ });
8259
8424
  });
8260
- if (fs10.existsSync(stateFile)) {
8261
- const stat = fs10.statSync(stateFile);
8262
- priorState = { contents: fs10.readFileSync(stateFile), mode: stat.mode & 511 };
8425
+ server.on("error", (error) => console.error(`LocalDeck: API server error: ${error.message}`));
8426
+ if (fs11.existsSync(stateFile)) {
8427
+ const stat = fs11.statSync(stateFile);
8428
+ priorState = { contents: fs11.readFileSync(stateFile), mode: stat.mode & 511 };
8263
8429
  }
8264
8430
  stateWriteAttempted = true;
8265
8431
  dependencies.writePrivateState(stateFile, {
@@ -8280,9 +8446,9 @@ async function startDaemon(opts = {}) {
8280
8446
  }
8281
8447
  function restorePrivateState(file, contents, mode) {
8282
8448
  const temporary = `${file}.${process.pid}.restore.tmp`;
8283
- fs10.writeFileSync(temporary, contents, { mode });
8284
- fs10.chmodSync(temporary, mode);
8285
- fs10.renameSync(temporary, file);
8449
+ fs11.writeFileSync(temporary, contents, { mode });
8450
+ fs11.chmodSync(temporary, mode);
8451
+ fs11.renameSync(temporary, file);
8286
8452
  }
8287
8453
  function toError3(error) {
8288
8454
  return error instanceof Error ? error : new Error(String(error));
@@ -8303,7 +8469,7 @@ function createOperationTracker() {
8303
8469
  async function findDaemon(stateFile = STATE_FILE) {
8304
8470
  let state;
8305
8471
  try {
8306
- state = JSON.parse(fs10.readFileSync(stateFile, "utf8"));
8472
+ state = JSON.parse(fs11.readFileSync(stateFile, "utf8"));
8307
8473
  } catch (error) {
8308
8474
  const code = error.code;
8309
8475
  if (code === "EACCES" || code === "EPERM") {
@@ -8328,7 +8494,7 @@ async function findDaemon(stateFile = STATE_FILE) {
8328
8494
 
8329
8495
  // src/client.ts
8330
8496
  import { spawn as spawn4, spawnSync } from "node:child_process";
8331
- import fs11 from "node:fs";
8497
+ import fs12 from "node:fs";
8332
8498
  import path12 from "node:path";
8333
8499
  import { fileURLToPath } from "node:url";
8334
8500
  function dashboardDir() {
@@ -8337,7 +8503,7 @@ function dashboardDir() {
8337
8503
  path12.resolve(here, "../dashboard"),
8338
8504
  path12.resolve(here, "../../dashboard/dist")
8339
8505
  ];
8340
- return candidates.find((d) => fs11.existsSync(path12.join(d, "index.html")));
8506
+ return candidates.find((d) => fs12.existsSync(path12.join(d, "index.html")));
8341
8507
  }
8342
8508
  function daemonCommand() {
8343
8509
  const entry = process.argv[1];
@@ -8345,7 +8511,7 @@ function daemonCommand() {
8345
8511
  if (runningUnderBun) {
8346
8512
  const dist = path12.resolve(path12.dirname(fileURLToPath(import.meta.url)), "../dist/index.js");
8347
8513
  const hasNode = spawnSync("node", ["--version"], { stdio: "ignore" }).status === 0;
8348
- if (fs11.existsSync(dist) && hasNode)
8514
+ if (fs12.existsSync(dist) && hasNode)
8349
8515
  return ["node", [dist, "daemon", "run"]];
8350
8516
  console.warn("LocalDeck: starting daemon under Bun — WebSocket proxying (HMR) will not work. Run `bun run build` and make sure `node` is on PATH.");
8351
8517
  }
@@ -8358,8 +8524,8 @@ async function ensureDaemon() {
8358
8524
  const running = await findDaemon();
8359
8525
  if (running)
8360
8526
  return running;
8361
- fs11.mkdirSync(path12.dirname(DAEMON_LOG), { recursive: true });
8362
- const out = fs11.openSync(DAEMON_LOG, "a");
8527
+ fs12.mkdirSync(path12.dirname(DAEMON_LOG), { recursive: true });
8528
+ const out = fs12.openSync(DAEMON_LOG, "a");
8363
8529
  const [cmd, args] = daemonCommand();
8364
8530
  const child = spawn4(cmd, args, { detached: true, stdio: ["ignore", out, out], env: process.env });
8365
8531
  child.unref();
@@ -8421,11 +8587,12 @@ class Api {
8421
8587
  }
8422
8588
  service = async (reference) => this.call("GET", `/api/services/${await this.servicePath(reference)}`);
8423
8589
  logs = async (reference, limit) => this.call("GET", `/api/services/${await this.servicePath(reference)}/logs?limit=${limit}`);
8590
+ response = (key, requestId) => this.call("GET", `/api/services/${encodeURIComponent(key)}/response?requestId=${encodeURIComponent(requestId)}`);
8424
8591
  requests = async (reference, limit) => this.call("GET", `/api/services/${await this.servicePath(reference)}/requests?limit=${limit}`);
8425
8592
  stop = async (reference) => this.call("POST", `/api/services/${await this.servicePath(reference)}/stop`);
8426
- share = async (reference, password, exclusive = false) => this.call("POST", `/api/services/${await this.servicePath(reference)}/share${exclusive ? "/new" : ""}`, { password });
8593
+ share = async (reference, password, exclusive = false, isPublic = false) => this.call("POST", `/api/services/${await this.servicePath(reference)}/share${exclusive ? "/new" : ""}`, isPublic ? { public: true } : { password });
8427
8594
  unshare = async (reference) => this.call("DELETE", `/api/services/${await this.servicePath(reference)}/share`);
8428
8595
  shutdown = () => this.call("POST", "/api/shutdown");
8429
8596
  }
8430
8597
 
8431
- export { __toESM, __commonJS, __export, __require, wrapper_default, startForwarder, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, findProjectFile, loadProject, loadProjectFile, shellCommand, isPortFree, getFreePort, serviceKey, processTree, listeningPorts, chooseUpstream, connectHost, streamLines, killProcessTree, stripAnsi, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startupGuidance, inferPortEnvironment, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };
8598
+ export { __toESM, __commonJS, __export, __require, wrapper_default, startForwarder, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, isPortFree, getFreePort, findProjectFile, loadProject, loadProjectFile, shellCommand, serviceKey, processTree, listeningPorts, chooseUpstream, unreachableListenerMessage, connectHost, streamLines, killProcessTree, stripAnsi, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startupGuidance, inferPortEnvironment, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };