@aws-blocks/core 0.1.7 → 0.1.11

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.
Files changed (65) hide show
  1. package/dist/db-naming.d.ts +17 -5
  2. package/dist/db-naming.d.ts.map +1 -1
  3. package/dist/db-naming.js +18 -6
  4. package/dist/db-naming.test.js +44 -3
  5. package/dist/hosting.d.ts +49 -0
  6. package/dist/hosting.d.ts.map +1 -1
  7. package/dist/hosting.js +47 -8
  8. package/dist/hosting.test.js +60 -0
  9. package/dist/scripts/deploy.d.ts.map +1 -1
  10. package/dist/scripts/deploy.js +4 -2
  11. package/dist/scripts/dev-server-reclaim.test.d.ts +2 -0
  12. package/dist/scripts/dev-server-reclaim.test.d.ts.map +1 -0
  13. package/dist/scripts/dev-server-reclaim.test.js +352 -0
  14. package/dist/scripts/dev-server.d.ts +168 -0
  15. package/dist/scripts/dev-server.d.ts.map +1 -1
  16. package/dist/scripts/dev-server.js +357 -25
  17. package/dist/scripts/ensure-secrets.d.ts +5 -2
  18. package/dist/scripts/ensure-secrets.d.ts.map +1 -1
  19. package/dist/scripts/ensure-secrets.js +14 -6
  20. package/dist/scripts/external-migrations-step.d.ts.map +1 -1
  21. package/dist/scripts/external-migrations-step.js +5 -1
  22. package/dist/scripts/index.d.ts +1 -1
  23. package/dist/scripts/index.d.ts.map +1 -1
  24. package/dist/scripts/index.js +1 -1
  25. package/dist/scripts/process-tree.d.ts +41 -2
  26. package/dist/scripts/process-tree.d.ts.map +1 -1
  27. package/dist/scripts/process-tree.js +83 -3
  28. package/dist/scripts/sandbox.d.ts.map +1 -1
  29. package/dist/scripts/sandbox.js +10 -1
  30. package/dist/scripts/stack-id.d.ts +25 -0
  31. package/dist/scripts/stack-id.d.ts.map +1 -1
  32. package/dist/scripts/stack-id.js +25 -0
  33. package/dist/scripts/stack-id.test.js +51 -1
  34. package/dist/telemetry/client.d.ts +3 -1
  35. package/dist/telemetry/client.d.ts.map +1 -1
  36. package/dist/telemetry/client.js +20 -24
  37. package/dist/telemetry/telemetry-send-worker.d.ts +2 -0
  38. package/dist/telemetry/telemetry-send-worker.d.ts.map +1 -0
  39. package/dist/telemetry/telemetry-send-worker.js +58 -0
  40. package/dist/telemetry/telemetry.test.js +77 -1
  41. package/dist/telemetry/trackCommand.d.ts +1 -1
  42. package/dist/telemetry/trackCommand.js +3 -3
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.d.ts.map +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
  47. package/src/db-naming.test.ts +50 -5
  48. package/src/db-naming.ts +18 -6
  49. package/src/hosting.test.ts +79 -0
  50. package/src/hosting.ts +105 -12
  51. package/src/scripts/deploy.ts +4 -2
  52. package/src/scripts/dev-server-reclaim.test.ts +430 -0
  53. package/src/scripts/dev-server.ts +428 -25
  54. package/src/scripts/ensure-secrets.ts +17 -6
  55. package/src/scripts/external-migrations-step.ts +5 -1
  56. package/src/scripts/index.ts +1 -1
  57. package/src/scripts/process-tree.ts +101 -3
  58. package/src/scripts/sandbox.ts +10 -1
  59. package/src/scripts/stack-id.test.ts +61 -1
  60. package/src/scripts/stack-id.ts +26 -0
  61. package/src/telemetry/client.ts +22 -30
  62. package/src/telemetry/telemetry-send-worker.ts +60 -0
  63. package/src/telemetry/telemetry.test.ts +91 -1
  64. package/src/telemetry/trackCommand.ts +3 -3
  65. package/src/version.ts +1 -1
@@ -3,20 +3,21 @@
3
3
  import { createServer } from 'node:http';
4
4
  import { pathToFileURL, URL } from 'node:url';
5
5
  import { resolve, dirname, join } from 'node:path';
