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.
- package/CHANGELOG.md +42 -0
- package/dashboard/assets/index-2ZVKTtzC.css +1 -0
- package/dashboard/assets/index-D9Moyhwp.js +21 -0
- package/dashboard/index.html +2 -2
- package/dist/{index-6r7h3h6p.js → index-316fp4p2.js} +323 -156
- package/dist/index.js +16 -8
- package/dist/{mcp-67ae2y6m.js → mcp-h8gf5pgj.js} +12 -4
- package/dist/{update-7rehj1x3.js → update-w6vc3sdy.js} +1 -1
- package/docs/dashboard.md +1 -1
- package/docs/mcp.md +64 -2
- package/package.json +1 -1
- package/dashboard/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dashboard/assets/index-DbQzubLF.css +0 -1
- package/dashboard/assets/index-nFZAC5Mt.js +0 -20
|
@@ -3414,15 +3414,16 @@ function stopOrder(commands) {
|
|
|
3414
3414
|
}
|
|
3415
3415
|
|
|
3416
3416
|
// ../daemon/src/index.ts
|
|
3417
|
-
import
|
|
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
|
|
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_
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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,
|
|
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 ===
|
|
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 ${
|
|
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,
|
|
4192
|
+
updateDependencies(projectId, serviceName2, after) {
|
|
4106
4193
|
const project = this.require(projectId);
|
|
4107
4194
|
try {
|
|
4108
|
-
updateProjectDependencies(project.file,
|
|
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,
|
|
4201
|
+
updateServiceConfig(projectId, serviceName2, settings) {
|
|
4115
4202
|
const project = this.require(projectId);
|
|
4116
4203
|
try {
|
|
4117
|
-
updateProjectServiceConfig(project.file,
|
|
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 (!
|
|
4211
|
+
if (!fs4.existsSync(this.catalogFile))
|
|
4125
4212
|
return [];
|
|
4126
4213
|
try {
|
|
4127
|
-
const parsed = JSON.parse(
|
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
4346
|
+
var HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
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]
|
|
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
|
|
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 =
|
|
5085
|
+
const stat = fs5.lstatSync(file);
|
|
4968
5086
|
if (!stat.isFile() || stat.size > 65536)
|
|
4969
5087
|
continue;
|
|
4970
|
-
const text =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
5227
|
+
fs6.mkdirSync(directory, { recursive: true, mode: 448 });
|
|
5110
5228
|
if (process.platform !== "win32")
|
|
5111
|
-
|
|
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
|
|
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,
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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",
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 =
|
|
6750
|
+
const boundary = fs8.existsSync(marker2) ? fs8.readFileSync(marker2, "utf8") : undefined;
|
|
6600
6751
|
let fd;
|
|
6601
6752
|
let size;
|
|
6602
6753
|
try {
|
|
6603
|
-
fd =
|
|
6604
|
-
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
|
-
|
|
6766
|
+
fs8.readSync(fd, bytes, 0, count, start);
|
|
6616
6767
|
} finally {
|
|
6617
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
6766
|
-
|
|
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
|
-
|
|
6773
|
-
|
|
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 (
|
|
6861
|
-
|
|
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
|
|
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
|
-
|
|
6940
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 (!
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
8420
|
+
server.listen(apiPort, "127.0.0.1", () => {
|
|
8421
|
+
server.off("error", reject);
|
|
8422
|
+
resolve();
|
|
8423
|
+
});
|
|
8259
8424
|
});
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
|
|
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
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
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(
|
|
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
|
|
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) =>
|
|
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 (
|
|
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
|
-
|
|
8362
|
-
const out =
|
|
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,
|
|
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 };
|