@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.
Files changed (91) hide show
  1. package/dist/cdk/index.d.ts +1 -1
  2. package/dist/cdk/index.d.ts.map +1 -1
  3. package/dist/cdk/index.js +1 -1
  4. package/dist/client/index.d.ts +1 -1
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/index.js +1 -1
  7. package/dist/db-naming.d.ts +17 -5
  8. package/dist/db-naming.d.ts.map +1 -1
  9. package/dist/db-naming.js +18 -6
  10. package/dist/db-naming.test.js +44 -3
  11. package/dist/errors.d.ts +28 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +27 -1
  14. package/dist/errors.test.d.ts +2 -0
  15. package/dist/errors.test.d.ts.map +1 -0
  16. package/dist/errors.test.js +47 -0
  17. package/dist/hosting.d.ts +71 -1
  18. package/dist/hosting.d.ts.map +1 -1
  19. package/dist/hosting.js +47 -8
  20. package/dist/hosting.test.js +60 -0
  21. package/dist/index.cdk.d.ts +1 -1
  22. package/dist/index.cdk.d.ts.map +1 -1
  23. package/dist/index.cdk.js +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/scripts/deploy.d.ts.map +1 -1
  28. package/dist/scripts/deploy.js +4 -2
  29. package/dist/scripts/dev-server-supervisor.test.d.ts +2 -0
  30. package/dist/scripts/dev-server-supervisor.test.d.ts.map +1 -0
  31. package/dist/scripts/dev-server-supervisor.test.js +551 -0
  32. package/dist/scripts/dev-server.d.ts +73 -0
  33. package/dist/scripts/dev-server.d.ts.map +1 -1
  34. package/dist/scripts/dev-server.js +279 -29
  35. package/dist/scripts/ensure-secrets.d.ts +5 -2
  36. package/dist/scripts/ensure-secrets.d.ts.map +1 -1
  37. package/dist/scripts/ensure-secrets.js +14 -6
  38. package/dist/scripts/external-migrations-step.d.ts.map +1 -1
  39. package/dist/scripts/external-migrations-step.js +5 -1
  40. package/dist/scripts/index.d.ts +1 -0
  41. package/dist/scripts/index.d.ts.map +1 -1
  42. package/dist/scripts/index.js +1 -0
  43. package/dist/scripts/process-tree.d.ts +126 -0
  44. package/dist/scripts/process-tree.d.ts.map +1 -0
  45. package/dist/scripts/process-tree.js +198 -0
  46. package/dist/scripts/sandbox.d.ts.map +1 -1
  47. package/dist/scripts/sandbox.js +51 -4
  48. package/dist/scripts/stack-id.d.ts +37 -0
  49. package/dist/scripts/stack-id.d.ts.map +1 -0
  50. package/dist/scripts/stack-id.js +79 -0
  51. package/dist/scripts/stack-id.test.d.ts +2 -0
  52. package/dist/scripts/stack-id.test.d.ts.map +1 -0
  53. package/dist/scripts/stack-id.test.js +104 -0
  54. package/dist/telemetry/client.d.ts +3 -1
  55. package/dist/telemetry/client.d.ts.map +1 -1
  56. package/dist/telemetry/client.js +20 -24
  57. package/dist/telemetry/telemetry-send-worker.d.ts +2 -0
  58. package/dist/telemetry/telemetry-send-worker.d.ts.map +1 -0
  59. package/dist/telemetry/telemetry-send-worker.js +58 -0
  60. package/dist/telemetry/telemetry.test.js +77 -1
  61. package/dist/telemetry/trackCommand.d.ts +1 -1
  62. package/dist/telemetry/trackCommand.js +3 -3
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.d.ts.map +1 -1
  65. package/dist/version.js +1 -1
  66. package/package.json +1 -1
  67. package/src/cdk/index.ts +1 -1
  68. package/src/client/index.ts +1 -1
  69. package/src/db-naming.test.ts +50 -5
  70. package/src/db-naming.ts +18 -6
  71. package/src/errors.test.ts +55 -0
  72. package/src/errors.ts +32 -1
  73. package/src/hosting.test.ts +79 -0
  74. package/src/hosting.ts +127 -13
  75. package/src/index.cdk.ts +1 -1
  76. package/src/index.ts +1 -1
  77. package/src/scripts/deploy.ts +4 -2
  78. package/src/scripts/dev-server-supervisor.test.ts +621 -0
  79. package/src/scripts/dev-server.ts +316 -27
  80. package/src/scripts/ensure-secrets.ts +17 -6
  81. package/src/scripts/external-migrations-step.ts +5 -1
  82. package/src/scripts/index.ts +1 -0
  83. package/src/scripts/process-tree.ts +245 -0
  84. package/src/scripts/sandbox.ts +50 -4
  85. package/src/scripts/stack-id.test.ts +123 -0
  86. package/src/scripts/stack-id.ts +87 -0
  87. package/src/telemetry/client.ts +22 -30
  88. package/src/telemetry/telemetry-send-worker.ts +60 -0
  89. package/src/telemetry/telemetry.test.ts +91 -1
  90. package/src/telemetry/trackCommand.ts +3 -3
  91. package/src/version.ts +1 -1