6
- import { writeFileSync, mkdirSync } from 'node:fs';
6
+ import { writeFileSync, mkdirSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { createConnection } from 'node:net';
9
9
  import httpProxy from 'http-proxy';
10
10
  import { writeClientCode } from './generate-client.js';
11
11
  import { ApiError } from '../errors.js';
12
12
  import { BLOCKS_RPC_PREFIX, BLOCKS_SANDBOX_PREFIX } from '../constants.js';
13
+ import { BLOCKS_SANDBOX_DIR } from '../common/constants.js';
13
14
  import { matchRoute, lockRouteRegistry } from '../raw-route.js';
14
15
  import { registerBuiltinRoutes } from '../builtin-routes.js';
15
16
  import { parseRpcRequest, successResponse, errorResponseFromCatch, methodNotFoundResponse, } from '../rpc.js';
16
17
  import { redactToJson } from '../redact.js';
17
18
  import { buildAndSendEvent } from '../telemetry/client.js';
18
19
  import { applyDevMigrations } from './external-migrations-step.js';
19
- import { killFrontendTree, terminateProcessTree } from './process-tree.js';
20
+ import { killFrontendTree, terminateProcessTree, findListenerPids, killListenerTree } from './process-tree.js';
20
21
  function toBodyStream(text) {
21
22
  if (!text)
22
23
  return null;
@@ -70,19 +71,44 @@ async function deployLocal(backend) {
70
71
  }
71
72
  await Promise.all(initPromises);
72
73
  }
74
+ /**
75
+ * Single-shot TCP probe: resolves `true` iff a connection to `port` succeeds
76
+ * within `timeoutMs`, else `false` (connection error or timeout). Never rejects.
77
+ *
78
+ * When `host` is omitted the port is probed on BOTH loopback families —
79
+ * `127.0.0.1` (IPv4) and `::1` (IPv6) — and reported open if EITHER answers.
80
+ * This matters because `server.listen(port, …)` (below) passes no host, so Node
81
+ * binds dual-stack on `::` (all interfaces, both families). A single `localhost`
82
+ * probe resolves to only one of `::1`/`127.0.0.1` on a given host, so an orphan
83
+ * holding the port on the *other* family would be invisible — leaving the
84
+ * startup/EADDRINUSE reclaim path blind to a port that `listen` will still
85
+ * reject. Probing both families keeps every "is the port bound" check in
86
+ * agreement with what `listen` actually contends for. Pass an explicit `host`
87
+ * to probe only that address.
88
+ *
89
+ * Shared by {@link waitForPort} (wait until open), {@link waitForPortFree} (wait
90
+ * until closed) and the startup/EADDRINUSE reclaim path so all three agree on
91
+ * exactly what "the port is bound" means.
92
+ */
93
+ export async function isPortOpen(port, host, timeoutMs = 200) {
94
+ const probeOne = (h) => new Promise((resolve) => {
95
+ const socket = createConnection({ port, host: h }, () => {
96
+ socket.destroy();
97
+ resolve(true);
98
+ });
99
+ socket.on('error', () => { socket.destroy(); resolve(false); });
100
+ socket.setTimeout(timeoutMs, () => { socket.destroy(); resolve(false); });
101
+ });
102
+ if (host !== undefined)
103
+ return probeOne(host);
104
+ const [v4, v6] = await Promise.all([probeOne('127.0.0.1'), probeOne('::1')]);
105
+ return v4 || v6;
106
+ }
73
107
  /** Wait for a port to accept TCP connections. */
74
108
  async function waitForPort(port, maxAttempts = 60) {
75
109
  const { setTimeout: sleep } = await import('node:timers/promises');
76
110
  for (let i = 0; i < maxAttempts; i++) {
77
- const connected = await new Promise((resolve) => {
78
- const socket = createConnection({ port, host: 'localhost' }, () => {
79
- socket.destroy();
80
- resolve(true);
81
- });
82
- socket.on('error', () => { socket.destroy(); resolve(false); });
83
- socket.setTimeout(300, () => { socket.destroy(); resolve(false); });
84
- });
85
- if (connected)
111
+ if (await isPortOpen(port, undefined, 300))
86
112
  return;
87
113
  await sleep(500);
88
114
  }
@@ -135,15 +161,7 @@ export async function waitForPortFree(port, timeoutMs = 2000) {
135
161
  const { setTimeout: sleep } = await import('node:timers/promises');
136
162
  const deadline = Date.now() + timeoutMs;
137
163
  while (Date.now() < deadline) {
138
- const open = await new Promise((resolve) => {
139
- const socket = createConnection({ port, host: 'localhost' }, () => {
140
- socket.destroy();
141
- resolve(true);
142
- });
143
- socket.on('error', () => { socket.destroy(); resolve(false); });
144
- socket.setTimeout(200, () => { socket.destroy(); resolve(false); });
145
- });
146
- if (!open)
164
+ if (!(await isPortOpen(port, undefined, 200)))
147
165
  return;
148
166
  await sleep(100);
149
167
  }
@@ -168,9 +186,261 @@ export function shouldCreditFrontendReady(child, current) {
168
186
  child.exitCode === null &&
169
187
  child.signalCode === null);
170
188
  }
189
+ /**
190
+ * Free a port left bound by a crashed / SIGKILL'd predecessor so a fresh dev
191
+ * server can bind the `:3000` front door — or spawn its `--strictPort` frontend
192
+ * on `:3100` — instead of colliding on it and crashing.
193
+ *
194
+ * No-op when the port is already free. Otherwise it discovers the listener PID(s)
195
+ * ({@link findListenerPids}, an `lsof`/`netstat` probe — the same fuser-style
196
+ * mechanism `cleanup` uses), SIGTERMs each ({@link killListenerTree}, which
197
+ * reuses the frontend process-group kill), waits (bounded) for release
198
+ * ({@link waitForPortFree}), then escalates to SIGKILL if the port is still held.
199
+ * This deliberately mirrors the respawn path (process-group kill + port-free
200
+ * wait) rather than inventing a new teardown mechanism.
201
+ *
202
+ * The caller is responsible for NOT reclaiming a *healthy peer* dev server — the
203
+ * singleton guard ({@link evaluateSingleton}) runs first and bows out when a live
204
+ * peer owns the front door, so anything still holding these ports here is an
205
+ * orphan. Dependencies are injected for tests; returns what it did for
206
+ * logging/assertions. Best-effort: never throws.
207
+ *
208
+ * Probe contract: the default `probe` is {@link isPortOpen} with no host, which
209
+ * checks BOTH `127.0.0.1` and `::1`. `server.listen` binds dual-stack on `::`,
210
+ * so an orphan holding the port on either loopback family is detected (and thus
211
+ * reclaimed) — a single-family `localhost` probe could miss it and no-op.
212
+ */
213
+ export async function reclaimPort(port, deps = {}) {
214
+ const probe = deps.probe ?? ((p) => isPortOpen(p));
215
+ const listPids = deps.listPids ?? ((p) => findListenerPids(p));
216
+ const killTree = deps.killTree ?? ((pid, sig) => killListenerTree(pid, sig));
217
+ const waitFree = deps.waitFree ?? ((p, t) => waitForPortFree(p, t));
218
+ if (!(await probe(port)))
219
+ return { wasOpen: false, reclaimed: true, pids: [] };
220
+ const pids = listPids(port);
221
+ for (const pid of pids)
222
+ killTree(pid, 'SIGTERM');
223
+ await waitFree(port, 2000);
224
+ if (await probe(port)) {
225
+ // Still held after a graceful SIGTERM — escalate to SIGKILL. ALWAYS re-list
226
+ // first: the owner set can change during the wait — the original process may
227
+ // have exited and a NEW one grabbed the port, so SIGKILLing the stale `pids`
228
+ // would miss the real owner. Fall back to the stale list only if the re-list
229
+ // comes back empty (e.g. lsof was momentarily unavailable).
230
+ const killPids = listPids(port);
231
+ for (const pid of killPids.length ? killPids : pids)
232
+ killTree(pid, 'SIGKILL');
233
+ await waitFree(port, 2000);
234
+ }
235
+ return { wasOpen: true, reclaimed: !(await probe(port)), pids };
236
+ }
237
+ /**
238
+ * Build the console message for a startup reclaim of a port that was in use,
239
+ * distinguishing the three meaningfully different outcomes so the operator knows
240
+ * what (if anything) to do next:
241
+ * - reclaimed → we freed it; startup continues.
242
+ * - not reclaimed but owner PID(s) known → tell the operator exactly which
243
+ * process(es) to stop and retry.
244
+ * - not reclaimed and no owner PID found → nothing to point at (lsof/netstat
245
+ * found no listener), so surface the generic "in use" message.
246
+ * `subject` describes what was reclaimed (e.g. 'a stale/orphaned listener'). Pure.
247
+ */
248
+ export function reclaimMessage(port, result, subject) {
249
+ if (result.reclaimed) {
250
+ return `♻️ Reclaimed port ${port} from ${subject} before startup.`;
251
+ }
252
+ if (result.pids.length > 0) {
253
+ return (`⚠️ Port ${port} held by pid(s) [${result.pids.join(', ')}] and could not be freed — ` +
254
+ `stop that process and retry.`);
255
+ }
256
+ return `⚠️ Port ${port} is in use and could not be reclaimed automatically (no owner PID found).`;
257
+ }
258
+ /** Default front-door bind retry budget: 3 attempts, 250ms→750ms linear backoff. */
259
+ export const DEFAULT_PORT_BIND_RETRY_POLICY = {
260
+ maxAttempts: 3,
261
+ backoffMs: 250,
262
+ };
263
+ /**
264
+ * Decide whether an EADDRINUSE on the `:3000` front door should trigger another
265
+ * reclaim-and-rebind attempt. `attempt` is the number of failures so far
266
+ * (1-based). Returns `retry: false` once the budget is exhausted so the caller
267
+ * exits non-zero with a clear message rather than looping forever. Pure.
268
+ */
269
+ export function evaluatePortBindRetry(attempt, policy = DEFAULT_PORT_BIND_RETRY_POLICY) {
270
+ if (attempt >= policy.maxAttempts)
271
+ return { retry: false, delayMs: 0 };
272
+ return { retry: true, delayMs: policy.backoffMs * attempt };
273
+ }
274
+ /**
275
+ * Build the `:3000` front-door EADDRINUSE bind-retry handler. Extracted from the
276
+ * `server.on('error')` closure so the retry *wiring* — the 1-based attempt
277
+ * counter, the bounded {@link evaluatePortBindRetry} decision, reclaim-result
278
+ * routing, and retry scheduling — is unit-testable without a real socket.
279
+ *
280
+ * Returns a function to invoke on each EADDRINUSE. Per invocation it:
281
+ * - increments the attempt counter and consults {@link evaluatePortBindRetry};
282
+ * - on exhaustion: warns with an actionable message and calls `onExhausted`
283
+ * (no further retry is scheduled);
284
+ * - otherwise: warns it is retrying, then (async) reclaims the port and — when
285
+ * reclaim did NOT free it — surfaces the {@link reclaimMessage} naming the
286
+ * holding pid(s) so the operator isn't left with only the generic banner,
287
+ * then schedules the next `relisten` after the decided backoff.
288
+ * Never throws.
289
+ */
290
+ export function createBindRetryController(port, deps, policy = DEFAULT_PORT_BIND_RETRY_POLICY) {
291
+ let attempts = 0;
292
+ return () => {
293
+ attempts += 1;
294
+ const decision = evaluatePortBindRetry(attempts, policy);
295
+ if (!decision.retry) {
296
+ deps.warn(`\n❌ Port ${port} is still in use after ${policy.maxAttempts} ` +
297
+ `attempts to reclaim it — another process is holding :${port}. Stop it (or run the ` +
298
+ `cleanup script) and retry \`npm run dev\`.\n`);
299
+ deps.onExhausted();
300
+ return;
301
+ }
302
+ deps.warn(`⚠️ Port ${port} already in use (EADDRINUSE) — reclaiming the stale owner and retrying ` +
303
+ `(attempt ${attempts}/${policy.maxAttempts})…`);
304
+ void (async () => {
305
+ const result = await deps.reclaim(port);
306
+ if (!result.reclaimed)
307
+ deps.warn(reclaimMessage(port, result, 'a stale/orphaned listener'));
308
+ deps.scheduleRetry(deps.relisten, decision.delayMs);
309
+ })();
310
+ };
311
+ }
312
+ /** Parse a pidfile body into a {@link DevServerPidRecord}; `null` if absent/corrupt/incomplete. */
313
+ export function parsePidRecord(text) {
314
+ try {
315
+ const o = JSON.parse(text);
316
+ if (o && Number.isInteger(o.pid) && Number.isInteger(o.ppid) && Number.isInteger(o.port)) {
317
+ return { pid: o.pid, ppid: o.ppid, port: o.port };
318
+ }
319
+ }
320
+ catch {
321
+ // Corrupt / empty pidfile — treat as absent.
322
+ }
323
+ return null;
324
+ }
325
+ /** True iff a signal can be delivered to `pid` (exists). `EPERM` (exists, not ours) counts as alive. */
326
+ export function isPidAlive(pid, kill = (p, s) => process.kill(p, s)) {
327
+ if (!Number.isInteger(pid) || pid <= 1)
328
+ return false;
329
+ try {
330
+ kill(pid, 0);
331
+ return true;
332
+ }
333
+ catch (e) {
334
+ return e.code === 'EPERM';
335
+ }
336
+ }
337
+ /**
338
+ * Decide whether a *new* dev-server invocation should start, or bow out because
339
+ * another supervisor already owns `port`. This is the singleton guard that stops
340
+ * the "two fighting supervisors" restart loop — a second `npm run dev` racing the
341
+ * first on `:3000`/`:3100` — WITHOUT breaking `tsx watch`'s own restart of the
342
+ * *same* supervisor on a file change.
343
+ *
344
+ * - **No / corrupt pidfile** → proceed (first start; startup reclaim covers any orphan socket).
345
+ * - **Same pid** → proceed (defensive; the record is our own).
346
+ * - **Same parent (`ppid`)** → proceed. `tsx watch` is the stable parent across
347
+ * reloads, so a matching parent means the watcher is relaunching OUR OWN script
348
+ * — not a competitor. A second `npm run dev` runs under a *different* watcher,
349
+ * so it never matches here. This carve-out is what preserves hot reload.
350
+ * - **Different, still-live owner actually holding the port** → exit cleanly with
351
+ * a clear message (do not spawn a competing supervisor).
352
+ * - **Otherwise** (recorded owner is dead → stale pidfile, or the port is free)
353
+ * → proceed; startup reclaim frees any orphaned socket.
354
+ */
355
+ export function evaluateSingleton(existing, self, portInUse, isAlive) {
356
+ if (!existing)
357
+ return { action: 'proceed' };
358
+ if (existing.pid === self.pid)
359
+ return { action: 'proceed' };
360
+ if (existing.ppid === self.ppid)
361
+ return { action: 'proceed' }; // tsx-watch relaunch of our own supervisor
362
+ const ownerAlive = isAlive(existing.pid) || (existing.ppid > 1 && isAlive(existing.ppid));
363
+ if (ownerAlive && portInUse) {
364
+ return { action: 'exit', reason: `dev server already running on :${existing.port} (pid ${existing.pid})` };
365
+ }
366
+ return { action: 'proceed' };
367
+ }
171
368
  export async function startDevServer(options) {
172
369
  const { port = 3000, backendPath, frontendCommand, frontendPort = 3100, } = options;
173
370
  const devStartTime = Date.now();
371
+ // ── Singleton guard ─────────────────────────────────────────────────────
372
+ // Prevent two fighting supervisors: a second `npm run dev` must not spawn a
373
+ // competing supervisor that races the first on :3000/:3100 (backend + Vite
374
+ // EADDRINUSE → mutual Vite kills → restart loop). We record {pid, ppid, port}
375
+ // in a per-port pidfile and consult it here. The `ppid` (stable `tsx watch`
376
+ // watcher) lets us tell a hot-reload relaunch of OUR OWN script apart from a
377
+ // genuine second invocation — see {@link evaluateSingleton}. A stale pidfile
378
+ // (dead owner) never blocks startup.
379
+ const pidfilePath = join(BLOCKS_SANDBOX_DIR, `dev-server.${port}.pid`);
380
+ const removeOwnPidfile = () => {
381
+ // Close the read-check-then-delete TOCTOU: a naive `read → if mine → unlink`
382
+ // could delete a tsx-watch SUCCESSOR's pidfile if it wrote between our read
383
+ // and our unlink. Instead we atomically `rename` the current pidfile to a
384
+ // pid-private path (rename(2) is atomic — a concurrent writer can't observe a
385
+ // half-state), THEN inspect the snapshot:
386
+ // • it's ours → unlink the private copy (done; a successor that
387
+ // writes a fresh pidfile afterwards is untouched
388
+ // because we never unlink the canonical path).
389
+ // • it's a successor → we grabbed its file (it wrote before our rename);
390
+ // put it back so the live successor keeps its guard.
391
+ // We only ever unlink the private claim, never the canonical path, so a
392
+ // successor's fresh pidfile is never destroyed.
393
+ const claimPath = `${pidfilePath}.${process.pid}.gc`;
394
+ try {
395
+ renameSync(pidfilePath, claimPath);
396
+ }
397
+ catch {
398
+ return; // No pidfile to remove (already gone / never written).
399
+ }
400
+ let rec = null;
401
+ try {
402
+ rec = parsePidRecord(readFileSync(claimPath, 'utf-8'));
403
+ }
404
+ catch { /* unreadable snapshot */ }
405
+ if (rec && rec.pid !== process.pid) {
406
+ // Snapshot belongs to a successor — restore it and leave its guard intact.
407
+ // Residual (astronomically narrow) window: between this rename-away and
408
+ // rename-back the canonical path briefly has no pidfile, so a THIRD
409
+ // concurrent start could momentarily see "no guard" and proceed — the same
410
+ // benign "weakened guard, never broken startup" degradation the write path
411
+ // already tolerates. tsx-watch drains the old supervisor before relaunching,
412
+ // so overlap here does not occur in practice.
413
+ try {
414
+ renameSync(claimPath, pidfilePath);
415
+ return;
416
+ }
417
+ catch { /* fall through to cleanup */ }
418
+ }
419
+ try {
420
+ unlinkSync(claimPath);
421
+ }
422
+ catch { /* already gone */ }
423
+ };
424
+ {
425
+ mkdirSync(BLOCKS_SANDBOX_DIR, { recursive: true });
426
+ let existing = null;
427
+ try {
428
+ existing = parsePidRecord(readFileSync(pidfilePath, 'utf-8'));
429
+ }
430
+ catch { /* absent */ }
431
+ const portInUse = await isPortOpen(port);
432
+ const decision = evaluateSingleton(existing, { pid: process.pid, ppid: process.ppid }, portInUse, isPidAlive);
433
+ if (decision.action === 'exit') {
434
+ console.error(`\n⚠️ ${decision.reason}.\n` +
435
+ ` Not starting a second dev server. Stop the other process (or run the ` +
436
+ `cleanup script) and retry \`npm run dev\`.\n`);
437
+ process.exit(0);
438
+ }
439
+ try {
440
+ writeFileSync(pidfilePath, JSON.stringify({ pid: process.pid, ppid: process.ppid, port }));
441
+ }
442
+ catch { /* best-effort — a missing pidfile only weakens the guard, never breaks startup */ }
443
+ }
174
444
  // Load .env.local if present (connection strings, project refs, etc.)
175
445
  try {
176
446
  process.loadEnvFile('.env.local');
@@ -198,8 +468,8 @@ export async function startDevServer(options) {
198
468
  // This makes sandbox single-origin, matching `npm run dev` and the prod
199
469
  // CloudFront proxy; `crossDomain` stays unnecessary.
200
470
  const blocksConfig = buildBlocksConfig(port, isSandbox);
201
- mkdirSync('.blocks-sandbox', { recursive: true });
202
- writeFileSync('.blocks-sandbox/config.json', JSON.stringify(blocksConfig, null, 2));
471
+ mkdirSync(BLOCKS_SANDBOX_DIR, { recursive: true });
472
+ writeFileSync(join(BLOCKS_SANDBOX_DIR, 'config.json'), JSON.stringify(blocksConfig, null, 2));
203
473
  // 1. Set up global collectors for plugin discovery
204
474
  globalThis.__BLOCKS_CLIENT_MIDDLEWARE__ = [];
205
475
  globalThis.__BLOCKS_DEV_ATTACHMENTS__ = [];
@@ -505,8 +775,30 @@ export async function startDevServer(options) {
505
775
  console.log('📝 Generating client code...');
506
776
  await writeClientCode(resolvedPath, clientPath);
507
777
  }
778
+ // ── Startup reclaim ──────────────────────────────────────────────────────
779
+ // Free any port left bound by a crashed / SIGKILL'd predecessor before we bind
780
+ // the front door or spawn the `--strictPort` frontend. tsx-watch only gives the
781
+ // previous process ~5s to run cleanup(); if it was SIGKILL'd or crashed, its
782
+ // detached Vite grandchild (or an orphaned backend) can still hold :3100/:3000
783
+ // and a fresh `--strictPort` start would collide and crash. The singleton guard
784
+ // above already ruled out a live *peer* supervisor, so anything still holding
785
+ // these ports is an orphan — reclaim it (see {@link reclaimPort}).
786
+ // A successful reclaim (♻️) is an informational confirmation → stdout; a
787
+ // failed reclaim (⚠️) is a warning the operator must act on → stderr. Keeping
788
+ // healthy-startup output off stderr avoids false alarms in CI pipelines that
789
+ // treat any stderr line as a failure.
790
+ const r3000 = await reclaimPort(port);
791
+ if (r3000.wasOpen) {
792
+ (r3000.reclaimed ? console.log : console.error)(reclaimMessage(port, r3000, 'a stale/orphaned listener'));
793
+ }
794
+ if (frontendCommand) {
795
+ const rFrontend = await reclaimPort(frontendPort);
796
+ if (rFrontend.wasOpen) {
797
+ (rFrontend.reclaimed ? console.log : console.error)(reclaimMessage(frontendPort, rFrontend, 'a stale/orphaned dev server'));
798
+ }
799
+ }
508
800
  // ── Start listening ────────────────────────────────────────────────────
509
- server.listen(port, async () => {
801
+ const onListening = async () => {
510
802
  console.log(`AWS Blocks local server running on http://localhost:${port}`);
511
803
  buildAndSendEvent({ command: 'dev', state: 'SUCCESS', duration: Date.now() - devStartTime });
512
804
  // Spawn frontend dev server after Blocks server is ready
@@ -517,11 +809,44 @@ export async function startDevServer(options) {
517
809
  else {
518
810
  console.log(`\n ➜ http://localhost:${port}/\n`);
519
811
  }
812
+ };
813
+ // Front-door EADDRINUSE robustness — mirror the treatment :3100 already gets:
814
+ // emit a REAL console error (not just telemetry), reclaim the stale owner and
815
+ // retry the bind (bounded), and on unrecoverable failure exit non-zero with a
816
+ // clear message so a contended :3000 never silently fails to serve. Startup
817
+ // reclaim above makes this a rare race (someone grabbed :3000 between reclaim
818
+ // and listen); the retry closes that window. The retry wiring lives in the
819
+ // testable {@link createBindRetryController}; telemetry stays here.
820
+ const onEaddrinuse = createBindRetryController(port, {
821
+ reclaim: (p) => reclaimPort(p),
822
+ relisten: () => server.listen(port, onListening),
823
+ scheduleRetry: (fn, delayMs) => { setTimeout(fn, delayMs).unref?.(); },
824
+ onExhausted: () => process.exit(1),
825
+ warn: (msg) => console.error(msg),
520
826
  });
521
827
  server.on('error', (err) => {
522
- const errorCode = err.code === 'EADDRINUSE' ? 'PORT_IN_USE' : 'UNKNOWN';
523
- buildAndSendEvent({ command: 'dev', state: 'FAIL', duration: Date.now() - devStartTime, error: { code: errorCode, phase: 'startup' } });
828
+ if (err.code === 'EADDRINUSE') {
829
+ // Keep the telemetry signal (unchanged)
830
+ buildAndSendEvent({
831
+ command: 'dev',
832
+ state: 'FAIL',
833
+ duration: Date.now() - devStartTime,
834
+ error: { code: 'PORT_IN_USE', phase: 'startup' },
835
+ });
836
+ onEaddrinuse();
837
+ return;
838
+ }
839
+ // Non-EADDRINUSE startup error: telemetry + a real error, then exit non-zero.
840
+ buildAndSendEvent({
841
+ command: 'dev',
842
+ state: 'FAIL',
843
+ duration: Date.now() - devStartTime,
844
+ error: { code: 'UNKNOWN', phase: 'startup' },
845
+ });
846
+ console.error(`\n❌ Dev server failed to start: ${err.message}\n`);
847
+ process.exit(1);
524
848
  });
849
+ server.listen(port, onListening);
525
850
  // ── Cleanup ────────────────────────────────────────────────────────────
526
851
  const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
527
852
  let cleaningUp = false;
@@ -549,6 +874,10 @@ export async function startDevServer(options) {
549
874
  }
550
875
  catch { }
551
876
  }
877
+ // Release the singleton pidfile so the next `npm run dev` isn't blocked by
878
+ // our own stale record (only removed if it still points at us — a hot-reload
879
+ // successor may already own it).
880
+ removeOwnPidfile();
552
881
  frontendProxy?.close();
553
882
  apiProxy?.close();
554
883
  server.close(() => process.exit(0));
@@ -567,6 +896,9 @@ export async function startDevServer(options) {
567
896
  // (a surviving grandchild keeps the group alive) — see POST-EXIT GROUP-KILL
568
897
  // POLICY above.
569
898
  process.once('exit', () => {
899
+ // Release our singleton pidfile on any exit path (crash, uncaught exception)
900
+ // that bypassed cleanup(), so it never lingers and blocks the next start.
901
+ removeOwnPidfile();
570
902
  const child = frontendProcess;
571
903
  if (!child)
572
904
  return;
@@ -8,9 +8,12 @@ export declare function findConnectionString(): {
8
8
  value: string;
9
9
  } | null;
10
10
  /**
11
- * Ensure the connection string is stored in SSM for the current stage.
11
+ * Ensure the connection string is stored in SSM under this app's stack-scoped
12
+ * parameter name. `projectRoot` locates the committed `.blocks/config.json`
13
+ * that defines the stack name; it must match the root used at synth (the deploy
14
+ * commands pass it explicitly) so the written name equals the name the app reads.
12
15
  */
13
- export declare function ensureSecrets(stage?: 'sandbox' | 'production'): Promise<EnsureSecretsResult>;
16
+ export declare function ensureSecrets(stage?: string, projectRoot?: string): Promise<EnsureSecretsResult>;
14
17
  /**
15
18
  * Load environment for production deployment.
16
19
  *
@@ -1 +1 @@
1
- {"version":3,"file":"ensure-secrets.d.ts","sourceRoot":"","sources":["../../src/scripts/ensure-secrets.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,wBAAgB,oBAAoB,IAAI;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAO7E;AAED;;GAEG;AACH,wBAAsB,aAAa,CAAC,KAAK,CAAC,EAAE,SAAS,GAAG,YAAY,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAsClG;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAIxC;AAED,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAelD"}
1
+ {"version":3,"file":"ensure-secrets.d.ts","sourceRoot":"","sources":["../../src/scripts/ensure-secrets.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,wBAAgB,oBAAoB,IAAI;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAO7E;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,KAAK,CAAC,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,mBAAmB,CAAC,CAsC9B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAIxC;AAED,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAelD"}
@@ -3,15 +3,20 @@
3
3
  /**
4
4
  * Pre-deploy secret provisioning.
5
5
  *
6
- * Writes the connection string to an SSM SecureString parameter.
7
- * Parameter name includes the stage to prevent sandbox/prod collision:
8
- * /blocks/{stage}/db-connection-string
6
+ * Writes the connection string to an SSM SecureString parameter. The parameter
7
+ * name is stack-scoped (`/<stackName>-db-url` via `dbConnectionParameterName`),
8
+ * so two Blocks apps in the same account/region/stage never collide. The synth
9
+ * step names the parameter with the same function and the same inputs
10
+ * (`projectRoot` + stage), so the value written here is read back under the
11
+ * identical name — which is why this must be given the same `projectRoot` the
12
+ * deploy command passes to synth.
9
13
  *
10
14
  * On first deploy: creates the parameter.
11
15
  * On subsequent deploys: updates if value changed, no-op otherwise.
12
16
  */
13
17
  import { existsSync, readFileSync } from 'node:fs';
14
18
  import { dbConnectionParameterName } from '../db-naming.js';
19
+ import { getStackName } from './stack-id.js';
15
20
  const CONNECTION_STRING_PATTERN = /_(DB_URL|CONNECTION_STRING)$/;
16
21
  export function findConnectionString() {
17
22
  for (const [name, value] of Object.entries(process.env)) {
@@ -22,9 +27,12 @@ export function findConnectionString() {
22
27
  return null;
23
28
  }
24
29
  /**
25
- * Ensure the connection string is stored in SSM for the current stage.
30
+ * Ensure the connection string is stored in SSM under this app's stack-scoped
31
+ * parameter name. `projectRoot` locates the committed `.blocks/config.json`
32
+ * that defines the stack name; it must match the root used at synth (the deploy
33
+ * commands pass it explicitly) so the written name equals the name the app reads.
26
34
  */
27
- export async function ensureSecrets(stage) {
35
+ export async function ensureSecrets(stage, projectRoot) {
28
36
  const result = { created: [], updated: [], unchanged: [] };
29
37
  const conn = findConnectionString();
30
38
  if (!conn)
@@ -32,7 +40,7 @@ export async function ensureSecrets(stage) {
32
40
  const resolvedStage = stage ?? process.env.BLOCKS_STAGE ?? 'sandbox';
33
41
  const { SSMClient, GetParameterCommand, PutParameterCommand } = await import('@aws-sdk/client-ssm');
34
42
  const client = new SSMClient();
35
- const parameterName = dbConnectionParameterName(resolvedStage);
43
+ const parameterName = dbConnectionParameterName(getStackName({ sandbox: resolvedStage !== 'production', projectRoot }));
36
44
  let isNew = false;
37
45
  try {
38
46
  const current = await client.send(new GetParameterCommand({
@@ -1 +1 @@
1
- {"version":3,"file":"external-migrations-step.d.ts","sourceRoot":"","sources":["../../src/scripts/external-migrations-step.ts"],"names":[],"mappings":"AAmCA;;;;;GAKG;AACH;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,kBAAkB,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAI5G;AAcD,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,SAAS,GAAG,YAAY,CAAC;IAChC,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,SAAS,GAAG,YAAY,GAAG,OAAO,CAErF;AAED;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,8BAA8B,GAAG,OAAO,CAAC,OAAO,CAAC,CAkBpG;AAoBD,qFAAqF;AACrF,wBAAgB,iCAAiC,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAe3E;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,OAAO,CAExF;AAuED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,CAAC,EAAE;IAAE,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CA8BnH"}
1
+ {"version":3,"file":"external-migrations-step.d.ts","sourceRoot":"","sources":["../../src/scripts/external-migrations-step.ts"],"names":[],"mappings":"AAoCA;;;;;GAKG;AACH;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,kBAAkB,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAI5G;AAcD,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,SAAS,GAAG,YAAY,CAAC;IAChC,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,SAAS,GAAG,YAAY,GAAG,OAAO,CAErF;AAED;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,8BAA8B,GAAG,OAAO,CAAC,OAAO,CAAC,CAkBpG;AAoBD,qFAAqF;AACrF,wBAAgB,iCAAiC,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAe3E;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,OAAO,CAExF;AA0ED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,CAAC,EAAE;IAAE,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CA8BnH"}
@@ -25,6 +25,7 @@
25
25
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
26
26
  import { findConnectionString } from './ensure-secrets.js';
27
27
  import { extractDbRef, dbConnectionParameterName } from '../db-naming.js';
28
+ import { getStackName } from './stack-id.js';
28
29
  import { runSync } from './run-command.js';
29
30
  const DEFAULT_MIGRATIONS_DIR = './migrations';
30
31
  /** Default output dir for db-pull generated files (database.types.ts / database.meta.ts). */
@@ -150,7 +151,10 @@ async function productionRefs(devRef) {
150
151
  }
151
152
  try {
152
153
  const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
153
- const res = await new SSMClient().send(new GetParameterCommand({ Name: dbConnectionParameterName('production'), WithDecryption: true }));
154
+ const res = await new SSMClient().send(new GetParameterCommand({
155
+ Name: dbConnectionParameterName(getStackName({ sandbox: false })),
156
+ WithDecryption: true,
157
+ }));
154
158
  const v = res.Parameter?.Value;
155
159
  const r = v ? safeRef(v) : null;
156
160
  if (r)
@@ -9,5 +9,5 @@ export { openConsole, type ConsoleOptions } from './console.js';
9
9
  export { ensureSecrets, loadProductionEnv, loadEnvFile } from './ensure-secrets.js';
10
10
  export { trackCommand, buildAndSendEvent, classifyError, type CommandName, type CommandState, type BuildAndSendEventOptions, } from '../telemetry/index.js';
11
11
  export { telemetry, type TelemetryOptions } from './telemetry.js';
12
- export { getStackId, getSandboxId } from './stack-id.js';
12
+ export { getStackId, getSandboxId, getStackName } from './stack-id.js';
13
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scripts/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AACjF,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACpF,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,wBAAwB,GAC9B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scripts/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AACjF,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACpF,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,wBAAwB,GAC9B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
@@ -11,4 +11,4 @@ export { openConsole } from './console.js';
11
11
  export { ensureSecrets, loadProductionEnv, loadEnvFile } from './ensure-secrets.js';
12
12
  export { trackCommand, buildAndSendEvent, classifyError, } from '../telemetry/index.js';
13
13
  export { telemetry } from './telemetry.js';
14
- export { getStackId, getSandboxId } from './stack-id.js';
14
+ export { getStackId, getSandboxId, getStackName } from './stack-id.js';
@@ -26,8 +26,9 @@ interface TreeKillResult {
26
26
  * denied): such a run did NOT reap the tree, so the caller must fall back to a
27
27
  * direct `child.kill` rather than treat the leak as handled. (`child.kill`
28
28
  * cannot reap the orphaned grandchild either, but the fallback is cheap and
29
- * strictly correct — we never silently swallow a failed tree-kill.) Never
30
- * throws.
29
+ * strictly correct — we never silently swallow a failed tree-kill.) Runs with a
30
+ * 3s `timeout` so a wedged `taskkill` can't stall teardown; a timed-out run
31
+ * surfaces as `{error}` and degrades to the fallback. Never throws.
31
32
  */
32
33
  export declare function windowsTreeKill(pid: number, runner?: (command: string, args: readonly string[]) => TreeKillResult): boolean;
33
34
  /**
@@ -52,6 +53,44 @@ export declare function windowsTreeKill(pid: number, runner?: (command: string,
52
53
  * direct `child.kill`.
53
54
  */
54
55
  export declare function killFrontendTree(child: KillableProcess, signal?: NodeJS.Signals, platform?: NodeJS.Platform, killFn?: (pid: number, signal: NodeJS.Signals) => void, winTreeKill?: (pid: number) => boolean): void;
56
+ /** Subset of a `spawnSync` result that {@link findListenerPids} inspects. */
57
+ interface CommandOutput {
58
+ stdout?: string | null;
59
+ status?: number | null;
60
+ error?: Error;
61
+ }
62
+ /**
63
+ * Find the PIDs of processes holding a TCP *listener* on `port`, so a fresh dev
64
+ * server can reclaim a port left bound by a crashed / SIGKILL'd predecessor (its
65
+ * orphaned backend, or a detached Vite grandchild) instead of colliding on it.
66
+ * This mirrors the `lsof -ti:<port>` discovery the `cleanup` script already uses
67
+ * — it does NOT introduce a new port-to-PID mechanism.
68
+ *
69
+ * - **POSIX**: `lsof -ti tcp:<port> -sTCP:LISTEN` — `-t` prints bare PIDs and the
70
+ * `-sTCP:LISTEN` state filter restricts the match to the *listener*, so a
71
+ * transient client socket on the same port is never targeted.
72
+ * - **Windows**: `netstat -ano -p tcp`, keeping the trailing PID column of
73
+ * `LISTENING` rows whose local address ends in `:<port>`.
74
+ *
75
+ * Best-effort and never throws: a missing tool, a non-zero exit ("nothing is
76
+ * listening"), or unparseable output all yield `[]`. The `spawnSync` runs with a
77
+ * 3s `timeout` so a hung `lsof`/`netstat` (e.g. an unresponsive NFS mount) can't
78
+ * block the event loop during startup — a timed-out probe returns `{error}`,
79
+ * which the `catch` degrades to `[]`. PIDs `<= 1` are dropped
80
+ * defensively (never target init / the whole current group). `runner`/`platform`
81
+ * are injected for tests.
82
+ */
83
+ export declare function findListenerPids(port: number, runner?: (command: string, args: readonly string[]) => CommandOutput, platform?: NodeJS.Platform): number[];
84
+ /**
85
+ * Force-terminate whatever process (and, on POSIX, its process group) currently
86
+ * holds a port — used by the dev server's startup / EADDRINUSE *reclaim* path on
87
+ * a PID discovered via {@link findListenerPids}, i.e. a process this dev server
88
+ * did NOT spawn. Reuses {@link killFrontendTree} (POSIX `-pid` group kill /
89
+ * Windows `taskkill /T`, with a direct-`kill` fallback) so reclaim reaps exactly
90
+ * like our own frontend teardown — no bespoke kill mechanism. Best-effort; never
91
+ * throws (a since-exited PID just yields ESRCH, swallowed by killFrontendTree).
92
+ */
93
+ export declare function killListenerTree(pid: number, signal?: NodeJS.Signals, platform?: NodeJS.Platform, killFn?: (pid: number, signal: NodeJS.Signals) => void, winTreeKill?: (pid: number) => boolean): void;
55
94
  /** Child surface {@link terminateProcessTree} needs: a tree to kill plus exit state to await. */
56
95
  export interface AwaitableChild extends KillableProcess {
57
96
  exitCode: number | null;