@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.
- package/dist/db-naming.d.ts +17 -5
- package/dist/db-naming.d.ts.map +1 -1
- package/dist/db-naming.js +18 -6
- package/dist/db-naming.test.js +44 -3
- package/dist/hosting.d.ts +49 -0
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +47 -8
- package/dist/hosting.test.js +60 -0
- package/dist/scripts/deploy.d.ts.map +1 -1
- package/dist/scripts/deploy.js +4 -2
- package/dist/scripts/dev-server-reclaim.test.d.ts +2 -0
- package/dist/scripts/dev-server-reclaim.test.d.ts.map +1 -0
- package/dist/scripts/dev-server-reclaim.test.js +352 -0
- package/dist/scripts/dev-server.d.ts +168 -0
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +357 -25
- package/dist/scripts/ensure-secrets.d.ts +5 -2
- package/dist/scripts/ensure-secrets.d.ts.map +1 -1
- package/dist/scripts/ensure-secrets.js +14 -6
- package/dist/scripts/external-migrations-step.d.ts.map +1 -1
- package/dist/scripts/external-migrations-step.js +5 -1
- package/dist/scripts/index.d.ts +1 -1
- package/dist/scripts/index.d.ts.map +1 -1
- package/dist/scripts/index.js +1 -1
- package/dist/scripts/process-tree.d.ts +41 -2
- package/dist/scripts/process-tree.d.ts.map +1 -1
- package/dist/scripts/process-tree.js +83 -3
- package/dist/scripts/sandbox.d.ts.map +1 -1
- package/dist/scripts/sandbox.js +10 -1
- package/dist/scripts/stack-id.d.ts +25 -0
- package/dist/scripts/stack-id.d.ts.map +1 -1
- package/dist/scripts/stack-id.js +25 -0
- package/dist/scripts/stack-id.test.js +51 -1
- package/dist/telemetry/client.d.ts +3 -1
- package/dist/telemetry/client.d.ts.map +1 -1
- package/dist/telemetry/client.js +20 -24
- package/dist/telemetry/telemetry-send-worker.d.ts +2 -0
- package/dist/telemetry/telemetry-send-worker.d.ts.map +1 -0
- package/dist/telemetry/telemetry-send-worker.js +58 -0
- package/dist/telemetry/telemetry.test.js +77 -1
- package/dist/telemetry/trackCommand.d.ts +1 -1
- package/dist/telemetry/trackCommand.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/db-naming.test.ts +50 -5
- package/src/db-naming.ts +18 -6
- package/src/hosting.test.ts +79 -0
- package/src/hosting.ts +105 -12
- package/src/scripts/deploy.ts +4 -2
- package/src/scripts/dev-server-reclaim.test.ts +430 -0
- package/src/scripts/dev-server.ts +428 -25
- package/src/scripts/ensure-secrets.ts +17 -6
- package/src/scripts/external-migrations-step.ts +5 -1
- package/src/scripts/index.ts +1 -1
- package/src/scripts/process-tree.ts +101 -3
- package/src/scripts/sandbox.ts +10 -1
- package/src/scripts/stack-id.test.ts +61 -1
- package/src/scripts/stack-id.ts +26 -0
- package/src/telemetry/client.ts +22 -30
- package/src/telemetry/telemetry-send-worker.ts +60 -0
- package/src/telemetry/telemetry.test.ts +91 -1
- package/src/telemetry/trackCommand.ts +3 -3
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
202
|
-
writeFileSync('
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
|
|
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
|
|
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?:
|
|
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":"
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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":"
|
|
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({
|
|
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)
|
package/dist/scripts/index.d.ts
CHANGED
|
@@ -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"}
|
package/dist/scripts/index.js
CHANGED
|
@@ -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.)
|
|
30
|
-
*
|
|
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;
|