@skitterbyte/skitterspec 2.0.1 → 7.0.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/src/cli.js CHANGED
@@ -22,6 +22,9 @@ const { ensureWorktreeDirTrusted } = require('./env/trust.js')
22
22
  const { planUp } = require('./env/provision.js')
23
23
  const { planDown } = require('./env/teardown.js')
24
24
  const { planIntegrate } = require('./env/integrate.js')
25
+ const { planDev } = require('./env/dev.js')
26
+ const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
27
+ const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
25
28
 
26
29
  const pkg = require('../package.json')
27
30
 
@@ -36,6 +39,9 @@ Usage:
36
39
  specs/.core/env.config.json). Subcommands:
37
40
  up <spec> plan a worktree + Docker stack + opener
38
41
  down <spec> tear down (guards; --keep-volumes, --force)
42
+ dev up <spec> start host dev servers on the spec's ports
43
+ dev down <spec> stop the spec's host dev servers
44
+ connect <spec> expose a spec on the canonical ports (main = off)
39
45
  integrate <spec> plan rebase + fast-forward onto the base branch
40
46
  status list provisioned specs + port blocks
41
47
  resolve <spec> print resolved slug/type/branch/paths
@@ -199,6 +205,11 @@ function specEnvUp(dir, config, specArg) {
199
205
  out.push(' run these:')
200
206
  for (const cmd of plan.commands) out.push(` ${cmd}`)
201
207
  if (plan.openCommand) out.push(` ${plan.openCommand}`)
208
+ if (plan.setupCommands.length) {
209
+ out.push('')
210
+ out.push(' in the worktree, run:')
211
+ for (const cmd of plan.setupCommands) out.push(` ${cmd}`)
212
+ }
202
213
  if (plan.envContents) {
203
214
  out.push('')
204
215
  out.push(` write ${config.docker.envFile} in the worktree:`)
@@ -401,9 +412,171 @@ function specEnvResolve(dir, config, specArg) {
401
412
  )
402
413
  }
403
414
 
415
+ // Start/stop a spec's host dev servers on its reserved port block. Host dev
416
+ // servers (e.g. `pnpm dev`) need a block even on a worktree-only spec, so `up`
417
+ // allocates a slot if the spec has none (idempotent). The planner is pure
418
+ // (dev.js); the spawning/killing lives in supervise.js.
419
+ async function specEnvDev(dir, config, positional) {
420
+ const action = positional[0]
421
+ const specArg = positional[1]
422
+ if ((action !== 'up' && action !== 'down') || !specArg) {
423
+ process.stdout.write('Usage: skitterspec spec-env dev <up|down> <spec>\n')
424
+ return
425
+ }
426
+ const spec = resolveSpec(specArg, dir, config)
427
+ if (!config.dev.length) {
428
+ process.stdout.write(
429
+ 'spec-env dev: no dev processes configured — set "dev": [...] in env.config.json.\n',
430
+ )
431
+ return
432
+ }
433
+
434
+ const registry = readRegistry(dir, config)
435
+ let slot
436
+ if (action === 'up') {
437
+ // Ensure a slot (idempotent) so the port block is reserved even worktree-only.
438
+ const alloc = allocateSlot(registry, spec.folder)
439
+ slot = alloc.slot
440
+ writeRegistry(dir, config, alloc.registry)
441
+ } else {
442
+ // Teardown only needs the pid-file paths (keyed by folder, not slot), so the
443
+ // slot value is immaterial — use the existing one, or 0 as a placeholder.
444
+ slot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
445
+ ? registry.slots[spec.folder]
446
+ : 0
447
+ }
448
+
449
+ const plan = planDev(spec, slot, config)
450
+
451
+ if (action === 'up') {
452
+ const out = [`spec-env dev up: ${spec.folder} slot ${slot} (ports from ${plan.portOffset})`]
453
+ for (const proc of plan.procs) {
454
+ const res = startProcess(proc, { cwd: spec.worktreePath, rootDir: dir })
455
+ let health = ''
456
+ if (proc.health) {
457
+ health = (await waitHealthy(proc.health)) ? ' health: ok' : ' health: TIMEOUT'
458
+ }
459
+ out.push(
460
+ ` ${proc.name}: port ${proc.port} pid ${res.pid} ` +
461
+ `${res.started ? 'started' : 'already running'}${health}`,
462
+ )
463
+ }
464
+ out.push('')
465
+ out.push(` logs: ${stateDirLabel(config)}/logs/`)
466
+ process.stdout.write(out.join('\n') + '\n')
467
+ } else {
468
+ const out = [`spec-env dev down: ${spec.folder}`]
469
+ for (const proc of plan.procs) {
470
+ const res = await stopProcess(proc, { rootDir: dir })
471
+ out.push(` ${proc.name}: ${res.stopped ? `stopped (pid ${res.pid})` : 'not running'}`)
472
+ }
473
+ process.stdout.write(out.join('\n') + '\n')
474
+ }
475
+ }
476
+
477
+ // The `.spec-env`-style state dir label for user-facing messages.
478
+ function stateDirLabel(config) {
479
+ return path.posix.dirname(config.registry) || '.spec-env'
480
+ }
481
+
482
+ // The supervised proxy process descriptor (paths relative to the checkout root).
483
+ function proxyProcFor(config, routesFileAbs) {
484
+ const sdir = stateDirLabel(config)
485
+ return {
486
+ name: 'proxy',
487
+ command: `node ${path.join(__dirname, 'env', 'proxy.js')} ${routesFileAbs}`,
488
+ env: {},
489
+ logFile: `${sdir}/logs/proxy.log`,
490
+ pidFile: `${sdir}/pids/proxy.pid`,
491
+ }
492
+ }
493
+
494
+ // Connect the canonical origin to ONE spec (exclusive model): (re)start the
495
+ // bundled proxy pointing at that spec's warm dev servers. `connect main` stops
496
+ // the proxy so the primary checkout owns the canonical ports again.
497
+ async function specEnvConnect(dir, config, specArg) {
498
+ const sdir = stateDirLabel(config)
499
+ const abs = (rel) => path.resolve(dir, rel)
500
+ const routesFile = `${sdir}/proxy.json`
501
+ const connectedFile = `${sdir}/connected`
502
+ const proxyProc = proxyProcFor(config, abs(routesFile))
503
+ const target = specArg || 'main'
504
+
505
+ if (target === 'main') {
506
+ const res = await stopProcess(proxyProc, { rootDir: dir })
507
+ for (const f of [connectedFile, routesFile]) {
508
+ try {
509
+ fs.unlinkSync(abs(f))
510
+ } catch {
511
+ /* not connected */
512
+ }
513
+ }
514
+ process.stdout.write(
515
+ res.stopped
516
+ ? 'spec-connect: disconnected — the primary checkout owns the canonical ports again.\n'
517
+ : 'spec-connect: nothing was connected — the primary checkout already owns the ports.\n',
518
+ )
519
+ return
520
+ }
521
+
522
+ const spec = resolveSpec(target, dir, config)
523
+ const registry = readRegistry(dir, config)
524
+ if (!Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)) {
525
+ process.stdout.write(
526
+ `spec-connect: ${spec.folder} has no reserved ports yet — ` +
527
+ `run \`skitterspec spec-env dev up ${spec.folder}\` first.\n`,
528
+ )
529
+ return
530
+ }
531
+
532
+ const plan = planDev(spec, registry.slots[spec.folder], config)
533
+ const routes = renderRoutes(plan.procs)
534
+ if (!routes.length) {
535
+ process.stdout.write(
536
+ 'spec-connect: no dev process declares a frontPort — nothing to expose.\n',
537
+ )
538
+ return
539
+ }
540
+
541
+ // Stop any proxy we already run (a previous connect), freeing the canonical
542
+ // ports, then refuse if the primary checkout still holds one of them.
543
+ await stopProcess(proxyProc, { rootDir: dir })
544
+ const busy = await portsInUse(routes.map((r) => r.frontPort), config.proxy.host)
545
+ if (busy.length) {
546
+ process.stdout.write(
547
+ `spec-connect: canonical port(s) ${busy.join(', ')} are in use (your main dev server?).\n` +
548
+ 'Stop main on those ports, then re-run spec-connect.\n',
549
+ )
550
+ return
551
+ }
552
+
553
+ fs.mkdirSync(abs(sdir), { recursive: true })
554
+ fs.writeFileSync(abs(routesFile), JSON.stringify(routes, null, 2) + '\n')
555
+ const res = startProcess(proxyProc, { cwd: dir, rootDir: dir })
556
+ fs.writeFileSync(abs(connectedFile), spec.folder + '\n')
557
+
558
+ const ready = await waitListening(
559
+ routes.map((r) => r.frontPort),
560
+ { host: config.proxy.host },
561
+ )
562
+
563
+ const out = [
564
+ `spec-connect: ${spec.folder} → canonical ports (proxy pid ${res.pid})` +
565
+ (ready ? '' : ' [WARNING: proxy did not come up — see .spec-env/logs/proxy.log]'),
566
+ ]
567
+ for (const r of routes) {
568
+ out.push(
569
+ ` http://${config.proxy.host}:${r.frontPort} → ${r.name} (127.0.0.1:${r.targetPort})`,
570
+ )
571
+ }
572
+ out.push('')
573
+ out.push(' Disconnect with: skitterspec spec-env connect main')
574
+ process.stdout.write(out.join('\n') + '\n')
575
+ }
576
+
404
577
  // Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
