procdeck 1.0.0 → 1.1.1
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/dist/cli.js +121 -30
- package/dist/public.d.ts +7 -0
- package/package.json +1 -1
- package/readme.md +3 -1
- package/schema.json +13 -0
package/dist/cli.js
CHANGED
|
@@ -45446,6 +45446,12 @@ var ProcSpecSchema = Schema_exports.Struct({
|
|
|
45446
45446
|
examples: ["http://localhost:${port}"]
|
|
45447
45447
|
})
|
|
45448
45448
|
),
|
|
45449
|
+
port: Schema_exports.optionalKey(
|
|
45450
|
+
Schema_exports.Number.annotate({
|
|
45451
|
+
description: "Pin this proc's assigned `${port}` to a fixed public number instead of a random free one \u2014 dependents outside the deck (dotenv files, mobile simulators, teammates' scripts) keep their hardcoded addresses, and an observed proc still binds a hidden internal port behind the HTTP observer. Requires `${port}` somewhere in the spec.",
|
|
45452
|
+
examples: [8787]
|
|
45453
|
+
}).pipe(Schema_exports.check(Schema_exports.isInt(), Schema_exports.isBetween({ minimum: 1, maximum: 65535 })))
|
|
45454
|
+
),
|
|
45449
45455
|
needs: Schema_exports.optionalKey(
|
|
45450
45456
|
Schema_exports.Array(Schema_exports.String).annotate({
|
|
45451
45457
|
description: "Ids of procs that must be ready before this one spawns. Declarative: readiness is auto-detected, no port numbers in the config.",
|
|
@@ -45553,6 +45559,25 @@ var validatePortTemplates = (procs) => {
|
|
|
45553
45559
|
}
|
|
45554
45560
|
return void 0;
|
|
45555
45561
|
};
|
|
45562
|
+
var validatePinnedPorts = (config) => {
|
|
45563
|
+
const uiPort = config.port ?? DEFAULT_UI_PORT;
|
|
45564
|
+
const holders = /* @__PURE__ */ new Map();
|
|
45565
|
+
for (const proc of config.procs) {
|
|
45566
|
+
if (proc.port === void 0) continue;
|
|
45567
|
+
if (!usesOwnPort(proc)) {
|
|
45568
|
+
return `proc "${proc.id}" pins port ${proc.port} but does not use \${port} \u2014 the pin is the public side of an assigned port`;
|
|
45569
|
+
}
|
|
45570
|
+
if (proc.port === uiPort) {
|
|
45571
|
+
return `proc "${proc.id}" pins port ${proc.port}, which is the deck UI's own port`;
|
|
45572
|
+
}
|
|
45573
|
+
const holder = holders.get(proc.port);
|
|
45574
|
+
if (holder !== void 0) {
|
|
45575
|
+
return `procs "${holder}" and "${proc.id}" pin the same port ${proc.port}`;
|
|
45576
|
+
}
|
|
45577
|
+
holders.set(proc.port, proc.id);
|
|
45578
|
+
}
|
|
45579
|
+
return void 0;
|
|
45580
|
+
};
|
|
45556
45581
|
var ProcdeckConfigSchema = Schema_exports.Struct({
|
|
45557
45582
|
// JSON configs point at the published schema for editor completion; the key
|
|
45558
45583
|
// is meaningless at runtime but must be accepted, because Struct is exact.
|
|
@@ -45586,7 +45611,7 @@ var ProcdeckConfigSchema = Schema_exports.Struct({
|
|
|
45586
45611
|
}).pipe(
|
|
45587
45612
|
Schema_exports.check(
|
|
45588
45613
|
Schema_exports.makeFilter(
|
|
45589
|
-
(config) => validateGraph(config.procs) ?? validatePortTemplates(config.procs)
|
|
45614
|
+
(config) => validateGraph(config.procs) ?? validatePortTemplates(config.procs) ?? validatePinnedPorts(config)
|
|
45590
45615
|
)
|
|
45591
45616
|
)
|
|
45592
45617
|
).annotate({
|
|
@@ -47017,14 +47042,29 @@ var WsMessageParser = class {
|
|
|
47017
47042
|
};
|
|
47018
47043
|
|
|
47019
47044
|
// src/interpose.ts
|
|
47020
|
-
var upstreamOptions = (req, port) =>
|
|
47021
|
-
|
|
47022
|
-
|
|
47023
|
-
|
|
47024
|
-
|
|
47025
|
-
|
|
47026
|
-
|
|
47027
|
-
}
|
|
47045
|
+
var upstreamOptions = (req, port) => {
|
|
47046
|
+
const headers = {
|
|
47047
|
+
...req.headers,
|
|
47048
|
+
host: `localhost:${port}`
|
|
47049
|
+
};
|
|
47050
|
+
if (headers["x-forwarded-host"] === void 0 && req.headers.host !== void 0) {
|
|
47051
|
+
headers["x-forwarded-host"] = req.headers.host;
|
|
47052
|
+
}
|
|
47053
|
+
if (headers["x-forwarded-proto"] === void 0) headers["x-forwarded-proto"] = "http";
|
|
47054
|
+
const client = req.socket.remoteAddress;
|
|
47055
|
+
if (client !== void 0) {
|
|
47056
|
+
const prior = headers["x-forwarded-for"];
|
|
47057
|
+
headers["x-forwarded-for"] = prior === void 0 ? client : `${String(prior)}, ${client}`;
|
|
47058
|
+
}
|
|
47059
|
+
return {
|
|
47060
|
+
host: "localhost",
|
|
47061
|
+
port,
|
|
47062
|
+
autoSelectFamily: true,
|
|
47063
|
+
method: req.method,
|
|
47064
|
+
path: req.url,
|
|
47065
|
+
headers
|
|
47066
|
+
};
|
|
47067
|
+
};
|
|
47028
47068
|
var forwardRequest = (options) => {
|
|
47029
47069
|
const { port, record: record2, req, res } = options;
|
|
47030
47070
|
const started = Date.now();
|
|
@@ -47052,17 +47092,27 @@ var forwardRequest = (options) => {
|
|
|
47052
47092
|
const resTap = record2 === void 0 ? void 0 : new BodyTap(isTextType(upRes.headers["content-type"]));
|
|
47053
47093
|
if (resTap !== void 0) upRes.on("data", (chunk) => resTap.push(chunk));
|
|
47054
47094
|
upRes.on("end", () => finish(upRes.statusCode ?? 502, resTap, redactHeaders2(upRes.headers)));
|
|
47055
|
-
upRes.on("close", () =>
|
|
47095
|
+
upRes.on("close", () => {
|
|
47096
|
+
finish(upRes.statusCode ?? 502, resTap, redactHeaders2(upRes.headers));
|
|
47097
|
+
if (!res.writableEnded) res.destroy();
|
|
47098
|
+
});
|
|
47056
47099
|
res.writeHead(upRes.statusCode ?? 502, upRes.headers);
|
|
47057
47100
|
upRes.pipe(res);
|
|
47058
47101
|
});
|
|
47059
47102
|
upstream.on("error", () => {
|
|
47060
47103
|
finish(0);
|
|
47104
|
+
if (res.destroyed) return;
|
|
47105
|
+
if (res.headersSent) return res.destroy();
|
|
47061
47106
|
if (options.onError !== void 0) return options.onError(res);
|
|
47062
|
-
|
|
47107
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
47063
47108
|
res.end(`procdeck: upstream refused the connection on :${port}
|
|
47064
47109
|
`);
|
|
47065
47110
|
});
|
|
47111
|
+
res.on("close", () => {
|
|
47112
|
+
if (res.writableEnded) return;
|
|
47113
|
+
upstream.destroy();
|
|
47114
|
+
finish(0);
|
|
47115
|
+
});
|
|
47066
47116
|
req.pipe(upstream);
|
|
47067
47117
|
};
|
|
47068
47118
|
var nextConnId = 1;
|
|
@@ -47320,12 +47370,18 @@ var make63 = Effect_exports.fn("procdeck.supervisor")(function* (loaded) {
|
|
|
47320
47370
|
const live = yield* PubSub_exports.sliding({ capacity: LIVE_BUFFER });
|
|
47321
47371
|
const wantsPort = loaded.config.procs.filter(usesOwnPort);
|
|
47322
47372
|
const observedSpecs = wantsPort.filter((spec) => spec.observe !== false);
|
|
47373
|
+
const unpinned = wantsPort.filter((spec) => spec.port === void 0);
|
|
47323
47374
|
const freePorts = yield* Effect_exports.promise(
|
|
47324
|
-
() => findFreePorts(
|
|
47375
|
+
() => findFreePorts(unpinned.length + observedSpecs.length)
|
|
47325
47376
|
);
|
|
47326
|
-
const assigned = new Map(
|
|
47377
|
+
const assigned = new Map([
|
|
47378
|
+
...wantsPort.flatMap(
|
|
47379
|
+
(spec) => spec.port === void 0 ? [] : [[spec.id, spec.port]]
|
|
47380
|
+
),
|
|
47381
|
+
...unpinned.map((spec, index2) => [spec.id, freePorts[index2]])
|
|
47382
|
+
]);
|
|
47327
47383
|
const internal = new Map(
|
|
47328
|
-
observedSpecs.map((spec, index2) => [spec.id, freePorts[
|
|
47384
|
+
observedSpecs.map((spec, index2) => [spec.id, freePorts[unpinned.length + index2]])
|
|
47329
47385
|
);
|
|
47330
47386
|
const runtimes = /* @__PURE__ */ new Map();
|
|
47331
47387
|
for (const spec of loaded.config.procs) {
|
|
@@ -47340,6 +47396,7 @@ var make63 = Effect_exports.fn("procdeck.supervisor")(function* (loaded) {
|
|
|
47340
47396
|
),
|
|
47341
47397
|
assignedPort,
|
|
47342
47398
|
internalPort,
|
|
47399
|
+
observerClose: void 0,
|
|
47343
47400
|
status: { id: spec.id, state: "stopped" },
|
|
47344
47401
|
proc: void 0,
|
|
47345
47402
|
ready: Latch_exports.makeUnsafe(false),
|
|
@@ -47356,19 +47413,14 @@ var make63 = Effect_exports.fn("procdeck.supervisor")(function* (loaded) {
|
|
|
47356
47413
|
rows: DEFAULT_ROWS
|
|
47357
47414
|
});
|
|
47358
47415
|
}
|
|
47359
|
-
|
|
47360
|
-
|
|
47361
|
-
|
|
47362
|
-
|
|
47363
|
-
() =>
|
|
47364
|
-
|
|
47365
|
-
|
|
47366
|
-
|
|
47367
|
-
})
|
|
47368
|
-
),
|
|
47369
|
-
(close4) => Effect_exports.promise(close4)
|
|
47370
|
-
);
|
|
47371
|
-
}
|
|
47416
|
+
yield* Effect_exports.acquireRelease(
|
|
47417
|
+
Effect_exports.void,
|
|
47418
|
+
() => Effect_exports.promise(
|
|
47419
|
+
() => Promise.all(
|
|
47420
|
+
[...runtimes.values()].map((runtime) => runtime.observerClose?.() ?? Promise.resolve())
|
|
47421
|
+
).then(() => void 0)
|
|
47422
|
+
)
|
|
47423
|
+
);
|
|
47372
47424
|
const emit = (event) => {
|
|
47373
47425
|
PubSub_exports.publishUnsafe(live, event);
|
|
47374
47426
|
};
|
|
@@ -47431,6 +47483,28 @@ var make63 = Effect_exports.fn("procdeck.supervisor")(function* (loaded) {
|
|
|
47431
47483
|
}
|
|
47432
47484
|
}
|
|
47433
47485
|
};
|
|
47486
|
+
const bindObserver = (runtime) => startObserver({
|
|
47487
|
+
publicPort: runtime.assignedPort,
|
|
47488
|
+
internalPort: runtime.internalPort,
|
|
47489
|
+
record: (capture) => runtime.http.record(capture)
|
|
47490
|
+
}).then(
|
|
47491
|
+
(close4) => {
|
|
47492
|
+
runtime.observerClose = close4;
|
|
47493
|
+
return true;
|
|
47494
|
+
},
|
|
47495
|
+
() => false
|
|
47496
|
+
);
|
|
47497
|
+
const parkOnTakenPort = (runtime) => {
|
|
47498
|
+
const hint = `free :${runtime.assignedPort} (is "${runtime.spec.id}" already running outside procdeck?), then Start`;
|
|
47499
|
+
say(
|
|
47500
|
+
runtime,
|
|
47501
|
+
`\r
|
|
47502
|
+
procdeck: pinned port :${runtime.assignedPort} is taken \u2014 cannot observe "${runtime.spec.id}"\r
|
|
47503
|
+
procdeck: hint: ${hint}\r
|
|
47504
|
+
`
|
|
47505
|
+
);
|
|
47506
|
+
setStatus2(runtime, { id: runtime.spec.id, state: "blocked", hint });
|
|
47507
|
+
};
|
|
47434
47508
|
const startSync = (runtime) => {
|
|
47435
47509
|
if (runtime.proc !== void 0) return;
|
|
47436
47510
|
setStatus2(runtime, { id: runtime.spec.id, state: "starting" });
|
|
@@ -47506,6 +47580,13 @@ ${lines2.join("\r\n")}\r
|
|
|
47506
47580
|
const start = Effect_exports.fn("procdeck.start")(function* (id2) {
|
|
47507
47581
|
const runtime = require_(id2);
|
|
47508
47582
|
if (runtime.proc !== void 0 || runtime.waiter !== void 0) return;
|
|
47583
|
+
if (runtime.internalPort !== void 0 && runtime.observerClose === void 0) {
|
|
47584
|
+
const bound = yield* Effect_exports.promise(() => bindObserver(runtime));
|
|
47585
|
+
if (!bound) {
|
|
47586
|
+
parkOnTakenPort(runtime);
|
|
47587
|
+
return;
|
|
47588
|
+
}
|
|
47589
|
+
}
|
|
47509
47590
|
const needs = runtime.spec.needs ?? [];
|
|
47510
47591
|
if (needs.length === 0 && runtime.spec.preflight === void 0) {
|
|
47511
47592
|
return yield* Effect_exports.sync(() => startSync(runtime));
|
|
@@ -47581,7 +47662,9 @@ ${lines2.join("\r\n")}\r
|
|
|
47581
47662
|
list: () => [...runtimes.values()].map((runtime) => ({
|
|
47582
47663
|
id: runtime.spec.id,
|
|
47583
47664
|
command: describeCommand(runtime.resolved),
|
|
47584
|
-
|
|
47665
|
+
// A pinned proc has a stable public address even with no `url` in
|
|
47666
|
+
// the config — derive the link instead of making the user repeat it.
|
|
47667
|
+
...runtime.resolved.url !== void 0 ? { url: runtime.resolved.url } : runtime.spec.port === void 0 ? {} : { url: `http://localhost:${runtime.spec.port}` },
|
|
47585
47668
|
...runtime.assignedPort === void 0 ? {} : { assignedPort: runtime.assignedPort },
|
|
47586
47669
|
proxyUrl: `http://${runtime.spec.id}.localhost:${uiPort}`,
|
|
47587
47670
|
status: runtime.status
|
|
@@ -47636,7 +47719,14 @@ ${lines2.join("\r\n")}\r
|
|
|
47636
47719
|
discard: true
|
|
47637
47720
|
});
|
|
47638
47721
|
for (const runtime of runtimes.values()) {
|
|
47639
|
-
if (runtime.
|
|
47722
|
+
if (runtime.internalPort === void 0) continue;
|
|
47723
|
+
const bound = yield* Effect_exports.promise(() => bindObserver(runtime));
|
|
47724
|
+
if (!bound) parkOnTakenPort(runtime);
|
|
47725
|
+
}
|
|
47726
|
+
for (const runtime of runtimes.values()) {
|
|
47727
|
+
if (runtime.spec.autostart !== false && runtime.status.state !== "blocked") {
|
|
47728
|
+
yield* start(runtime.spec.id);
|
|
47729
|
+
}
|
|
47640
47730
|
}
|
|
47641
47731
|
return { supervisor, stopAll };
|
|
47642
47732
|
});
|
|
@@ -47946,7 +48036,8 @@ var hostProcId = (host) => {
|
|
|
47946
48036
|
return name.endsWith(".localhost") ? name.slice(0, -".localhost".length) : void 0;
|
|
47947
48037
|
};
|
|
47948
48038
|
var refuse = (res, status2, message) => {
|
|
47949
|
-
if (
|
|
48039
|
+
if (res.headersSent) return void res.destroy();
|
|
48040
|
+
res.writeHead(status2, { "content-type": "text/plain; charset=utf-8" });
|
|
47950
48041
|
res.end(`procdeck: ${message}
|
|
47951
48042
|
`);
|
|
47952
48043
|
};
|
package/dist/public.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export type ProcSpec = {
|
|
|
42
42
|
readonly autostart?: boolean;
|
|
43
43
|
/** URL this process serves, shown in the UI as a link. */
|
|
44
44
|
readonly url?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Pin this proc's assigned `${port}` to a fixed public number instead of a
|
|
47
|
+
* random free one — dependents outside the deck keep their hardcoded
|
|
48
|
+
* addresses, and an observed proc still binds a hidden internal port.
|
|
49
|
+
* Requires `${port}` somewhere in the spec.
|
|
50
|
+
*/
|
|
51
|
+
readonly port?: number;
|
|
45
52
|
/** Ids of procs that must be ready before this one spawns. */
|
|
46
53
|
readonly needs?: ReadonlyArray<string>;
|
|
47
54
|
/** What "ready" means for dependents. Defaults to "listening". */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "procdeck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Interactive dev-process multiplexer with a web UI: terminal panes in a browser tab, declarative dependencies, assigned ports and a *.localhost reverse proxy.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
package/readme.md
CHANGED
|
@@ -52,7 +52,9 @@ pnpm add -D procdeck # npm i -D procdeck · bun add -d procdeck
|
|
|
52
52
|
- **Ports that never collide.** Write `${port}` in a command, env value or url
|
|
53
53
|
and procdeck assigns a free port before spawning. `${port:api}` is another
|
|
54
54
|
proc's port, so dependents get wired — `API_URL=http://localhost:${port:api}`
|
|
55
|
-
— with no hardcoded numbers anywhere.
|
|
55
|
+
— with no hardcoded numbers anywhere. And when the world already knows the
|
|
56
|
+
address, `port: 8787` pins the public side to that exact number — dotenv
|
|
57
|
+
files and simulators keep working, the observer stays in front.
|
|
56
58
|
- **A memorable address for each service.** `api.localhost:4820`,
|
|
57
59
|
`web.localhost:4820` — whatever port the process actually got. Browsers
|
|
58
60
|
resolve `*.localhost` themselves, so there is zero system setup, and
|
package/schema.json
CHANGED
|
@@ -118,6 +118,19 @@
|
|
|
118
118
|
"http://localhost:${port}"
|
|
119
119
|
]
|
|
120
120
|
},
|
|
121
|
+
"port": {
|
|
122
|
+
"description": "Pin this proc's assigned `${port}` to a fixed public number instead of a random free one — dependents outside the deck (dotenv files, mobile simulators, teammates' scripts) keep their hardcoded addresses, and an observed proc still binds a hidden internal port behind the HTTP observer. Requires `${port}` somewhere in the spec.",
|
|
123
|
+
"examples": [
|
|
124
|
+
8787
|
|
125
|
+
],
|
|
126
|
+
"type": "integer",
|
|
127
|
+
"allOf": [
|
|
128
|
+
{
|
|
129
|
+
"minimum": 1,
|
|
130
|
+
"maximum": 65535
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
},
|
|
121
134
|
"needs": {
|
|
122
135
|
"type": "array",
|
|
123
136
|
"items": {
|