@aws-blocks/core 0.1.13 → 0.1.17

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 (53) hide show
  1. package/README.md +180 -17
  2. package/dist/cors.d.ts +27 -1
  3. package/dist/cors.d.ts.map +1 -1
  4. package/dist/cors.js +55 -2
  5. package/dist/cors.test.js +81 -2
  6. package/dist/errors.test.js +26 -1
  7. package/dist/hosting.d.ts.map +1 -1
  8. package/dist/hosting.js +26 -1
  9. package/dist/hosting.test.js +73 -0
  10. package/dist/lambda-handler.d.ts.map +1 -1
  11. package/dist/lambda-handler.js +4 -17
  12. package/dist/lambda-handler.test.js +59 -2
  13. package/dist/rpc.test.js +77 -1
  14. package/dist/scripts/console.d.ts.map +1 -1
  15. package/dist/scripts/console.js +30 -2
  16. package/dist/scripts/deploy-stream.d.ts +181 -0
  17. package/dist/scripts/deploy-stream.d.ts.map +1 -0
  18. package/dist/scripts/deploy-stream.js +332 -0
  19. package/dist/scripts/deploy-stream.test.d.ts +2 -0
  20. package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
  21. package/dist/scripts/deploy-stream.test.js +845 -0
  22. package/dist/scripts/deploy.d.ts.map +1 -1
  23. package/dist/scripts/deploy.js +16 -9
  24. package/dist/scripts/dev-server-cors.test.js +19 -1
  25. package/dist/scripts/dev-server-rpc.test.js +50 -0
  26. package/dist/scripts/dev-server.d.ts +8 -0
  27. package/dist/scripts/dev-server.d.ts.map +1 -1
  28. package/dist/scripts/dev-server.js +35 -8
  29. package/dist/scripts/sandbox.js +1 -1
  30. package/dist/telemetry/client.js +4 -4
  31. package/dist/telemetry/telemetry-send-worker.js +4 -0
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +10 -1
  35. package/src/cors.test.ts +96 -2
  36. package/src/cors.ts +59 -2
  37. package/src/errors.test.ts +29 -1
  38. package/src/hosting.test.ts +107 -0
  39. package/src/hosting.ts +27 -1
  40. package/src/lambda-handler.test.ts +71 -2
  41. package/src/lambda-handler.ts +4 -20
  42. package/src/rpc.test.ts +96 -1
  43. package/src/scripts/console.ts +29 -2
  44. package/src/scripts/deploy-stream.test.ts +1035 -0
  45. package/src/scripts/deploy-stream.ts +475 -0
  46. package/src/scripts/deploy.ts +18 -11
  47. package/src/scripts/dev-server-cors.test.ts +26 -1
  48. package/src/scripts/dev-server-rpc.test.ts +54 -0
  49. package/src/scripts/dev-server.ts +38 -8
  50. package/src/scripts/sandbox.ts +1 -1
  51. package/src/telemetry/client.ts +4 -4
  52. package/src/telemetry/telemetry-send-worker.ts +5 -0
  53. package/src/version.ts +1 -1