@@ -0,0 +1,126 @@
1
+ /** Minimal child-process surface needed to terminate a frontend dev server. */
2
+ export interface KillableProcess {
3
+ pid?: number;
4
+ kill(signal?: NodeJS.Signals | number): boolean;
5
+ }
6
+ /** Subset of {@link import('node:child_process').SpawnSyncReturns} that {@link windowsTreeKill} inspects. */
7
+ interface TreeKillResult {
8
+ status: number | null;
9
+ error?: Error;
10
+ }
11
+ /**
12
+ * Force-kill an entire process tree on Windows via `taskkill /T /F /PID <pid>`.
13
+ *
14
+ * Windows has no POSIX process groups, so a bare `child.kill()` only signals the
15
+ * spawned shell and orphans the real dev server (the Vite grandchild), which
16
+ * keeps holding `:3100` — the very wedge the POSIX process-group kill fixes.
17
+ * `taskkill /T` walks the live child tree by PID and terminates every
18
+ * descendant; `/F` is required because Windows cannot deliver a graceful
19
+ * shutdown to a non-console subtree anyway (Node maps SIGTERM/SIGKILL to
20
+ * `TerminateProcess`).
21
+ *
22
+ * Returns `true` only when `taskkill` ran AND reported the tree handled — exit
23
+ * `0` (reaped the tree) or `128` (`"process not found"`, i.e. already gone).
24
+ * Returns `false` when the command could not be spawned at all (e.g. not on
25
+ * `PATH`) OR when it ran but returned any other status (e.g. `1` = access
26
+ * denied): such a run did NOT reap the tree, so the caller must fall back to a
27
+ * direct `child.kill` rather than treat the leak as handled. (`child.kill`
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.
31
+ */
32
+ export declare function windowsTreeKill(pid: number, runner?: (command: string, args: readonly string[]) => TreeKillResult): boolean;
33
+ /**
34
+ * Terminate a process spawned with `shell: true`, including its descendants, on
35
+ * every platform.
36
+ *
37
+ * Under a shell the real dev server (e.g. Vite) is a **grandchild**: the direct
38
+ * child is the shell, so signalling only the shell (`child.kill`) orphans the
39
+ * grandchild, which keeps holding its port (`:3100`) and wedges the next
40
+ * restart.
41
+ *
42
+ * - **POSIX**: the process is spawned `detached` (its own process group,
43
+ * pgid === child.pid), so we signal the whole group with
44
+ * `process.kill(-pid, signal)` and every descendant dies, freeing the port.
45
+ * - **Windows**: there are no process groups, so we reap the tree with
46
+ * `taskkill /T /F /PID <pid>` (see {@link windowsTreeKill}), which walks the
47
+ * child tree by PID. A bare `child.kill` would leave the Vite grandchild
48
+ * bound to `:3100`, reproducing the POSIX wedge.
49
+ *
50
+ * Best-effort and never throws: a missing/invalid pid, an already-dead group
51
+ * (ESRCH), a failed group signal, or an unavailable `taskkill` all degrade to a
52
+ * direct `child.kill`.
53
+ */
54
+ export declare function killFrontendTree(child: KillableProcess, signal?: NodeJS.Signals, platform?: NodeJS.Platform, killFn?: (pid: number, signal: NodeJS.Signals) => void, winTreeKill?: (pid: number) => boolean): void;
55
+ /** Child surface {@link terminateProcessTree} needs: a tree to kill plus exit state to await. */
56
+ export interface AwaitableChild extends KillableProcess {
57
+ exitCode: number | null;
58
+ signalCode: NodeJS.Signals | null;
59
+ once(event: 'exit', listener: () => void): unknown;
60
+ }
61
+ /**
62
+ * Grace (ms) we wait for the child's `exit` event *after* SIGKILL before giving
63
+ * up and reporting its last-known exit state. Deliberately shorter than — and
64
+ * intentionally decoupled from — the injectable SIGTERM `graceMs`: SIGKILL
65
+ * cannot be caught, blocked, or handled, so the child is already being
66
+ * force-terminated; we only need a brief beat to observe the `exit` event, not a
67
+ * full, tunable shutdown window. Fixed (not a parameter) because no caller needs
68
+ * to tune it — the injected `sleep` is the test seam.
69
+ */
70
+ export declare const KILL_GRACE_MS = 500;
71
+ /**
72
+ * Probe whether a detached process *group* still has at least one live member,
73
+ * **without signalling it**. Used to scope the post-exit group SIGKILL in
74
+ * {@link terminateProcessTree} to the only window where the `-pid` group signal
75
+ * is PID-reuse-safe.
76
+ *
77
+ * The hazard: {@link killFrontendTree}'s POSIX reap is `process.kill(-pid, …)`,
78
+ * which targets the process group whose gid is `pid`. That is safe only while a
79
+ * group member is still alive — a survivor keeps the kernel from recycling
80
+ * `pid` as a brand-new (unrelated) group leader. Once the whole group has
81
+ * drained, `pid` is eligible for reuse and a blind `-pid` kill could land on an
82
+ * unrelated group. So before a *post-exit* reap we probe here and skip when the
83
+ * group has already drained (there is then nothing of ours left to reap).
84
+ *
85
+ * - **POSIX**: `kill(-pid, 0)` sends no signal — it only checks the group
86
+ * exists and is signallable. Success or `EPERM` (exists but owned by another
87
+ * user) ⇒ alive. `ESRCH` (or anything else) ⇒ treat as drained.
88
+ * - **Windows**: there are no process groups and the reap path
89
+ * (`taskkill /T /F /PID`) walks the live PID tree, so there is no `-pid`
90
+ * recycle hazard — always allow the reap (`true`).
91
+ *
92
+ * Never throws. `platform`/`kill` are injected for tests.
93
+ */
94
+ export declare function isProcessGroupAlive(pid: number, platform?: NodeJS.Platform, kill?: (pid: number, signal: number) => void): boolean;
95
+ /**
96
+ * Terminate a child process *tree* and wait — bounded — for the child to exit,
97
+ * escalating SIGTERM → SIGKILL. Reuses {@link killFrontendTree} so every
98
+ * entrypoint reaps the same way (POSIX process-group kill / Windows `taskkill`)
99
+ * instead of hand-rolling its own group kill.
100
+ *
101
+ * Post-exit policy: if the child has *already* exited, a detached grandchild may
102
+ * still be orphaned (still holding a port), so we issue one best-effort group
103
+ * SIGKILL to reap it — but ONLY when the group still has a live member
104
+ * ({@link isProcessGroupAlive}). When the whole group has already drained (the
105
+ * common healthy shutdown — Vite was already gone), `pid` is eligible for
106
+ * recycling and a blind `-pid` signal could hit an unrelated, newly created
107
+ * group; since there is also nothing of ours left to reap, we skip the kill.
108
+ * See the dev server's "POST-EXIT GROUP-KILL POLICY" for the full rationale and
109
+ * the accepted residual (the synchronous probe→kill window). Otherwise we
110
+ * SIGTERM the tree, wait up to `graceMs` for a clean exit, then SIGKILL the tree
111
+ * and wait a short grace.
112
+ *
113
+ * Return value — IMPORTANT: the boolean reflects only the **direct child's**
114
+ * exit state (its `exitCode`/`signalCode`), NOT whole-group teardown or port
115
+ * release. On POSIX the SIGKILL is delivered to the whole group (`-pid`), but a
116
+ * surviving *detached grandchild* can outlive the awaited child and keep holding
117
+ * a port even after this resolves `true`. So `true` means only "the child we
118
+ * awaited has exited (or was already gone)" and `false` means "it was still
119
+ * alive when the budget elapsed" — neither guarantees the port is free. Callers
120
+ * that need a freed port MUST follow this with a bounded port-free wait (see
121
+ * `waitForPortFree` in dev-server.ts, which the dev-server child's own SIGTERM
122
+ * handler runs). Dependencies are injected for tests.
123
+ */
124
+ export declare function terminateProcessTree(child: AwaitableChild, graceMs?: number, killTree?: (c: KillableProcess, signal: NodeJS.Signals) => void, sleep?: (ms: number) => Promise<void>, isGroupAlive?: (pid: number) => boolean): Promise<boolean>;
125
+ export {};
126
+ //# sourceMappingURL=process-tree.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process-tree.d.ts","sourceRoot":"","sources":["../../src/scripts/process-tree.ts"],"names":[],"mappings":"AAaA,+EAA+E;AAC/E,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;CACjD;AAED,6GAA6G;AAC7G,UAAU,cAAc;IACtB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,cACwB,GAC7E,OAAO,CAaT;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,eAAe,EACtB,MAAM,GAAE,MAAM,CAAC,OAAmB,EAClC,QAAQ,GAAE,MAAM,CAAC,QAA2B,EAC5C,MAAM,GAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,KAAK,IAAmC,EACpF,WAAW,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAyB,GACtD,IAAI,CAqBN;AAED,iGAAiG;AACjG,MAAM,WAAW,cAAe,SAAQ,eAAe;IACrD,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAClC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC;CACpD;AAOD;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,MAAM,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAM,CAAC,QAA2B,EAC5C,IAAI,GAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAmC,GACzE,OAAO,CAQT;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,cAAc,EACrB,OAAO,SAAO,EACd,QAAQ,GAAE,CAAC,CAAC,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,KAAK,IAAuB,EACjF,KAAK,GAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAgB,EACnD,YAAY,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAA6B,GAC3D,OAAO,CAAC,OAAO,CAAC,CAkClB"}
@@ -0,0 +1,198 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { spawnSync } from 'node:child_process';
4
+ /**
5
+ * Force-kill an entire process tree on Windows via `taskkill /T /F /PID <pid>`.
6
+ *
7
+ * Windows has no POSIX process groups, so a bare `child.kill()` only signals the
8
+ * spawned shell and orphans the real dev server (the Vite grandchild), which
9
+ * keeps holding `:3100` — the very wedge the POSIX process-group kill fixes.
10
+ * `taskkill /T` walks the live child tree by PID and terminates every
11
+ * descendant; `/F` is required because Windows cannot deliver a graceful
12
+ * shutdown to a non-console subtree anyway (Node maps SIGTERM/SIGKILL to
13
+ * `TerminateProcess`).
14
+ *
15
+ * Returns `true` only when `taskkill` ran AND reported the tree handled — exit
16
+ * `0` (reaped the tree) or `128` (`"process not found"`, i.e. already gone).
17
+ * Returns `false` when the command could not be spawned at all (e.g. not on
18
+ * `PATH`) OR when it ran but returned any other status (e.g. `1` = access
19
+ * denied): such a run did NOT reap the tree, so the caller must fall back to a
20
+ * direct `child.kill` rather than treat the leak as handled. (`child.kill`
21
+ * cannot reap the orphaned grandchild either, but the fallback is cheap and
22
+ * strictly correct — we never silently swallow a failed tree-kill.) Never
23
+ * throws.
24
+ */
25
+ export function windowsTreeKill(pid, runner = (command, args) => spawnSync(command, args, { stdio: 'ignore', windowsHide: true })) {
26
+ try {
27
+ const { status, error } = runner('taskkill', ['/T', '/F', '/PID', String(pid)]);
28
+ // Couldn't even spawn taskkill (e.g. not on PATH) → not handled; fall back.
29
+ if (error)
30
+ return false;
31
+ // taskkill ran: only exit 0 (reaped the tree) or 128 ("process not found",
32
+ // already gone) mean the tree is handled. Any other non-null status (e.g.
33
+ // 1 = access denied) means taskkill ran but did NOT reap the tree, so report
34
+ // not-handled and let the caller fall back to a direct child.kill.
35
+ return status === 0 || status === 128;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /**
42
+ * Terminate a process spawned with `shell: true`, including its descendants, on
43
+ * every platform.
44
+ *
45
+ * Under a shell the real dev server (e.g. Vite) is a **grandchild**: the direct
46
+ * child is the shell, so signalling only the shell (`child.kill`) orphans the
47
+ * grandchild, which keeps holding its port (`:3100`) and wedges the next
48
+ * restart.
49
+ *
50
+ * - **POSIX**: the process is spawned `detached` (its own process group,
51
+ * pgid === child.pid), so we signal the whole group with
52
+ * `process.kill(-pid, signal)` and every descendant dies, freeing the port.
53
+ * - **Windows**: there are no process groups, so we reap the tree with
54
+ * `taskkill /T /F /PID <pid>` (see {@link windowsTreeKill}), which walks the
55
+ * child tree by PID. A bare `child.kill` would leave the Vite grandchild
56
+ * bound to `:3100`, reproducing the POSIX wedge.
57
+ *
58
+ * Best-effort and never throws: a missing/invalid pid, an already-dead group
59
+ * (ESRCH), a failed group signal, or an unavailable `taskkill` all degrade to a
60
+ * direct `child.kill`.
61
+ */
62
+ export function killFrontendTree(child, signal = 'SIGTERM', platform = process.platform, killFn = (p, s) => process.kill(p, s), winTreeKill = windowsTreeKill) {
63
+ const { pid } = child;
64
+ // pid > 1 guards against signalling the whole current group (-0) or init (-1).
65
+ if (pid && pid > 1) {
66
+ if (platform !== 'win32') {
67
+ try {
68
+ killFn(-pid, signal);
69
+ return;
70
+ }
71
+ catch {
72
+ // Group already gone or signal failed — fall through to a direct kill.
73
+ }
74
+ }
75
+ else if (winTreeKill(pid)) {
76
+ // taskkill walked the PID tree and reaped the Vite grandchild.
77
+ return;
78
+ }
79
+ }
80
+ try {
81
+ child.kill(signal);
82
+ }
83
+ catch {
84
+ // Process already exited; nothing to do.
85
+ }
86
+ }
87
+ const defaultSleep = (ms) => new Promise((res) => {
88
+ setTimeout(res, ms).unref?.();
89
+ });
90
+ /**
91
+ * Grace (ms) we wait for the child's `exit` event *after* SIGKILL before giving
92
+ * up and reporting its last-known exit state. Deliberately shorter than — and
93
+ * intentionally decoupled from — the injectable SIGTERM `graceMs`: SIGKILL
94
+ * cannot be caught, blocked, or handled, so the child is already being
95
+ * force-terminated; we only need a brief beat to observe the `exit` event, not a
96
+ * full, tunable shutdown window. Fixed (not a parameter) because no caller needs
97
+ * to tune it — the injected `sleep` is the test seam.
98
+ */
99
+ export const KILL_GRACE_MS = 500;
100
+ /**
101
+ * Probe whether a detached process *group* still has at least one live member,
102
+ * **without signalling it**. Used to scope the post-exit group SIGKILL in
103
+ * {@link terminateProcessTree} to the only window where the `-pid` group signal
104
+ * is PID-reuse-safe.
105
+ *
106
+ * The hazard: {@link killFrontendTree}'s POSIX reap is `process.kill(-pid, …)`,
107
+ * which targets the process group whose gid is `pid`. That is safe only while a
108
+ * group member is still alive — a survivor keeps the kernel from recycling
109
+ * `pid` as a brand-new (unrelated) group leader. Once the whole group has
110
+ * drained, `pid` is eligible for reuse and a blind `-pid` kill could land on an
111
+ * unrelated group. So before a *post-exit* reap we probe here and skip when the
112
+ * group has already drained (there is then nothing of ours left to reap).
113
+ *
114
+ * - **POSIX**: `kill(-pid, 0)` sends no signal — it only checks the group
115
+ * exists and is signallable. Success or `EPERM` (exists but owned by another
116
+ * user) ⇒ alive. `ESRCH` (or anything else) ⇒ treat as drained.
117
+ * - **Windows**: there are no process groups and the reap path
118
+ * (`taskkill /T /F /PID`) walks the live PID tree, so there is no `-pid`
119
+ * recycle hazard — always allow the reap (`true`).
120
+ *
121
+ * Never throws. `platform`/`kill` are injected for tests.
122
+ */
123
+ export function isProcessGroupAlive(pid, platform = process.platform, kill = (p, s) => process.kill(p, s)) {
124
+ if (platform === 'win32')
125
+ return true;
126
+ try {
127
+ kill(-pid, 0);
128
+ return true;
129
+ }
130
+ catch (e) {
131
+ return e.code === 'EPERM';
132
+ }
133
+ }
134
+ /**
135
+ * Terminate a child process *tree* and wait — bounded — for the child to exit,
136
+ * escalating SIGTERM → SIGKILL. Reuses {@link killFrontendTree} so every
137
+ * entrypoint reaps the same way (POSIX process-group kill / Windows `taskkill`)
138
+ * instead of hand-rolling its own group kill.
139
+ *
140
+ * Post-exit policy: if the child has *already* exited, a detached grandchild may
141
+ * still be orphaned (still holding a port), so we issue one best-effort group
142
+ * SIGKILL to reap it — but ONLY when the group still has a live member
143
+ * ({@link isProcessGroupAlive}). When the whole group has already drained (the
144
+ * common healthy shutdown — Vite was already gone), `pid` is eligible for
145
+ * recycling and a blind `-pid` signal could hit an unrelated, newly created
146
+ * group; since there is also nothing of ours left to reap, we skip the kill.
147
+ * See the dev server's "POST-EXIT GROUP-KILL POLICY" for the full rationale and
148
+ * the accepted residual (the synchronous probe→kill window). Otherwise we
149
+ * SIGTERM the tree, wait up to `graceMs` for a clean exit, then SIGKILL the tree
150
+ * and wait a short grace.
151
+ *
152
+ * Return value — IMPORTANT: the boolean reflects only the **direct child's**
153
+ * exit state (its `exitCode`/`signalCode`), NOT whole-group teardown or port
154
+ * release. On POSIX the SIGKILL is delivered to the whole group (`-pid`), but a
155
+ * surviving *detached grandchild* can outlive the awaited child and keep holding
156
+ * a port even after this resolves `true`. So `true` means only "the child we
157
+ * awaited has exited (or was already gone)" and `false` means "it was still
158
+ * alive when the budget elapsed" — neither guarantees the port is free. Callers
159
+ * that need a freed port MUST follow this with a bounded port-free wait (see
160
+ * `waitForPortFree` in dev-server.ts, which the dev-server child's own SIGTERM
161
+ * handler runs). Dependencies are injected for tests.
162
+ */
163
+ export async function terminateProcessTree(child, graceMs = 2000, killTree = killFrontendTree, sleep = defaultSleep, isGroupAlive = isProcessGroupAlive) {
164
+ if (child.exitCode !== null || child.signalCode !== null) {
165
+ // ── POST-EXIT GROUP-KILL (scoped) ──────────────────────────────────────
166
+ // The direct child has already exited, but a detached *grandchild* (e.g. an
167
+ // orphaned Vite) may still be alive in its process group, still holding a
168
+ // port — reap it with one best-effort group SIGKILL.
169
+ //
170
+ // SCOPING: only reap when the group still has a live member. killFrontendTree's
171
+ // `-pid` group signal is PID-reuse-safe ONLY while a member keeps `pid`
172
+ // reserved as the group id; once the whole group has drained `pid` can be
173
+ // recycled and a blind `process.kill(-pid)` could hit an unrelated group. So
174
+ // we probe first (isProcessGroupAlive; POSIX signal 0) and skip when already
175
+ // drained — there is then nothing of ours to reap. The residual synchronous
176
+ // probe→kill window is the accepted trade-off documented in dev-server.ts
177
+ // "POST-EXIT GROUP-KILL POLICY", cross-referenced here so the risk is
178
+ // discoverable at this shared primitive.
179
+ const { pid } = child;
180
+ if (pid && pid > 1 && isGroupAlive(pid)) {
181
+ killTree(child, 'SIGKILL');
182
+ }
183
+ return true;
184
+ }
185
+ const exited = new Promise((res) => child.once('exit', () => res()));
186
+ killTree(child, 'SIGTERM');
187
+ const exitedCleanly = await Promise.race([
188
+ exited.then(() => true),
189
+ sleep(graceMs).then(() => false),
190
+ ]);
191
+ if (exitedCleanly)
192
+ return true;
193
+ killTree(child, 'SIGKILL');
194
+ // Shorter, fixed grace after SIGKILL (vs. the injectable SIGTERM graceMs):
195
+ // SIGKILL is uncatchable, so we only need a brief beat to observe `exit`.
196
+ await Promise.race([exited, sleep(KILL_GRACE_MS)]);
197
+ return child.exitCode !== null || child.signalCode !== null;
198
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/scripts/sandbox.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,+BAqJzD;AAED,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,iBAwCvD"}
1
+ {"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/scripts/sandbox.ts"],"names":[],"mappings":"AA+BA,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,+BAkMzD;AAED,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,iBAwCvD"}
@@ -10,6 +10,7 @@ import { trackCommand } from '../telemetry/trackCommand.js';
10
10
  import { buildAndSendEvent } from '../telemetry/client.js';
11
11
  import { getCdkTelemetryEnv } from './cdk-telemetry-env.js';
12
12
  import { runSync, spawnCommand } from './run-command.js';
13
+ import { terminateProcessTree } from './process-tree.js';
13
14
  /**
14
15
  * Import the backend definition to populate the Scope BB registry.
15
16
  *
@@ -40,7 +41,9 @@ export async function startSandbox(options) {
40
41
  process.env.BLOCKS_STAGE = 'sandbox';
41
42
  // Provision connection string to SSM SecureString.
42
43
  // On first deploy, creates the parameter. On subsequent deploys, updates if changed.
43
- const secrets = await ensureSecrets('sandbox');
44
+ // projectRoot is process.cwd() — the same value passed to cdk as --context
45
+ // projectRoot below — so the written name matches the name resolved at synth.
46
+ const secrets = await ensureSecrets('sandbox', process.cwd());
44
47
  if (secrets.created.length > 0) {
45
48
  console.log(`🔐 Created secrets: ${secrets.created.join(', ')}`);
46
49
  }
@@ -58,6 +61,13 @@ export async function startSandbox(options) {
58
61
  try {
59
62
  runSync("npm", [
60
63
  "exec", "cdk", "--", "deploy",
64
+ // `--all`: an app that uses Lambda@Edge (e.g. a Next.js route with
65
+ // `export const runtime = 'edge'`) synthesizes a SECOND stack
66
+ // (`edge-lambda-stack-*`, region us-east-1) in addition to the main
67
+ // hosting stack. Without `--all`, CDK refuses with "specify which
68
+ // stacks to use". Deploying every stack in a sandbox app is the
69
+ // intended behavior, so select them all.
70
+ "--all",
61
71
  "--require-approval", "never",
62
72
  "--outputs-file", `${outDir}/outputs.json`,
63
73
  "--context", `projectRoot=${process.cwd()}`,
@@ -121,6 +131,12 @@ export async function startSandbox(options) {
121
131
  `--app`, `npm exec tsx -- -C cdk ${backendPath}`
122
132
  ], {
123
133
  stdio: ["ignore", "pipe", "pipe"],
134
+ // Own process group on POSIX so cleanup can reap the whole `cdk watch` tree
135
+ // (npx → cdk → node) via terminateProcessTree, not just the npx shell — a
136
+ // bare kill() would orphan the real cdk-watch node process, the same
137
+ // shell-only-kill leak this PR eliminates for the dev server. Windows has no
138
+ // groups; terminateProcessTree reaps the tree via taskkill.
139
+ detached: process.platform !== 'win32',
124
140
  env: { ...process.env, NODE_OPTIONS: "--conditions=cdk", ...getCdkTelemetryEnv('sandbox') },
125
141
  });
126
142
  cdkWatch.stdout?.on("data", (data) => {
@@ -137,18 +153,49 @@ export async function startSandbox(options) {
137
153
  const devServer = spawnCommand(cmd, args, {
138
154
  stdio: "inherit",
139
155
  shell: true,
156
+ // Own process group on POSIX so cleanup can signal the whole dev-server
157
+ // tree (shell → tsx → node). The node dev server then runs its own SIGTERM
158
+ // handler — the ~2s terminateFrontend drain that reaps the *detached* Vite
159
+ // great-grandchild — which a bare `devServer.kill()` (the shell only) never
160
+ // triggers. Windows has no groups; terminateProcessTree reaps via taskkill.
161
+ detached: process.platform !== 'win32',
140
162
  env: {
141
163
  ...process.env,
142
164
  NODE_OPTIONS: '',
143
165
  BLOCKS_API_URL: apiUrl,
144
166
  },
145
167
  });
146
- const cleanup = () => {
168
+ let cleaningUp = false;
169
+ const cleanup = async () => {
170
+ if (cleaningUp)
171
+ return; // idempotent — a second signal must not re-enter
172
+ cleaningUp = true;
147
173
  console.log("\n\n🛑 Stopping local processes...");
148
174
  console.log(" (AWS resources are still running)");
149
175
  console.log("\n To destroy AWS resources, run: npm run sandbox:destroy\n");
150
- cdkWatch.kill();
151
- devServer.kill();
176
+ // Reap BOTH child trees the way the dev server reaps Vite — a process-group
177
+ // SIGTERM→SIGKILL via the shared terminateProcessTree — instead of a bare
178
+ // kill() that signals only the npx/shell parent and orphans the real
179
+ // grandchild (cdk-watch's node, or the dev server's detached Vite). Run them
180
+ // concurrently so the cdk-watch teardown doesn't serialize on top of the dev
181
+ // server's longer drain.
182
+ //
183
+ // Only the dev-server child owns the `:3100` port-free wait: its own SIGTERM
184
+ // handler runs terminateFrontend (a ~2s drain that reaps the detached Vite
185
+ // great-grandchild AND polls until the port frees), so we give it the longer
186
+ // 6s budget (> that ~2s drain) — a hung dev server still escalates to a tree
187
+ // SIGKILL and we exit regardless, so shutdown can never wedge. cdk watch
188
+ // holds no local port, so a bounded tree-kill is all it needs.
189
+ //
190
+ // That a group SIGTERM (terminateProcessTree → killFrontendTree's
191
+ // `process.kill(-pid, 'SIGTERM')`) actually reaches the *nested* node dev
192
+ // server and runs its own SIGTERM handler — the load-bearing assumption of
193
+ // the 6s budget above — is verified by the "group SIGTERM reaches a nested
194
+ // node child" integration test in dev-server-supervisor.test.ts.
195
+ await Promise.all([
196
+ terminateProcessTree(cdkWatch, 2000),
197
+ terminateProcessTree(devServer, 6000),
198
+ ]);
152
199
  process.exit(0);
153
200
  };
154
201
  process.on("SIGINT", cleanup);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Get the stackId from `.blocks/config.json` in the project root.
3
+ * This is the stable project identifier used as the base for CloudFormation stack names.
4
+ */
5
+ export declare function getStackId(projectRoot?: string): string;
6
+ /**
7
+ * Get or create a per-machine sandbox identifier.
8
+ * Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
9
+ * Format: `<username(8)>-<random(6)>` — identifies the developer's sandbox.
10
+ *
11
+ * Get-or-create (lazy init): returns the existing id, or generates and persists
12
+ * one on first call. The file is the shared sync point — once written, every
13
+ * later caller and every process reads the same id, so the secret writer
14
+ * (`ensureSecrets`) and synth derive identical names.
15
+ */
16
+ export declare function getSandboxId(projectRoot?: string): string;
17
+ /**
18
+ * The full CloudFormation stack name for a deployment.
19
+ *
20
+ * Single source of truth for the stack-name scheme (D-012): production is
21
+ * `<stackId>-prod`; a sandbox is `<stackId>-<sandboxId>`. The CDK templates name
22
+ * the stack with this function, and the external-DB connection-string parameter
23
+ * name (`dbConnectionParameterName`) is derived from it — so a deployed stack and
24
+ * the parameter holding its database credentials can never use divergent names.
25
+ *
26
+ * This function reads committed config (`.blocks/config.json`, throws if absent
27
+ * — D-012) and resolves the sandbox id via {@link getSandboxId} (get-or-create):
28
+ * the first caller materializes `.blocks-sandbox/sandbox-id.txt`, every later
29
+ * caller reads the same value. Because that file persists and is shared across
30
+ * processes, the secret writer (`ensureSecrets`) and synth resolve identical
31
+ * names. Production does not use the sandbox id.
32
+ */
33
+ export declare function getStackName(opts: {
34
+ sandbox: boolean;
35
+ projectRoot?: string;
36
+ }): string;
37
+ //# sourceMappingURL=stack-id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stack-id.d.ts","sourceRoot":"","sources":["../../src/scripts/stack-id.ts"],"names":[],"mappings":"AAiBA;;;GAGG;AACH,wBAAgB,UAAU,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAavD;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAWzD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAGrF"}
@@ -0,0 +1,79 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { randomBytes } from 'node:crypto';
7
+ function randomSuffix(length) {
8
+ return randomBytes(length).toString('hex').slice(0, length);
9
+ }
10
+ /**
11
+ * Get the stackId from `.blocks/config.json` in the project root.
12
+ * This is the stable project identifier used as the base for CloudFormation stack names.
13
+ */
14
+ export function getStackId(projectRoot) {
15
+ const root = projectRoot || process.cwd();
16
+ const configPath = join(root, '.blocks', 'config.json');
17
+ try {
18
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
19
+ if (!config.stackId)
20
+ throw new Error('missing key');
21
+ return config.stackId;
22
+ }
23
+ catch {
24
+ throw new Error(`.blocks/config.json not found or missing stackId — it is created by create-blocks-app and should be committed. ` +
25
+ `To fix manually, create ${configPath} with: { "stackId": "<your-app-name>" }`);
26
+ }
27
+ }
28
+ /**
29
+ * Get or create a per-machine sandbox identifier.
30
+ * Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
31
+ * Format: `<username(8)>-<random(6)>` — identifies the developer's sandbox.
32
+ *
33
+ * Get-or-create (lazy init): returns the existing id, or generates and persists
34
+ * one on first call. The file is the shared sync point — once written, every
35
+ * later caller and every process reads the same id, so the secret writer
36
+ * (`ensureSecrets`) and synth derive identical names.
37
+ */
38
+ export function getSandboxId(projectRoot) {
39
+ const root = projectRoot || process.cwd();
40
+ const filePath = join(root, '.blocks-sandbox', 'sandbox-id.txt');
41
+ if (existsSync(filePath))
42
+ return readFileSync(filePath, 'utf-8').trim();
43
+ const dir = dirname(filePath);
44
+ if (!existsSync(dir))
45
+ mkdirSync(dir, { recursive: true });
46
+ const username = getUsername().toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8) || 'dev';
47
+ const random = randomSuffix(6);
48
+ const id = `${username}-${random}`;
49
+ writeFileSync(filePath, id);
50
+ return id;
51
+ }
52
+ /**
53
+ * The full CloudFormation stack name for a deployment.
54
+ *
55
+ * Single source of truth for the stack-name scheme (D-012): production is
56
+ * `<stackId>-prod`; a sandbox is `<stackId>-<sandboxId>`. The CDK templates name
57
+ * the stack with this function, and the external-DB connection-string parameter
58
+ * name (`dbConnectionParameterName`) is derived from it — so a deployed stack and
59
+ * the parameter holding its database credentials can never use divergent names.
60
+ *
61
+ * This function reads committed config (`.blocks/config.json`, throws if absent
62
+ * — D-012) and resolves the sandbox id via {@link getSandboxId} (get-or-create):
63
+ * the first caller materializes `.blocks-sandbox/sandbox-id.txt`, every later
64
+ * caller reads the same value. Because that file persists and is shared across
65
+ * processes, the secret writer (`ensureSecrets`) and synth resolve identical
66
+ * names. Production does not use the sandbox id.
67
+ */
68
+ export function getStackName(opts) {
69
+ const base = getStackId(opts.projectRoot);
70
+ return opts.sandbox ? `${base}-${getSandboxId(opts.projectRoot)}` : `${base}-prod`;
71
+ }
72
+ function getUsername() {
73
+ try {
74
+ return execSync('git config user.name', { encoding: 'utf-8' }).trim();
75
+ }
76
+ catch {
77
+ return process.env.USER || process.env.USERNAME || 'user';
78
+ }
79
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=stack-id.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stack-id.test.d.ts","sourceRoot":"","sources":["../../src/scripts/stack-id.test.ts"],"names":[],"mappings":""}