@aws-blocks/core 0.1.4 → 0.1.10
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/cdk/index.d.ts +1 -1
- package/dist/cdk/index.d.ts.map +1 -1
- package/dist/cdk/index.js +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- 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/errors.d.ts +28 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +27 -1
- package/dist/errors.test.d.ts +2 -0
- package/dist/errors.test.d.ts.map +1 -0
- package/dist/errors.test.js +47 -0
- package/dist/hosting.d.ts +71 -1
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +47 -8
- package/dist/hosting.test.js +60 -0
- package/dist/index.cdk.d.ts +1 -1
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/scripts/deploy.d.ts.map +1 -1
- package/dist/scripts/deploy.js +4 -2
- package/dist/scripts/dev-server-supervisor.test.d.ts +2 -0
- package/dist/scripts/dev-server-supervisor.test.d.ts.map +1 -0
- package/dist/scripts/dev-server-supervisor.test.js +551 -0
- package/dist/scripts/dev-server.d.ts +73 -0
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +279 -29
- 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 -0
- package/dist/scripts/index.d.ts.map +1 -1
- package/dist/scripts/index.js +1 -0
- package/dist/scripts/process-tree.d.ts +126 -0
- package/dist/scripts/process-tree.d.ts.map +1 -0
- package/dist/scripts/process-tree.js +198 -0
- package/dist/scripts/sandbox.d.ts.map +1 -1
- package/dist/scripts/sandbox.js +51 -4
- package/dist/scripts/stack-id.d.ts +37 -0
- package/dist/scripts/stack-id.d.ts.map +1 -0
- package/dist/scripts/stack-id.js +79 -0
- package/dist/scripts/stack-id.test.d.ts +2 -0
- package/dist/scripts/stack-id.test.d.ts.map +1 -0
- package/dist/scripts/stack-id.test.js +104 -0
- 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/cdk/index.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/db-naming.test.ts +50 -5
- package/src/db-naming.ts +18 -6
- package/src/errors.test.ts +55 -0
- package/src/errors.ts +32 -1
- package/src/hosting.test.ts +79 -0
- package/src/hosting.ts +127 -13
- package/src/index.cdk.ts +1 -1
- package/src/index.ts +1 -1
- package/src/scripts/deploy.ts +4 -2
- package/src/scripts/dev-server-supervisor.test.ts +621 -0
- package/src/scripts/dev-server.ts +316 -27
- package/src/scripts/ensure-secrets.ts +17 -6
- package/src/scripts/external-migrations-step.ts +5 -1
- package/src/scripts/index.ts +1 -0
- package/src/scripts/process-tree.ts +245 -0
- package/src/scripts/sandbox.ts +50 -4
- package/src/scripts/stack-id.test.ts +123 -0
- package/src/scripts/stack-id.ts +87 -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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
// Shared process-tree teardown primitives used by every dev-tooling entrypoint
|
|
7
|
+
// (the dev server and the sandbox). Both spawn a long-running command with
|
|
8
|
+
// `shell: true`, so the real process (Vite, or the `tsx watch` dev server) is a
|
|
9
|
+
// grandchild of the shell. Reaping it requires killing the whole tree, not just
|
|
10
|
+
// the shell parent — see the per-function docs. Keeping this in one module means
|
|
11
|
+
// the dev server, the sandbox, and the `process.on('exit')` safety net all reap
|
|
12
|
+
// identically instead of hand-rolling divergent copies.
|
|
13
|
+
|
|
14
|
+
/** Minimal child-process surface needed to terminate a frontend dev server. */
|
|
15
|
+
export interface KillableProcess {
|
|
16
|
+
pid?: number;
|
|
17
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Subset of {@link import('node:child_process').SpawnSyncReturns} that {@link windowsTreeKill} inspects. */
|
|
21
|
+
interface TreeKillResult {
|
|
22
|
+
status: number | null;
|
|
23
|
+
error?: Error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Force-kill an entire process tree on Windows via `taskkill /T /F /PID <pid>`.
|
|
28
|
+
*
|
|
29
|
+
* Windows has no POSIX process groups, so a bare `child.kill()` only signals the
|
|
30
|
+
* spawned shell and orphans the real dev server (the Vite grandchild), which
|
|
31
|
+
* keeps holding `:3100` — the very wedge the POSIX process-group kill fixes.
|
|
32
|
+
* `taskkill /T` walks the live child tree by PID and terminates every
|
|
33
|
+
* descendant; `/F` is required because Windows cannot deliver a graceful
|
|
34
|
+
* shutdown to a non-console subtree anyway (Node maps SIGTERM/SIGKILL to
|
|
35
|
+
* `TerminateProcess`).
|
|
36
|
+
*
|
|
37
|
+
* Returns `true` only when `taskkill` ran AND reported the tree handled — exit
|
|
38
|
+
* `0` (reaped the tree) or `128` (`"process not found"`, i.e. already gone).
|
|
39
|
+
* Returns `false` when the command could not be spawned at all (e.g. not on
|
|
40
|
+
* `PATH`) OR when it ran but returned any other status (e.g. `1` = access
|
|
41
|
+
* denied): such a run did NOT reap the tree, so the caller must fall back to a
|
|
42
|
+
* direct `child.kill` rather than treat the leak as handled. (`child.kill`
|
|
43
|
+
* cannot reap the orphaned grandchild either, but the fallback is cheap and
|
|
44
|
+
* strictly correct — we never silently swallow a failed tree-kill.) Never
|
|
45
|
+
* throws.
|
|
46
|
+
*/
|
|
47
|
+
export function windowsTreeKill(
|
|
48
|
+
pid: number,
|
|
49
|
+
runner: (command: string, args: readonly string[]) => TreeKillResult = (command, args) =>
|
|
50
|
+
spawnSync(command, args as string[], { stdio: 'ignore', windowsHide: true }),
|
|
51
|
+
): boolean {
|
|
52
|
+
try {
|
|
53
|
+
const { status, error } = runner('taskkill', ['/T', '/F', '/PID', String(pid)]);
|
|
54
|
+
// Couldn't even spawn taskkill (e.g. not on PATH) → not handled; fall back.
|
|
55
|
+
if (error) return false;
|
|
56
|
+
// taskkill ran: only exit 0 (reaped the tree) or 128 ("process not found",
|
|
57
|
+
// already gone) mean the tree is handled. Any other non-null status (e.g.
|
|
58
|
+
// 1 = access denied) means taskkill ran but did NOT reap the tree, so report
|
|
59
|
+
// not-handled and let the caller fall back to a direct child.kill.
|
|
60
|
+
return status === 0 || status === 128;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Terminate a process spawned with `shell: true`, including its descendants, on
|
|
68
|
+
* every platform.
|
|
69
|
+
*
|
|
70
|
+
* Under a shell the real dev server (e.g. Vite) is a **grandchild**: the direct
|
|
71
|
+
* child is the shell, so signalling only the shell (`child.kill`) orphans the
|
|
72
|
+
* grandchild, which keeps holding its port (`:3100`) and wedges the next
|
|
73
|
+
* restart.
|
|
74
|
+
*
|
|
75
|
+
* - **POSIX**: the process is spawned `detached` (its own process group,
|
|
76
|
+
* pgid === child.pid), so we signal the whole group with
|
|
77
|
+
* `process.kill(-pid, signal)` and every descendant dies, freeing the port.
|
|
78
|
+
* - **Windows**: there are no process groups, so we reap the tree with
|
|
79
|
+
* `taskkill /T /F /PID <pid>` (see {@link windowsTreeKill}), which walks the
|
|
80
|
+
* child tree by PID. A bare `child.kill` would leave the Vite grandchild
|
|
81
|
+
* bound to `:3100`, reproducing the POSIX wedge.
|
|
82
|
+
*
|
|
83
|
+
* Best-effort and never throws: a missing/invalid pid, an already-dead group
|
|
84
|
+
* (ESRCH), a failed group signal, or an unavailable `taskkill` all degrade to a
|
|
85
|
+
* direct `child.kill`.
|
|
86
|
+
*/
|
|
87
|
+
export function killFrontendTree(
|
|
88
|
+
child: KillableProcess,
|
|
89
|
+
signal: NodeJS.Signals = 'SIGTERM',
|
|
90
|
+
platform: NodeJS.Platform = process.platform,
|
|
91
|
+
killFn: (pid: number, signal: NodeJS.Signals) => void = (p, s) => process.kill(p, s),
|
|
92
|
+
winTreeKill: (pid: number) => boolean = windowsTreeKill,
|
|
93
|
+
): void {
|
|
94
|
+
const { pid } = child;
|
|
95
|
+
// pid > 1 guards against signalling the whole current group (-0) or init (-1).
|
|
96
|
+
if (pid && pid > 1) {
|
|
97
|
+
if (platform !== 'win32') {
|
|
98
|
+
try {
|
|
99
|
+
killFn(-pid, signal);
|
|
100
|
+
return;
|
|
101
|
+
} catch {
|
|
102
|
+
// Group already gone or signal failed — fall through to a direct kill.
|
|
103
|
+
}
|
|
104
|
+
} else if (winTreeKill(pid)) {
|
|
105
|
+
// taskkill walked the PID tree and reaped the Vite grandchild.
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
child.kill(signal);
|
|
111
|
+
} catch {
|
|
112
|
+
// Process already exited; nothing to do.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Child surface {@link terminateProcessTree} needs: a tree to kill plus exit state to await. */
|
|
117
|
+
export interface AwaitableChild extends KillableProcess {
|
|
118
|
+
exitCode: number | null;
|
|
119
|
+
signalCode: NodeJS.Signals | null;
|
|
120
|
+
once(event: 'exit', listener: () => void): unknown;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
124
|
+
new Promise((res) => {
|
|
125
|
+
setTimeout(res, ms).unref?.();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Grace (ms) we wait for the child's `exit` event *after* SIGKILL before giving
|
|
130
|
+
* up and reporting its last-known exit state. Deliberately shorter than — and
|
|
131
|
+
* intentionally decoupled from — the injectable SIGTERM `graceMs`: SIGKILL
|
|
132
|
+
* cannot be caught, blocked, or handled, so the child is already being
|
|
133
|
+
* force-terminated; we only need a brief beat to observe the `exit` event, not a
|
|
134
|
+
* full, tunable shutdown window. Fixed (not a parameter) because no caller needs
|
|
135
|
+
* to tune it — the injected `sleep` is the test seam.
|
|
136
|
+
*/
|
|
137
|
+
export const KILL_GRACE_MS = 500;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Probe whether a detached process *group* still has at least one live member,
|
|
141
|
+
* **without signalling it**. Used to scope the post-exit group SIGKILL in
|
|
142
|
+
* {@link terminateProcessTree} to the only window where the `-pid` group signal
|
|
143
|
+
* is PID-reuse-safe.
|
|
144
|
+
*
|
|
145
|
+
* The hazard: {@link killFrontendTree}'s POSIX reap is `process.kill(-pid, …)`,
|
|
146
|
+
* which targets the process group whose gid is `pid`. That is safe only while a
|
|
147
|
+
* group member is still alive — a survivor keeps the kernel from recycling
|
|
148
|
+
* `pid` as a brand-new (unrelated) group leader. Once the whole group has
|
|
149
|
+
* drained, `pid` is eligible for reuse and a blind `-pid` kill could land on an
|
|
150
|
+
* unrelated group. So before a *post-exit* reap we probe here and skip when the
|
|
151
|
+
* group has already drained (there is then nothing of ours left to reap).
|
|
152
|
+
*
|
|
153
|
+
* - **POSIX**: `kill(-pid, 0)` sends no signal — it only checks the group
|
|
154
|
+
* exists and is signallable. Success or `EPERM` (exists but owned by another
|
|
155
|
+
* user) ⇒ alive. `ESRCH` (or anything else) ⇒ treat as drained.
|
|
156
|
+
* - **Windows**: there are no process groups and the reap path
|
|
157
|
+
* (`taskkill /T /F /PID`) walks the live PID tree, so there is no `-pid`
|
|
158
|
+
* recycle hazard — always allow the reap (`true`).
|
|
159
|
+
*
|
|
160
|
+
* Never throws. `platform`/`kill` are injected for tests.
|
|
161
|
+
*/
|
|
162
|
+
export function isProcessGroupAlive(
|
|
163
|
+
pid: number,
|
|
164
|
+
platform: NodeJS.Platform = process.platform,
|
|
165
|
+
kill: (pid: number, signal: number) => void = (p, s) => process.kill(p, s),
|
|
166
|
+
): boolean {
|
|
167
|
+
if (platform === 'win32') return true;
|
|
168
|
+
try {
|
|
169
|
+
kill(-pid, 0);
|
|
170
|
+
return true;
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return (e as NodeJS.ErrnoException).code === 'EPERM';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Terminate a child process *tree* and wait — bounded — for the child to exit,
|
|
178
|
+
* escalating SIGTERM → SIGKILL. Reuses {@link killFrontendTree} so every
|
|
179
|
+
* entrypoint reaps the same way (POSIX process-group kill / Windows `taskkill`)
|
|
180
|
+
* instead of hand-rolling its own group kill.
|
|
181
|
+
*
|
|
182
|
+
* Post-exit policy: if the child has *already* exited, a detached grandchild may
|
|
183
|
+
* still be orphaned (still holding a port), so we issue one best-effort group
|
|
184
|
+
* SIGKILL to reap it — but ONLY when the group still has a live member
|
|
185
|
+
* ({@link isProcessGroupAlive}). When the whole group has already drained (the
|
|
186
|
+
* common healthy shutdown — Vite was already gone), `pid` is eligible for
|
|
187
|
+
* recycling and a blind `-pid` signal could hit an unrelated, newly created
|
|
188
|
+
* group; since there is also nothing of ours left to reap, we skip the kill.
|
|
189
|
+
* See the dev server's "POST-EXIT GROUP-KILL POLICY" for the full rationale and
|
|
190
|
+
* the accepted residual (the synchronous probe→kill window). Otherwise we
|
|
191
|
+
* SIGTERM the tree, wait up to `graceMs` for a clean exit, then SIGKILL the tree
|
|
192
|
+
* and wait a short grace.
|
|
193
|
+
*
|
|
194
|
+
* Return value — IMPORTANT: the boolean reflects only the **direct child's**
|
|
195
|
+
* exit state (its `exitCode`/`signalCode`), NOT whole-group teardown or port
|
|
196
|
+
* release. On POSIX the SIGKILL is delivered to the whole group (`-pid`), but a
|
|
197
|
+
* surviving *detached grandchild* can outlive the awaited child and keep holding
|
|
198
|
+
* a port even after this resolves `true`. So `true` means only "the child we
|
|
199
|
+
* awaited has exited (or was already gone)" and `false` means "it was still
|
|
200
|
+
* alive when the budget elapsed" — neither guarantees the port is free. Callers
|
|
201
|
+
* that need a freed port MUST follow this with a bounded port-free wait (see
|
|
202
|
+
* `waitForPortFree` in dev-server.ts, which the dev-server child's own SIGTERM
|
|
203
|
+
* handler runs). Dependencies are injected for tests.
|
|
204
|
+
*/
|
|
205
|
+
export async function terminateProcessTree(
|
|
206
|
+
child: AwaitableChild,
|
|
207
|
+
graceMs = 2000,
|
|
208
|
+
killTree: (c: KillableProcess, signal: NodeJS.Signals) => void = killFrontendTree,
|
|
209
|
+
sleep: (ms: number) => Promise<void> = defaultSleep,
|
|
210
|
+
isGroupAlive: (pid: number) => boolean = isProcessGroupAlive,
|
|
211
|
+
): Promise<boolean> {
|
|
212
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
213
|
+
// ── POST-EXIT GROUP-KILL (scoped) ──────────────────────────────────────
|
|
214
|
+
// The direct child has already exited, but a detached *grandchild* (e.g. an
|
|
215
|
+
// orphaned Vite) may still be alive in its process group, still holding a
|
|
216
|
+
// port — reap it with one best-effort group SIGKILL.
|
|
217
|
+
//
|
|
218
|
+
// SCOPING: only reap when the group still has a live member. killFrontendTree's
|
|
219
|
+
// `-pid` group signal is PID-reuse-safe ONLY while a member keeps `pid`
|
|
220
|
+
// reserved as the group id; once the whole group has drained `pid` can be
|
|
221
|
+
// recycled and a blind `process.kill(-pid)` could hit an unrelated group. So
|
|
222
|
+
// we probe first (isProcessGroupAlive; POSIX signal 0) and skip when already
|
|
223
|
+
// drained — there is then nothing of ours to reap. The residual synchronous
|
|
224
|
+
// probe→kill window is the accepted trade-off documented in dev-server.ts
|
|
225
|
+
// "POST-EXIT GROUP-KILL POLICY", cross-referenced here so the risk is
|
|
226
|
+
// discoverable at this shared primitive.
|
|
227
|
+
const { pid } = child;
|
|
228
|
+
if (pid && pid > 1 && isGroupAlive(pid)) {
|
|
229
|
+
killTree(child, 'SIGKILL');
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
const exited = new Promise<void>((res) => child.once('exit', () => res()));
|
|
234
|
+
killTree(child, 'SIGTERM');
|
|
235
|
+
const exitedCleanly = await Promise.race([
|
|
236
|
+
exited.then(() => true),
|
|
237
|
+
sleep(graceMs).then(() => false),
|
|
238
|
+
]);
|
|
239
|
+
if (exitedCleanly) return true;
|
|
240
|
+
killTree(child, 'SIGKILL');
|
|
241
|
+
// Shorter, fixed grace after SIGKILL (vs. the injectable SIGTERM graceMs):
|
|
242
|
+
// SIGKILL is uncatchable, so we only need a brief beat to observe `exit`.
|
|
243
|
+
await Promise.race([exited, sleep(KILL_GRACE_MS)]);
|
|
244
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
245
|
+
}
|
package/src/scripts/sandbox.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { trackCommand } from '../telemetry/trackCommand.js';
|
|
|
11
11
|
import { buildAndSendEvent } from '../telemetry/client.js';
|
|
12
12
|
import { getCdkTelemetryEnv } from './cdk-telemetry-env.js';
|
|
13
13
|
import { runSync, spawnCommand } from './run-command.js';
|
|
14
|
+
import { terminateProcessTree } from './process-tree.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Import the backend definition to populate the Scope BB registry.
|
|
@@ -50,7 +51,9 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
50
51
|
|
|
51
52
|
// Provision connection string to SSM SecureString.
|
|
52
53
|
// On first deploy, creates the parameter. On subsequent deploys, updates if changed.
|
|
53
|
-
|
|
54
|
+
// projectRoot is process.cwd() — the same value passed to cdk as --context
|
|
55
|
+
// projectRoot below — so the written name matches the name resolved at synth.
|
|
56
|
+
const secrets = await ensureSecrets('sandbox', process.cwd());
|
|
54
57
|
if (secrets.created.length > 0) {
|
|
55
58
|
console.log(`🔐 Created secrets: ${secrets.created.join(', ')}`);
|
|
56
59
|
}
|
|
@@ -74,6 +77,13 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
74
77
|
"npm",
|
|
75
78
|
[
|
|
76
79
|
"exec", "cdk", "--", "deploy",
|
|
80
|
+
// `--all`: an app that uses Lambda@Edge (e.g. a Next.js route with
|
|
81
|
+
// `export const runtime = 'edge'`) synthesizes a SECOND stack
|
|
82
|
+
// (`edge-lambda-stack-*`, region us-east-1) in addition to the main
|
|
83
|
+
// hosting stack. Without `--all`, CDK refuses with "specify which
|
|
84
|
+
// stacks to use". Deploying every stack in a sandbox app is the
|
|
85
|
+
// intended behavior, so select them all.
|
|
86
|
+
"--all",
|
|
77
87
|
"--require-approval", "never",
|
|
78
88
|
"--outputs-file", `${outDir}/outputs.json`,
|
|
79
89
|
"--context", `projectRoot=${process.cwd()}`,
|
|
@@ -147,6 +157,12 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
147
157
|
`--app`, `npm exec tsx -- -C cdk ${backendPath}`
|
|
148
158
|
], {
|
|
149
159
|
stdio: ["ignore", "pipe", "pipe"],
|
|
160
|
+
// Own process group on POSIX so cleanup can reap the whole `cdk watch` tree
|
|
161
|
+
// (npx → cdk → node) via terminateProcessTree, not just the npx shell — a
|
|
162
|
+
// bare kill() would orphan the real cdk-watch node process, the same
|
|
163
|
+
// shell-only-kill leak this PR eliminates for the dev server. Windows has no
|
|
164
|
+
// groups; terminateProcessTree reaps the tree via taskkill.
|
|
165
|
+
detached: process.platform !== 'win32',
|
|
150
166
|
env: { ...process.env, NODE_OPTIONS: "--conditions=cdk", ...getCdkTelemetryEnv('sandbox') },
|
|
151
167
|
});
|
|
152
168
|
|
|
@@ -166,6 +182,12 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
166
182
|
const devServer = spawnCommand(cmd, args, {
|
|
167
183
|
stdio: "inherit",
|
|
168
184
|
shell: true,
|
|
185
|
+
// Own process group on POSIX so cleanup can signal the whole dev-server
|
|
186
|
+
// tree (shell → tsx → node). The node dev server then runs its own SIGTERM
|
|
187
|
+
// handler — the ~2s terminateFrontend drain that reaps the *detached* Vite
|
|
188
|
+
// great-grandchild — which a bare `devServer.kill()` (the shell only) never
|
|
189
|
+
// triggers. Windows has no groups; terminateProcessTree reaps via taskkill.
|
|
190
|
+
detached: process.platform !== 'win32',
|
|
169
191
|
env: {
|
|
170
192
|
...process.env,
|
|
171
193
|
NODE_OPTIONS: '',
|
|
@@ -173,12 +195,36 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
173
195
|
},
|
|
174
196
|
});
|
|
175
197
|
|
|
176
|
-
|
|
198
|
+
let cleaningUp = false;
|
|
199
|
+
const cleanup = async () => {
|
|
200
|
+
if (cleaningUp) return; // idempotent — a second signal must not re-enter
|
|
201
|
+
cleaningUp = true;
|
|
177
202
|
console.log("\n\n🛑 Stopping local processes...");
|
|
178
203
|
console.log(" (AWS resources are still running)");
|
|
179
204
|
console.log("\n To destroy AWS resources, run: npm run sandbox:destroy\n");
|
|
180
|
-
|
|
181
|
-
|
|
205
|
+
// Reap BOTH child trees the way the dev server reaps Vite — a process-group
|
|
206
|
+
// SIGTERM→SIGKILL via the shared terminateProcessTree — instead of a bare
|
|
207
|
+
// kill() that signals only the npx/shell parent and orphans the real
|
|
208
|
+
// grandchild (cdk-watch's node, or the dev server's detached Vite). Run them
|
|
209
|
+
// concurrently so the cdk-watch teardown doesn't serialize on top of the dev
|
|
210
|
+
// server's longer drain.
|
|
211
|
+
//
|
|
212
|
+
// Only the dev-server child owns the `:3100` port-free wait: its own SIGTERM
|
|
213
|
+
// handler runs terminateFrontend (a ~2s drain that reaps the detached Vite
|
|
214
|
+
// great-grandchild AND polls until the port frees), so we give it the longer
|
|
215
|
+
// 6s budget (> that ~2s drain) — a hung dev server still escalates to a tree
|
|
216
|
+
// SIGKILL and we exit regardless, so shutdown can never wedge. cdk watch
|
|
217
|
+
// holds no local port, so a bounded tree-kill is all it needs.
|
|
218
|
+
//
|
|
219
|
+
// That a group SIGTERM (terminateProcessTree → killFrontendTree's
|
|
220
|
+
// `process.kill(-pid, 'SIGTERM')`) actually reaches the *nested* node dev
|
|
221
|
+
// server and runs its own SIGTERM handler — the load-bearing assumption of
|
|
222
|
+
// the 6s budget above — is verified by the "group SIGTERM reaches a nested
|
|
223
|
+
// node child" integration test in dev-server-supervisor.test.ts.
|
|
224
|
+
await Promise.all([
|
|
225
|
+
terminateProcessTree(cdkWatch, 2000),
|
|
226
|
+
terminateProcessTree(devServer, 6000),
|
|
227
|
+
]);
|
|
182
228
|
process.exit(0);
|
|
183
229
|
};
|
|
184
230
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, it, afterEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
import { getStackId, getSandboxId, getStackName } from './stack-id.js';
|
|
11
|
+
|
|
12
|
+
describe('getStackId', () => {
|
|
13
|
+
let tmpDir: string;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reads stackId from .blocks/config.json', () => {
|
|
20
|
+
tmpDir = join(tmpdir(), `stack-id-test-${Date.now()}`);
|
|
21
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
22
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-abc123' }));
|
|
23
|
+
assert.strictEqual(getStackId(tmpDir), 'test-abc123');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('throws actionable error when config is missing', () => {
|
|
27
|
+
tmpDir = join(tmpdir(), `stack-id-test-missing-${Date.now()}`);
|
|
28
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
29
|
+
assert.throws(() => getStackId(tmpDir), /\.blocks\/config\.json not found/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('throws actionable error when stackId key is missing', () => {
|
|
33
|
+
tmpDir = join(tmpdir(), `stack-id-test-nokey-${Date.now()}`);
|
|
34
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
35
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ other: 'value' }));
|
|
36
|
+
assert.throws(() => getStackId(tmpDir), /\.blocks\/config\.json not found/);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('getSandboxId', () => {
|
|
41
|
+
let tmpDir: string;
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('generates and persists a sandbox id', () => {
|
|
48
|
+
tmpDir = join(tmpdir(), `sandbox-id-test-${Date.now()}`);
|
|
49
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
50
|
+
const id = getSandboxId(tmpDir);
|
|
51
|
+
assert.match(id, /^[a-z0-9]+-[a-f0-9]{6}$/);
|
|
52
|
+
// Verify persisted
|
|
53
|
+
const stored = readFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'utf-8').trim();
|
|
54
|
+
assert.strictEqual(stored, id);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns existing id on subsequent calls', () => {
|
|
58
|
+
tmpDir = join(tmpdir(), `sandbox-id-test-idem-${Date.now()}`);
|
|
59
|
+
mkdirSync(join(tmpDir, '.blocks-sandbox'), { recursive: true });
|
|
60
|
+
writeFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'alice-abc123');
|
|
61
|
+
assert.strictEqual(getSandboxId(tmpDir), 'alice-abc123');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('getStackName', () => {
|
|
66
|
+
let tmpDir: string;
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('production is <stackId>-prod', () => {
|
|
73
|
+
tmpDir = join(tmpdir(), `stack-name-prod-${Date.now()}`);
|
|
74
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
75
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'my-app-k7x2mf' }));
|
|
76
|
+
assert.strictEqual(getStackName({ sandbox: false, projectRoot: tmpDir }), 'my-app-k7x2mf-prod');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('sandbox is <stackId>-<sandboxId>', () => {
|
|
80
|
+
tmpDir = join(tmpdir(), `stack-name-sbx-${Date.now()}`);
|
|
81
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
82
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'my-app-k7x2mf' }));
|
|
83
|
+
mkdirSync(join(tmpDir, '.blocks-sandbox'), { recursive: true });
|
|
84
|
+
writeFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'alice-0d7e1c');
|
|
85
|
+
assert.strictEqual(getStackName({ sandbox: true, projectRoot: tmpDir }), 'my-app-k7x2mf-alice-0d7e1c');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws actionable error when config is missing (fail fast, no silent fallback)', () => {
|
|
89
|
+
tmpDir = join(tmpdir(), `stack-name-missing-${Date.now()}`);
|
|
90
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
91
|
+
assert.throws(() => getStackName({ sandbox: false, projectRoot: tmpDir }), /\.blocks\/config\.json not found/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('getStackName sandbox id (get-or-create)', () => {
|
|
96
|
+
let tmpDir: string;
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('creates and persists sandbox-id.txt on first call when missing', () => {
|
|
103
|
+
tmpDir = join(tmpdir(), `stack-name-getorcreate-${Date.now()}`);
|
|
104
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
105
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-app' }));
|
|
106
|
+
// No .blocks-sandbox dir yet — getStackName creates the id rather than throwing.
|
|
107
|
+
const name = getStackName({ sandbox: true, projectRoot: tmpDir });
|
|
108
|
+
assert.match(name, /^test-app-[a-z0-9]+-[a-f0-9]{6}$/);
|
|
109
|
+
// Persisted so later callers/processes resolve the identical name.
|
|
110
|
+
const stored = readFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'utf-8').trim();
|
|
111
|
+
assert.strictEqual(name, `test-app-${stored}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('reuses the same id across calls', () => {
|
|
115
|
+
tmpDir = join(tmpdir(), `stack-name-getorcreate-idem-${Date.now()}`);
|
|
116
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
117
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-app' }));
|
|
118
|
+
assert.strictEqual(
|
|
119
|
+
getStackName({ sandbox: true, projectRoot: tmpDir }),
|
|
120
|
+
getStackName({ sandbox: true, projectRoot: tmpDir }),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
interface BlocksConfig {
|
|
10
|
+
stackId?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function randomSuffix(length: number): string {
|
|
15
|
+
return randomBytes(length).toString('hex').slice(0, length);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the stackId from `.blocks/config.json` in the project root.
|
|
20
|
+
* This is the stable project identifier used as the base for CloudFormation stack names.
|
|
21
|
+
*/
|
|
22
|
+
export function getStackId(projectRoot?: string): string {
|
|
23
|
+
const root = projectRoot || process.cwd();
|
|
24
|
+
const configPath = join(root, '.blocks', 'config.json');
|
|
25
|
+
try {
|
|
26
|
+
const config: BlocksConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
27
|
+
if (!config.stackId) throw new Error('missing key');
|
|
28
|
+
return config.stackId;
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`.blocks/config.json not found or missing stackId — it is created by create-blocks-app and should be committed. ` +
|
|
32
|
+
`To fix manually, create ${configPath} with: { "stackId": "<your-app-name>" }`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get or create a per-machine sandbox identifier.
|
|
39
|
+
* Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
|
|
40
|
+
* Format: `<username(8)>-<random(6)>` — identifies the developer's sandbox.
|
|
41
|
+
*
|
|
42
|
+
* Get-or-create (lazy init): returns the existing id, or generates and persists
|
|
43
|
+
* one on first call. The file is the shared sync point — once written, every
|
|
44
|
+
* later caller and every process reads the same id, so the secret writer
|
|
45
|
+
* (`ensureSecrets`) and synth derive identical names.
|
|
46
|
+
*/
|
|
47
|
+
export function getSandboxId(projectRoot?: string): string {
|
|
48
|
+
const root = projectRoot || process.cwd();
|
|
49
|
+
const filePath = join(root, '.blocks-sandbox', 'sandbox-id.txt');
|
|
50
|
+
if (existsSync(filePath)) return readFileSync(filePath, 'utf-8').trim();
|
|
51
|
+
const dir = dirname(filePath);
|
|
52
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
53
|
+
const username = getUsername().toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8) || 'dev';
|
|
54
|
+
const random = randomSuffix(6);
|
|
55
|
+
const id = `${username}-${random}`;
|
|
56
|
+
writeFileSync(filePath, id);
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The full CloudFormation stack name for a deployment.
|
|
62
|
+
*
|
|
63
|
+
* Single source of truth for the stack-name scheme (D-012): production is
|
|
64
|
+
* `<stackId>-prod`; a sandbox is `<stackId>-<sandboxId>`. The CDK templates name
|
|
65
|
+
* the stack with this function, and the external-DB connection-string parameter
|
|
66
|
+
* name (`dbConnectionParameterName`) is derived from it — so a deployed stack and
|
|
67
|
+
* the parameter holding its database credentials can never use divergent names.
|
|
68
|
+
*
|
|
69
|
+
* This function reads committed config (`.blocks/config.json`, throws if absent
|
|
70
|
+
* — D-012) and resolves the sandbox id via {@link getSandboxId} (get-or-create):
|
|
71
|
+
* the first caller materializes `.blocks-sandbox/sandbox-id.txt`, every later
|
|
72
|
+
* caller reads the same value. Because that file persists and is shared across
|
|
73
|
+
* processes, the secret writer (`ensureSecrets`) and synth resolve identical
|
|
74
|
+
* names. Production does not use the sandbox id.
|
|
75
|
+
*/
|
|
76
|
+
export function getStackName(opts: { sandbox: boolean; projectRoot?: string }): string {
|
|
77
|
+
const base = getStackId(opts.projectRoot);
|
|
78
|
+
return opts.sandbox ? `${base}-${getSandboxId(opts.projectRoot)}` : `${base}-prod`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getUsername(): string {
|
|
82
|
+
try {
|
|
83
|
+
return execSync('git config user.name', { encoding: 'utf-8' }).trim();
|
|
84
|
+
} catch {
|
|
85
|
+
return process.env.USER || process.env.USERNAME || 'user';
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/telemetry/client.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { request as httpsRequest } from 'node:https';
|
|
2
|
-
import { request as httpRequest } from 'node:http';
|
|
3
1
|
import { existsSync, readFileSync, mkdirSync, openSync, writeSync, closeSync, writeFileSync, constants } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
4
3
|
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { debuglog } from 'node:util';
|
|
6
6
|
import { CORE_VERSION } from '../version.js';
|
|
7
7
|
import { Scope } from '../common/index.js';
|
|
@@ -14,7 +14,6 @@ import type { BlocksTelemetryEvent, BuildAndSendEventOptions } from './types.js'
|
|
|
14
14
|
const debug = debuglog('blocks-telemetry');
|
|
15
15
|
|
|
16
16
|
const DEFAULT_ENDPOINT = 'https://blocks-telemetry.us-east-1.api.aws/metrics';
|
|
17
|
-
const TIMEOUT_MS = 500;
|
|
18
17
|
const TELEMETRY_VERSION = '1.0.0';
|
|
19
18
|
|
|
20
19
|
function getEndpoint(): string {
|
|
@@ -179,7 +178,9 @@ export function buildAndSendEvent(opts: BuildAndSendEventOptions): void {
|
|
|
179
178
|
/**
|
|
180
179
|
* Send a pre-built telemetry event to the collection endpoint.
|
|
181
180
|
*
|
|
182
|
-
*
|
|
181
|
+
* Spawns a detached subprocess that performs the HTTPS POST independently of
|
|
182
|
+
* the parent CLI process. This ensures the request completes even when the
|
|
183
|
+
* parent exits on failure paths before an in-process request would flush.
|
|
183
184
|
* Debug output available via `NODE_DEBUG=blocks-telemetry`.
|
|
184
185
|
*/
|
|
185
186
|
export function sendEvent(event: BlocksTelemetryEvent): void {
|
|
@@ -194,32 +195,23 @@ export function sendEvent(event: BlocksTelemetryEvent): void {
|
|
|
194
195
|
|
|
195
196
|
debug('sending event to %s (%d bytes)', endpoint, Buffer.byteLength(payload));
|
|
196
197
|
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
debug('event sent (status=%d)', res.statusCode);
|
|
215
|
-
res.resume();
|
|
216
|
-
},
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
req.on('error', (err) => { debug('send failed: %s', err.message); });
|
|
220
|
-
req.on('timeout', () => { debug('send timed out'); req.destroy(); });
|
|
221
|
-
req.write(payload);
|
|
222
|
-
req.end();
|
|
198
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
199
|
+
const workerPath = path.join(dir, 'telemetry-send-worker.js');
|
|
200
|
+
const child = spawn(process.execPath, [workerPath, endpoint], {
|
|
201
|
+
detached: true,
|
|
202
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
203
|
+
// Clear NODE_OPTIONS so inherited flags (e.g. --conditions=cdk) don't interfere
|
|
204
|
+
env: { ...process.env, NODE_OPTIONS: '' },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
child.stdin!.on('error', (err) => { debug('stdin write failed: %s', err.message); });
|
|
208
|
+
// Payload is small (<1KB JSON) so it fits in the kernel pipe buffer (~64KB)
|
|
209
|
+
// and survives the parent closing its fd on exit.
|
|
210
|
+
child.stdin!.write(payload);
|
|
211
|
+
child.stdin!.end();
|
|
212
|
+
child.on('error', (err) => { debug('spawn failed: %s', err.message); });
|
|
213
|
+
child.unref();
|
|
214
|
+
debug('spawned telemetry subprocess (pid=%d)', child.pid);
|
|
223
215
|
} catch {
|
|
224
216
|
// Telemetry must never throw or affect the user's command
|
|
225
217
|
}
|