@@ -0,0 +1,332 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { spawnCommand } from './run-command.js';
4
+ import { terminateProcessTree } from './process-tree.js';
5
+ /** Signals whose delivery we take over while a deploy is in flight. */
6
+ export const DEPLOY_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
7
+ /** Default gap (ms) of silence after which the runner prints a progress heartbeat. */
8
+ export const DEFAULT_HEARTBEAT_MS = 30_000;
9
+ /** Grace (ms) given to the child tree to exit after an operator-requested abort. */
10
+ export const ABORT_GRACE_MS = 10_000;
11
+ /**
12
+ * Grace (ms) we wait after the child exits for its stdout/stderr pipes to end,
13
+ * so the last CloudFormation lines are relayed before we resolve. Bounded
14
+ * because a lingering grandchild could hold the pipe open forever; losing a
15
+ * trailing line is strictly better than hanging a finished deploy.
16
+ */
17
+ export const STREAM_FLUSH_GRACE_MS = 2_000;
18
+ /**
19
+ * Window (ms) in which repeated SIGTERMs count as ONE operator request.
20
+ *
21
+ * A single external signal reaches this process more than once. `npm run deploy`
22
+ * runs `npm -> sh -> tsx -> node`, so a process-group SIGTERM is delivered to
23
+ * the node process directly AND relayed to it a second time by `tsx`, which
24
+ * forwards SIGTERM/SIGINT to its child. Measured on a real deploy: one
25
+ * `kill -TERM -<pgid>` produced two SIGTERMs about 50ms apart. Without this
26
+ * window the second delivery is read as "the operator insisted" and the deploy
27
+ * is abandoned, which is the exact failure this module exists to prevent.
28
+ *
29
+ * 2s is far longer than a delivery burst (tens of ms) and far shorter than a
30
+ * deliberate repeat (a human running `kill` twice, or a supervisor's
31
+ * SIGTERM-then-escalate cycle), so both intents stay distinguishable.
32
+ */
33
+ export const SIGNAL_COALESCE_MS = 2_000;
34
+ /**
35
+ * Decide how to answer a signal that arrives while CloudFormation is still
36
+ * converging. This is the whole "decouple the CLI lifecycle from the in-flight
37
+ * deploy" policy, kept pure so it can be asserted directly.
38
+ *
39
+ * - `SIGHUP` → always `defer`. A hangup means the terminal or parent shell went
40
+ * away (a backgrounded `npm run deploy &`, a closed SSH session). The deploy
41
+ * is server-side work that is already paid for; killing the CLI here is what
42
+ * produced the phantom failures, so we keep streaming instead. Duplicate
43
+ * deliveries inside {@link SIGNAL_COALESCE_MS} are coalesced so one hangup
44
+ * logs one line, not one per delivery.
45
+ * - `SIGTERM` → `defer` the first time. A lone SIGTERM is almost always
46
+ * process-group collateral (a harness reaping the parent shell, a supervisor
47
+ * tidying up) rather than a deliberate "stop the deploy", so it only warns.
48
+ * Repeats inside {@link SIGNAL_COALESCE_MS} are duplicate *deliveries* of that
49
+ * same signal (the group delivers it, then `tsx` relays it) and are coalesced
50
+ * into the first. A SIGTERM after that window is a deliberate repeat and
51
+ * aborts. A SIGKILL follow-up (`docker stop`, most CI cancels) is uncatchable
52
+ * and still ends the process immediately, so this cannot wedge a shutdown.
53
+ * - `SIGINT` → always `abort`. Ctrl-C is unambiguous, interactive intent, so it
54
+ * stays responsive on the first press.
55
+ *
56
+ * @param signal - the signal received.
57
+ * @param msSinceFirstDeferral - ms since the first deferral of *this* signal, or
58
+ * `null` when this signal has not been deferred yet. Each signal is tracked
59
+ * separately, so a deferred SIGHUP never consumes the SIGTERM abort budget
60
+ * (and a repeated SIGHUP is deduped the same way a repeated SIGTERM is).
61
+ * @param coalesceWindowMs - see {@link SIGNAL_COALESCE_MS}.
62
+ */
63
+ export function decideSignalResponse(signal, msSinceFirstDeferral, coalesceWindowMs = SIGNAL_COALESCE_MS) {
64
+ if (signal === 'SIGINT') {
65
+ return {
66
+ action: 'abort',
67
+ message: '\n🛑 Interrupted. Stopping the CDK CLI — CloudFormation may keep applying the change set server-side.',
68
+ };
69
+ }
70
+ if (signal === 'SIGHUP') {
71
+ return {
72
+ action: 'defer',
73
+ message: '⚠️ Ignoring SIGHUP: the deploy is still running and CloudFormation is still converging. Streaming continues; send SIGTERM twice to abort.',
74
+ coalesced: msSinceFirstDeferral !== null && msSinceFirstDeferral < coalesceWindowMs,
75
+ };
76
+ }
77
+ if (signal === 'SIGTERM' && msSinceFirstDeferral === null) {
78
+ return {
79
+ action: 'defer',
80
+ message: '⚠️ Ignoring SIGTERM: the deploy is still running and CloudFormation is still converging. Send SIGTERM again to abort (the stack update continues server-side either way).',
81
+ };
82
+ }
83
+ if (signal === 'SIGTERM' && msSinceFirstDeferral !== null && msSinceFirstDeferral < coalesceWindowMs) {
84
+ // Same signal reaching us twice (process group + a wrapper's relay), not a
85
+ // second request — keep deploying and stay quiet about the duplicate.
86
+ return {
87
+ action: 'defer',
88
+ message: '',
89
+ coalesced: true,
90
+ };
91
+ }
92
+ return {
93
+ action: 'abort',
94
+ message: `\n🛑 Received ${signal} while deploying. Stopping the CDK CLI — CloudFormation may keep applying the change set server-side.`,
95
+ };
96
+ }
97
+ /**
98
+ * Split a byte stream into whole lines across chunk boundaries.
99
+ *
100
+ * The child's output arrives in arbitrary chunks, so a naive
101
+ * `chunk.toString().split('\n')` emits torn lines (and drops the tail). This
102
+ * keeps the partial trailing line buffered until it completes; {@link flush}
103
+ * returns whatever is left when the stream ends (CDK's final line has no
104
+ * trailing newline).
105
+ */
106
+ export function createLineAssembler() {
107
+ let buffered = '';
108
+ return {
109
+ push(chunk) {
110
+ buffered += chunk;
111
+ const lines = buffered.split('\n');
112
+ buffered = lines.pop() ?? '';
113
+ return lines.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
114
+ },
115
+ flush() {
116
+ if (!buffered)
117
+ return [];
118
+ const last = buffered.endsWith('\r') ? buffered.slice(0, -1) : buffered;
119
+ buffered = '';
120
+ return last ? [last] : [];
121
+ },
122
+ };
123
+ }
124
+ /** Human-readable elapsed time (`45s`, `4m 05s`) for progress lines. */
125
+ export function formatElapsed(ms) {
126
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
127
+ const minutes = Math.floor(totalSeconds / 60);
128
+ const seconds = totalSeconds % 60;
129
+ return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s`;
130
+ }
131
+ /**
132
+ * Build the `cdk deploy` argv used by `npm run deploy`.
133
+ *
134
+ * Two of these flags exist purely so the deploy is observable — losing either
135
+ * one brings back the 0-byte stdout:
136
+ *
137
+ * - `--ci`: the CDK CLI picks its log stream as `isCI ? stdout : stderr`, so
138
+ * without it every CloudFormation event goes to **stderr** and a caller
139
+ * capturing stdout (`npm run deploy > deploy.log`) sees nothing for the whole
140
+ * multi-minute deploy. With it, progress goes to stdout and only error-level
141
+ * messages stay on stderr.
142
+ * - `--progress events`: print one line per resource transition instead of the
143
+ * redrawing progress bar. The bar needs a TTY, which a piped/backgrounded
144
+ * deploy does not have, and a half-rendered bar is not a usable progress
145
+ * signal in a log file.
146
+ */
147
+ export function buildCdkDeployArgs({ projectRoot, outputsFile }) {
148
+ return [
149
+ 'cdk',
150
+ 'deploy',
151
+ '--require-approval',
152
+ 'never',
153
+ '--ci',
154
+ '--progress',
155
+ 'events',
156
+ '--outputs-file',
157
+ outputsFile,
158
+ '--context',
159
+ `projectRoot=${projectRoot}`,
160
+ ];
161
+ }
162
+ /** Unref'd sleep: a pending flush grace must never keep the process alive. */
163
+ function delay(ms) {
164
+ return new Promise((resolve) => {
165
+ setTimeout(resolve, ms).unref?.();
166
+ });
167
+ }
168
+ /** Raised when the child exits non-zero, is killed, or the operator aborts. */
169
+ export class DeployProcessError extends Error {
170
+ exitCode;
171
+ signal;
172
+ aborted;
173
+ constructor(message, details = {}) {
174
+ super(message);
175
+ this.name = 'DeployProcessError';
176
+ this.exitCode = details.exitCode ?? null;
177
+ this.signal = details.signal ?? null;
178
+ this.aborted = details.aborted ?? false;
179
+ }
180
+ }
181
+ /**
182
+ * Run a long deployment command, relaying its output line by line as it happens
183
+ * and keeping it alive across a stray SIGTERM/SIGHUP.
184
+ *
185
+ * Behaviour that matters to callers:
186
+ * - **Streamed, non-empty stdout.** Child stdout is relayed to `stdout` the
187
+ * moment a line completes (never buffered until exit) and child stderr to
188
+ * `stderr`, so `npm run deploy | tee` shows CloudFormation progress live.
189
+ * - **Idle heartbeat.** While the child is silent for `heartbeatMs`, a
190
+ * `still deploying` line with elapsed time is written to `stdout`, so a
191
+ * ten-minute RDS resource never looks like a hung process.
192
+ * - **Own process group (POSIX only).** The child is spawned `detached` on
193
+ * POSIX, so a process-group signal aimed at the parent shell
194
+ * (`kill -TERM -pgid`, a harness reaping a backgrounded job) cannot kill the
195
+ * CDK CLI behind our back; this runner is the only thing that signals it.
196
+ * Windows has neither process groups nor OS-delivered SIGTERM/SIGHUP, so the
197
+ * signal resilience below does not apply there — a `taskkill` on the tree ends
198
+ * the deploy, and the abort path reaps by pid via `terminateProcessTree`.
199
+ * - **No stdin.** The child gets `ignore` for stdin so a backgrounded deploy
200
+ * can never be stopped by SIGTTIN trying to read a terminal it no longer
201
+ * owns; the caller must keep passing `--require-approval never`.
202
+ * - **Signal policy (POSIX).** See {@link decideSignalResponse}: a deferred
203
+ * signal only logs (which itself doubles as a progress signal on stdout),
204
+ * while an abort reaps the child tree and throws a {@link DeployProcessError}
205
+ * with `aborted: true`. Repeat deliveries of the same signal inside
206
+ * {@link SIGNAL_COALESCE_MS} log once, not once per delivery.
207
+ * - **Streams stay separated.** Child stdout and child stderr are relayed to
208
+ * their own sinks and never merged, so a deploy failure reason (which the CDK
209
+ * CLI keeps on stderr even under `--ci`) stays on stderr while progress is on
210
+ * stdout.
211
+ *
212
+ * Resolves when the child exits 0; otherwise throws {@link DeployProcessError}.
213
+ */
214
+ export async function runStreaming(command, args, options = {}) {
215
+ const { cwd, env, label = 'deploy', heartbeatMs = DEFAULT_HEARTBEAT_MS, stdout = process.stdout, stderr = process.stderr, now = Date.now, signalTarget = process, } = options;
216
+ const startedAt = now();
217
+ let lastOutputAt = startedAt;
218
+ let aborting = false;
219
+ let exitObserved = false;
220
+ // Per signal, when its current deferral window opened. Kept per signal so a
221
+ // deferred SIGHUP neither consumes the SIGTERM abort budget nor silently
222
+ // swallows its own log line.
223
+ const deferredAt = new Map();
224
+ const child = spawnCommand(command, args, {
225
+ cwd,
226
+ env,
227
+ // stdin ignored (see doc comment); stdout/stderr piped so we can relay them
228
+ // line by line instead of handing the child our fds and going blind.
229
+ stdio: ['ignore', 'pipe', 'pipe'],
230
+ detached: process.platform !== 'win32',
231
+ });
232
+ const relay = (stream, sink) => {
233
+ if (!stream)
234
+ return;
235
+ const assembler = createLineAssembler();
236
+ stream.setEncoding('utf-8');
237
+ stream.on('data', (chunk) => {
238
+ lastOutputAt = now();
239
+ for (const line of assembler.push(chunk))
240
+ sink.write(`${line}\n`);
241
+ });
242
+ stream.on('end', () => {
243
+ for (const line of assembler.flush())
244
+ sink.write(`${line}\n`);
245
+ });
246
+ };
247
+ relay(child.stdout, stdout);
248
+ relay(child.stderr, stderr);
249
+ const heartbeat = heartbeatMs > 0
250
+ ? setInterval(() => {
251
+ if (now() - lastOutputAt < heartbeatMs)
252
+ return;
253
+ stdout.write(`⏳ [${label}] still running after ${formatElapsed(now() - startedAt)} — CloudFormation is converging (pid ${child.pid ?? '?'})\n`);
254
+ }, heartbeatMs)
255
+ : undefined;
256
+ // Never let the heartbeat timer be the reason the process stays alive.
257
+ heartbeat?.unref?.();
258
+ const exited = new Promise((resolve, reject) => {
259
+ child.once('error', reject);
260
+ child.once('exit', (code, signal) => {
261
+ exitObserved = true;
262
+ resolve({ code, signal });
263
+ });
264
+ });
265
+ const streamsEnded = Promise.all([child.stdout, child.stderr].map((stream) => new Promise((resolve) => {
266
+ if (!stream)
267
+ return resolve();
268
+ stream.once('end', () => resolve());
269
+ stream.once('close', () => resolve());
270
+ })));
271
+ const handlers = new Map();
272
+ const removeHandlers = () => {
273
+ for (const [signal, handler] of handlers)
274
+ signalTarget.off(signal, handler);
275
+ handlers.clear();
276
+ };
277
+ for (const signal of DEPLOY_SIGNALS) {
278
+ const handler = () => {
279
+ // The deploy already finished (we are just draining pipes / reporting):
280
+ // there is nothing left to defer or abort, and reporting an abort here
281
+ // would turn a successful deploy into a failure.
282
+ if (exitObserved)
283
+ return;
284
+ const openedAt = deferredAt.get(signal);
285
+ const { action, message, coalesced } = decideSignalResponse(signal, openedAt === undefined ? null : now() - openedAt);
286
+ if (action === 'defer') {
287
+ // A coalesced duplicate is the same signal arriving twice (process group
288
+ // plus a wrapper relay); log it once, not once per delivery. Anything
289
+ // outside the window is a fresh request, so it opens a new window and
290
+ // reports again instead of being muted for the rest of the deploy.
291
+ if (!coalesced) {
292
+ deferredAt.set(signal, now());
293
+ lastOutputAt = now();
294
+ stdout.write(`${message}\n`);
295
+ }
296
+ return;
297
+ }
298
+ if (aborting)
299
+ return; // already tearing down; a repeat signal is a no-op
300
+ aborting = true;
301
+ stdout.write(`${message}\n`);
302
+ // Reap the whole tree (npx → cdk → node), not just the npx parent, so an
303
+ // abort cannot leave an orphaned CDK CLI still driving the stack.
304
+ void terminateProcessTree(child, ABORT_GRACE_MS);
305
+ };
306
+ handlers.set(signal, handler);
307
+ signalTarget.on(signal, handler);
308
+ }
309
+ try {
310
+ const { code, signal } = await exited;
311
+ // Let the pipes drain so a caller reading our stdout sees the child's final
312
+ // lines (CDK's summary / failure reason) before the terminal status.
313
+ await Promise.race([streamsEnded, delay(STREAM_FLUSH_GRACE_MS)]);
314
+ if (aborting) {
315
+ throw new DeployProcessError(`${command} was aborted by an operator signal after ${formatElapsed(now() - startedAt)}`, { exitCode: code, signal, aborted: true });
316
+ }
317
+ if (signal) {
318
+ throw new DeployProcessError(`${command} was terminated by signal ${signal}`, {
319
+ exitCode: code,
320
+ signal,
321
+ });
322
+ }
323
+ if (code !== 0) {
324
+ throw new DeployProcessError(`${command} ${args.join(' ')} exited with code ${code}`, { exitCode: code });
325
+ }
326
+ }
327
+ finally {
328
+ if (heartbeat)
329
+ clearInterval(heartbeat);
330
+ removeHandlers();
331
+ }
332
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=deploy-stream.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deploy-stream.test.d.ts","sourceRoot":"","sources":["../../src/scripts/deploy-stream.test.ts"],"names":[],"mappings":""}