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 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
- host: "localhost",
47022
- port,
47023
- autoSelectFamily: true,
47024
- method: req.method,
47025
- path: req.url,
47026
- headers: { ...req.headers, host: `localhost:${port}` }
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", () => finish(upRes.statusCode ?? 502, resTap, redactHeaders2(upRes.headers)));
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
- if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
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(wantsPort.length + observedSpecs.length)
47375
+ () => findFreePorts(unpinned.length + observedSpecs.length)
47325
47376
  );
47326
- const assigned = new Map(wantsPort.map((spec, index2) => [spec.id, freePorts[index2]]));
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[wantsPort.length + index2]])
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
- for (const [id2, internalPort] of internal) {
47360
- const runtime = runtimes.get(id2);
47361
- yield* Effect_exports.acquireRelease(
47362
- Effect_exports.promise(
47363
- () => startObserver({
47364
- publicPort: assigned.get(id2),
47365
- internalPort,
47366
- record: (capture) => runtime.http.record(capture)
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
- ...runtime.resolved.url === void 0 ? {} : { url: runtime.resolved.url },
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.spec.autostart !== false) yield* start(runtime.spec.id);
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 (!res.headersSent) res.writeHead(status2, { "content-type": "text/plain; charset=utf-8" });
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.0.0",
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": {