405
578
  // message when the feature isn't enabled (no specs/.core/env.config.json).
406
- function specEnv(rest) {
579
+ async function specEnv(rest) {
407
580
  const [sub, ...args] = rest
408
581
  let dir = process.cwd()
409
582
  const positional = []
@@ -432,6 +605,12 @@ function specEnv(rest) {
432
605
  case 'down':
433
606
  specEnvDown(dir, config, positional[0], flags)
434
607
  break
608
+ case 'dev':
609
+ await specEnvDev(dir, config, positional)
610
+ break
611
+ case 'connect':
612
+ await specEnvConnect(dir, config, positional[0])
613
+ break
435
614
  case 'integrate':
436
615
  specEnvIntegrate(dir, config, positional[0])
437
616
  break
@@ -443,7 +622,7 @@ function specEnv(rest) {
443
622
  break
444
623
  default:
445
624
  process.stdout.write(
446
- 'Usage: skitterspec spec-env <up|down|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
625
+ 'Usage: skitterspec spec-env <up|down|dev|connect|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
447
626
  )
448
627
  }
449
628
  }
@@ -461,7 +640,7 @@ async function run(argv) {
461
640
  const [cmd, ...rest] = argv
462
641
 
463
642
  if (cmd === 'spec-env') {
464
- specEnv(rest)
643
+ await specEnv(rest)
465
644
  return
466
645
  }
467
646
 
package/src/env/config.js CHANGED
@@ -16,6 +16,11 @@
16
16
  * worktree: { root, folderPattern },
17
17
  * docker: { enabled, composeFile, projectNamePattern, portBase,
18
18
  * portsPerSpec, envFile, backupCommand },
19
+ * setup: [ "cmd", ... ], // bootstrap commands run in the worktree right
20
+ * // after `git worktree add` (e.g. install deps); empty = none
21
+ * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
22
+ * // servers started on the spec's port block (empty = none)
23
+ * proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
19
24
  * open: { command }, // optional, editor/terminal-agnostic opener
20
25
  * registry: ".spec-env/registry.json",
21
26
  * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
@@ -40,6 +45,16 @@ const DEFAULT_CONFIG = Object.freeze({
40
45
  envFile: '.env',
41
46
  backupCommand: '',
42
47
  }),
48
+ // Bootstrap commands run in the worktree by `spec-env up`, right after
49
+ // `git worktree add` (before Docker/dev), on every provision. Array of shell
50
+ // strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
51
+ setup: Object.freeze([]),
52
+ // Host dev servers started on the spec's port block by `spec-env dev up`.
53
+ // Each: { name, command, portVar, health?, frontPort? }. Default: none.
54
+ dev: Object.freeze([]),
55
+ // Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
56
+ // exposes one connected spec's frontPort processes on the canonical ports.
57
+ proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
43
58
  open: Object.freeze({ command: '' }),
44
59
  registry: '.spec-env/registry.json',
45
60
  // Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
@@ -61,6 +76,9 @@ function defaults() {
61
76
  return {
62
77
  worktree: { ...DEFAULT_CONFIG.worktree },
63
78
  docker: { ...DEFAULT_CONFIG.docker },
79
+ setup: [],
80
+ dev: [],
81
+ proxy: { ...DEFAULT_CONFIG.proxy },
64
82
  open: { ...DEFAULT_CONFIG.open },
65
83
  registry: DEFAULT_CONFIG.registry,
66
84
  branch: { ...DEFAULT_CONFIG.branch },
@@ -85,6 +103,45 @@ function assign(base, parsed, key, type) {
85
103
  }
86
104
  }
87
105
 
106
+ /**
107
+ * Normalise a parsed `dev` array into well-formed process entries. Each entry
108
+ * needs non-empty string `name`, `command`, and `portVar`; `health` (string) and
109
+ * `frontPort` (finite number) are optional. Malformed entries are dropped
110
+ * (lenient, like the rest of the loader) so a stray entry can't crash provisioning.
111
+ */
112
+ function normalizeDev(parsed) {
113
+ const out = []
114
+ for (const raw of parsed) {
115
+ if (!isObject(raw)) continue
116
+ const name = typeof raw.name === 'string' ? raw.name.trim() : ''
117
+ const command = typeof raw.command === 'string' ? raw.command.trim() : ''
118
+ const portVar = typeof raw.portVar === 'string' ? raw.portVar.trim() : ''
119
+ if (!name || !command || !portVar) continue
120
+ const entry = { name, command, portVar }
121
+ if (typeof raw.health === 'string' && raw.health.trim()) entry.health = raw.health.trim()
122
+ if (typeof raw.frontPort === 'number' && Number.isFinite(raw.frontPort)) {
123
+ entry.frontPort = raw.frontPort
124
+ }
125
+ out.push(entry)
126
+ }
127
+ return out
128
+ }
129
+
130
+ /**
131
+ * Normalise a parsed `setup` array into bootstrap commands: keep only trimmed,
132
+ * non-empty strings, drop everything else (lenient, like `normalizeDev`) so a
133
+ * stray entry can't crash provisioning.
134
+ */
135
+ function normalizeSetup(parsed) {
136
+ const out = []
137
+ for (const raw of parsed) {
138
+ if (typeof raw !== 'string') continue
139
+ const cmd = raw.trim()
140
+ if (cmd) out.push(cmd)
141
+ }
142
+ return out
143
+ }
144
+
88
145
  /**
89
146
  * Merge a parsed config over the defaults. Only known keys are copied (unknown
90
147
  * keys ignored for forward-compat). Nested objects are merged field-by-field.
@@ -107,6 +164,19 @@ function mergeConfig(base, parsed) {
107
164
  assign(base.docker, parsed.docker, 'backupCommand', 'string?')
108
165
  }
109
166
 
167
+ if (Array.isArray(parsed.setup)) {
168
+ base.setup = normalizeSetup(parsed.setup)
169
+ }
170
+
171
+ if (Array.isArray(parsed.dev)) {
172
+ base.dev = normalizeDev(parsed.dev)
173
+ }
174
+
175
+ if (isObject(parsed.proxy)) {
176
+ assign(base.proxy, parsed.proxy, 'enabled', 'boolean')
177
+ assign(base.proxy, parsed.proxy, 'host', 'string')
178
+ }
179
+
110
180
  if (isObject(parsed.open)) {
111
181
  // command may be intentionally empty (no auto-open)
112
182
  assign(base.open, parsed.open, 'command', 'string?')
package/src/env/dev.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure planner for host dev-process supervision (`spec-env dev up|down`).
5
+ *
6
+ * Given a resolved spec and its allocated slot, `planDev` computes — for each
7
+ * configured `dev` process — its resolved port, the env var to inject, the log
8
+ * and pid file paths, the expanded health URL, and the canonical `frontPort` a
9
+ * later proxy (Phase 2) routes to it. No side effects: the CLI's `supervise.js`
10
+ * does the spawning/killing; this stays unit-testable with no processes.
11
+ *
12
+ * Port math: process `i` in the slot's block gets `portBase + slot*portsPerSpec
13
+ * + i` (reusing the same block the Docker stack draws from — see registry.js).
14
+ * So host dev servers get a reserved block even on a worktree-only spec.
15
+ *
16
+ * Log/pid files live beside the registry (default `.spec-env/`) and are keyed by
17
+ * the spec **folder** (not the bare slug) so a `feat-foo`/`bug-foo` pair can't
18
+ * collide — matching how the registry keys slots.
19
+ */
20
+
21
+ const path = require('node:path')
22
+ const { portOffset } = require('./registry.js')
23
+ const { expandTokens } = require('./resolve.js')
24
+
25
+ // The state dir (logs + pids) sits beside the registry file, e.g. `.spec-env`.
26
+ function stateDir(config) {
27
+ return path.posix.dirname(config.registry) || '.spec-env'
28
+ }
29
+
30
+ /**
31
+ * Plan the host dev processes for `spec` at `slot`.
32
+ *
33
+ * @returns {object} { slot, portOffset, procs: [{ name, command, port, portVar,
34
+ * env, frontPort, logFile, pidFile, health }] } — logFile/pidFile are paths
35
+ * relative to the primary checkout root (the CLI resolves them).
36
+ */
37
+ function planDev(spec, slot, config) {
38
+ const base = portOffset(slot, config)
39
+ const dir = stateDir(config)
40
+ const procs = (config.dev || []).map((entry, i) => {
41
+ const port = base + i
42
+ const tokens = {
43
+ [entry.portVar]: String(port),
44
+ port: String(port),
45
+ slug: spec.slug,
46
+ name: entry.name,
47
+ }
48
+ return {
49
+ name: entry.name,
50
+ command: expandTokens(entry.command, tokens),
51
+ port,
52
+ portVar: entry.portVar,
53
+ env: { [entry.portVar]: String(port) },
54
+ frontPort: typeof entry.frontPort === 'number' ? entry.frontPort : null,
55
+ logFile: `${dir}/logs/${spec.folder}-${entry.name}.log`,
56
+ pidFile: `${dir}/pids/${spec.folder}-${entry.name}.pid`,
57
+ health: entry.health ? expandTokens(entry.health, tokens) : null,
58
+ }
59
+ })
60
+ return { slot, portOffset: base, procs }
61
+ }
62
+
63
+ module.exports = { planDev, stateDir }
@@ -13,6 +13,7 @@
13
13
 
14
14
  const { portOffset } = require('./registry.js')
15
15
  const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
+ const { expandTokens } = require('./resolve.js')
16
17
 
17
18
  /**
18
19
  * Plan a provisioning run.
@@ -23,7 +24,8 @@ const { renderEnvFile, expandOpenCommand } = require('./render.js')
23
24
  * existed in the registry (re-run → attach, don't clobber).
24
25
  * @param {object} config normalised env config.
25
26
  * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
26
- * envContents, openCommand, commands, attached }
27
+ * envContents, openCommand, commands, setupCommands,
28
+ * attached }
27
29
  */
28
30
  function planUp(spec, alloc, config) {
29
31
  const { slot, attached } = alloc
@@ -41,13 +43,21 @@ function planUp(spec, alloc, config) {
41
43
  ? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
42
44
  : null
43
45
 
44
- const openCommand = expandOpenCommand(config.open.command, {
46
+ const tokens = {
45
47
  worktreePath: spec.worktreePath,
46
48
  slug: spec.slug,
47
49
  branch: spec.branch,
48
50
  projectName: spec.projectName,
49
51
  portOffset: offset === null ? '' : String(offset),
50
- })
52
+ }
53
+
54
+ const openCommand = expandOpenCommand(config.open.command, tokens)
55
+
56
+ // Bootstrap commands run *in the worktree* after `git worktree add` (before
57
+ // Docker/dev), on every provision including re-attach — deps must exist for
58
+ // the worktree to be usable. Kept separate from `commands` (run from the
59
+ // primary checkout root); the CLI prints them under an "in the worktree" head.
60
+ const setupCommands = (config.setup || []).map((cmd) => expandTokens(cmd, tokens))
51
61
 
52
62
  const commands = []
53
63
  // Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
@@ -69,6 +79,7 @@ function planUp(spec, alloc, config) {
69
79
  envContents,
70
80
  openCommand,
71
81
  commands,
82
+ setupCommands,
72
83
  attached,
73
84
  }
74
85
  }
@@ -0,0 +1,159 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Front-door reverse proxy for `spec-env connect` — a small, dependency-free
5
+ * Node proxy that exposes the ONE connected spec's warm dev servers on the
6
+ * canonical ports (exclusive model: no cookie, one target at a time).
7
+ *
8
+ * `renderRoutes` is pure (procs → routes). `startProxy` builds one `http` server
9
+ * per route, forwarding HTTP and `upgrade` (WebSocket/HMR) to the spec's port.
10
+ * Run directly (`node proxy.js <routesFile>`) it becomes the long-lived,
11
+ * detached proxy process the CLI supervises with the Phase 1 seam.
12
+ */
13
+
14
+ const http = require('node:http')
15
+ const net = require('node:net')
16
+ const fs = require('node:fs')
17
+
18
+ /**
19
+ * The connected spec's `dev` processes that declare a `frontPort` → proxy routes.
20
+ * @param {Array} procs from `planDev(...).procs`
21
+ * @returns {Array<{name, frontPort, targetPort}>}
22
+ */
23
+ function renderRoutes(procs) {
24
+ return (procs || [])
25
+ .filter((p) => typeof p.frontPort === 'number')
26
+ .map((p) => ({ name: p.name, frontPort: p.frontPort, targetPort: p.port }))
27
+ }
28
+
29
+ // Build (but don't listen on) one proxy server: frontPort → targetPort. Forwards
30
+ // regular requests and transparently pipes `upgrade` (WebSocket) connections.
31
+ function createRouteServer(route, host = '127.0.0.1') {
32
+ const { targetPort } = route
33
+
34
+ const server = http.createServer((req, res) => {
35
+ const upstream = http.request(
36
+ { host, port: targetPort, method: req.method, path: req.url, headers: req.headers },
37
+ (ur) => {
38
+ res.writeHead(ur.statusCode || 502, ur.headers)
39
+ ur.pipe(res)
40
+ },
41
+ )
42
+ upstream.on('error', () => {
43
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' })
44
+ res.end(`spec-connect: upstream 127.0.0.1:${targetPort} not reachable\n`)
45
+ })
46
+ req.pipe(upstream)
47
+ })
48
+
49
+ // WebSocket / HTTP upgrade passthrough — reconstruct the request line + headers
50
+ // onto a raw TCP connection to the upstream, then pipe both ways.
51
+ server.on('upgrade', (req, socket, head) => {
52
+ const upstream = net.connect(targetPort, host, () => {
53
+ let raw = `${req.method} ${req.url} HTTP/1.1\r\n`
54
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
55
+ raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`
56
+ }
57
+ raw += '\r\n'
58
+ upstream.write(raw)
59
+ if (head && head.length) upstream.write(head)
60
+ socket.pipe(upstream)
61
+ upstream.pipe(socket)
62
+ })
63
+ upstream.on('error', () => socket.destroy())
64
+ socket.on('error', () => upstream.destroy())
65
+ })
66
+
67
+ return server
68
+ }
69
+
70
+ /**
71
+ * Start every route's server listening. Returns `{ servers, close() }` where
72
+ * `close()` resolves once all servers are shut. Rejects (via the returned
73
+ * promise on the server 'error') if a port can't be bound.
74
+ */
75
+ function startProxy(routes, { host = '127.0.0.1' } = {}) {
76
+ const servers = routes.map((route) => {
77
+ const server = createRouteServer(route, host)
78
+ server.listen(route.frontPort, host)
79
+ return server
80
+ })
81
+ return {
82
+ servers,
83
+ close() {
84
+ return Promise.all(
85
+ servers.map((s) => new Promise((resolve) => s.close(() => resolve()))),
86
+ )
87
+ },
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Which of `ports` are already bound (so `connect` can tell the operator to stop
93
+ * main first). Resolves to the list of busy ports. `host` scopes the check.
94
+ */
95
+ function portsInUse(ports, host = '127.0.0.1') {
96
+ return Promise.all(
97
+ ports.map(
98
+ (port) =>
99
+ new Promise((resolve) => {
100
+ const tester = net
101
+ .createServer()
102
+ .once('error', () => resolve(port)) // EADDRINUSE → busy
103
+ .once('listening', () => tester.close(() => resolve(null)))
104
+ .listen(port, host)
105
+ }),
106
+ ),
107
+ ).then((results) => results.filter((p) => p !== null))
108
+ }
109
+
110
+ // Does `port` accept a TCP connection right now?
111
+ function checkListening(port, host) {
112
+ return new Promise((resolve) => {
113
+ const sock = net.connect(port, host)
114
+ sock.once('connect', () => {
115
+ sock.destroy()
116
+ resolve(true)
117
+ })
118
+ sock.once('error', () => {
119
+ sock.destroy()
120
+ resolve(false)
121
+ })
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Poll until every port in `ports` accepts a connection (the detached proxy has
127
+ * finished binding), or `timeoutMs` elapses. Resolves true when all are up.
128
+ * `now`/`wait` are injectable for tests.
129
+ */
130
+ async function waitListening(
131
+ ports,
132
+ { host = '127.0.0.1', timeoutMs = 3000, intervalMs = 50, now = () => Date.now(), wait } = {},
133
+ ) {
134
+ const sleep = wait || ((ms) => new Promise((r) => setTimeout(r, ms)))
135
+ const deadline = now() + timeoutMs
136
+ while (now() < deadline) {
137
+ const results = await Promise.all(ports.map((p) => checkListening(p, host)))
138
+ if (results.every(Boolean)) return true
139
+ await sleep(intervalMs)
140
+ }
141
+ return false
142
+ }
143
+
144
+ module.exports = { renderRoutes, createRouteServer, startProxy, portsInUse, waitListening }
145
+
146
+ // Entry point: run as a detached process by the CLI. Reads its routes from a
147
+ // file so a re-`connect` just rewrites the file and restarts this (tiny) process.
148
+ if (require.main === module) {
149
+ const routesFile = process.argv[2]
150
+ if (!routesFile) {
151
+ process.stderr.write('proxy: usage: node proxy.js <routesFile>\n')
152
+ process.exit(1)
153
+ }
154
+ const routes = JSON.parse(fs.readFileSync(routesFile, 'utf-8'))
155
+ const { close } = startProxy(routes)
156
+ const shutdown = () => close().then(() => process.exit(0))
157
+ process.on('SIGTERM', shutdown)
158
+ process.on('SIGINT', shutdown)
159
+ }