@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,475 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { ChildProcess } from 'node:child_process';
5
+ import { spawnCommand } from './run-command.js';
6
+ import { terminateProcessTree } from './process-tree.js';
7
+
8
+ // Streaming, signal-resilient runner for the long CloudFormation phase of
9
+ // `npm run deploy`.
10
+ //
11
+ // Two failures made a production deploy unobservable and, worse, made a
12
+ // SUCCESSFUL deploy look like a failure (callers re-ran it, paying for the same
13
+ // stack two or three times):
14
+ //
15
+ // 1. ZERO BYTES ON STDOUT. The CDK CLI sends every non-error message —
16
+ // including all CloudFormation stack activity — to *stderr* unless CI mode
17
+ // is on: its io host resolves the target stream as
18
+ // `isCI ? process.stdout : process.stderr`. So `npm run deploy > deploy.log`
19
+ // captured nothing at all for the entire (multi-minute) CloudFormation
20
+ // phase, leaving callers to poll `describe-stacks` by hand.
21
+ // 2. A BACKGROUNDED DEPLOY DIED ON SIGTERM. The old code ran `cdk deploy`
22
+ // through a *synchronous* spawn, so the event loop was blocked for the whole
23
+ // deploy: signals could not be handled, the default SIGTERM disposition
24
+ // killed the CLI (exit 143), and CloudFormation — which executes
25
+ // server-side once the change set is executing — carried on and finished.
26
+ // The caller saw a dead process with no output and assumed failure.
27
+ //
28
+ // The fix streams the child's output line by line as it arrives (so stdout is
29
+ // never silent), emits an idle heartbeat so a slow resource still produces
30
+ // progress, and puts the deploy's lifecycle under this CLI's control: the child
31
+ // runs in its own process group and a single SIGTERM/SIGHUP no longer abandons
32
+ // an in-flight deploy.
33
+ //
34
+ // The signal half of that is POSIX-only. It relies on process groups (`detached`)
35
+ // and on real SIGTERM/SIGHUP delivery, neither of which Windows has: `detached`
36
+ // is passed only when `process.platform !== 'win32'`, and on Windows nothing
37
+ // outside the process delivers those signals (a `taskkill` on the tree still
38
+ // ends the deploy). Streaming, the heartbeat and the argv contract are
39
+ // cross-platform; only "survives a stray reap" is POSIX.
40
+
41
+ /** What to do with a signal that arrives while a deploy is in flight. */
42
+ export type DeploySignalAction = 'defer' | 'abort';
43
+
44
+ export interface DeploySignalResponse {
45
+ action: DeploySignalAction;
46
+ /** Operator-facing line explaining what happened and how to force an abort. */
47
+ message: string;
48
+ /**
49
+ * True when this signal is a duplicate delivery of one the operator already
50
+ * sent (see {@link SIGNAL_COALESCE_MS}), so the caller can skip logging it
51
+ * twice.
52
+ */
53
+ coalesced?: boolean;
54
+ }
55
+
56
+ /** Signals whose delivery we take over while a deploy is in flight. */
57
+ export const DEPLOY_SIGNALS: readonly NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];
58
+
59
+ /** Default gap (ms) of silence after which the runner prints a progress heartbeat. */
60
+ export const DEFAULT_HEARTBEAT_MS = 30_000;
61
+
62
+ /** Grace (ms) given to the child tree to exit after an operator-requested abort. */
63
+ export const ABORT_GRACE_MS = 10_000;
64
+
65
+ /**
66
+ * Grace (ms) we wait after the child exits for its stdout/stderr pipes to end,
67
+ * so the last CloudFormation lines are relayed before we resolve. Bounded
68
+ * because a lingering grandchild could hold the pipe open forever; losing a
69
+ * trailing line is strictly better than hanging a finished deploy.
70
+ */
71
+ export const STREAM_FLUSH_GRACE_MS = 2_000;
72
+
73
+ /**
74
+ * Window (ms) in which repeated SIGTERMs count as ONE operator request.
75
+ *
76
+ * A single external signal reaches this process more than once. `npm run deploy`
77
+ * runs `npm -> sh -> tsx -> node`, so a process-group SIGTERM is delivered to
78
+ * the node process directly AND relayed to it a second time by `tsx`, which
79
+ * forwards SIGTERM/SIGINT to its child. Measured on a real deploy: one
80
+ * `kill -TERM -<pgid>` produced two SIGTERMs about 50ms apart. Without this
81
+ * window the second delivery is read as "the operator insisted" and the deploy
82
+ * is abandoned, which is the exact failure this module exists to prevent.
83
+ *
84
+ * 2s is far longer than a delivery burst (tens of ms) and far shorter than a
85
+ * deliberate repeat (a human running `kill` twice, or a supervisor's
86
+ * SIGTERM-then-escalate cycle), so both intents stay distinguishable.
87
+ */
88
+ export const SIGNAL_COALESCE_MS = 2_000;
89
+
90
+ /**
91
+ * Decide how to answer a signal that arrives while CloudFormation is still
92
+ * converging. This is the whole "decouple the CLI lifecycle from the in-flight
93
+ * deploy" policy, kept pure so it can be asserted directly.
94
+ *
95
+ * - `SIGHUP` → always `defer`. A hangup means the terminal or parent shell went
96
+ * away (a backgrounded `npm run deploy &`, a closed SSH session). The deploy
97
+ * is server-side work that is already paid for; killing the CLI here is what
98
+ * produced the phantom failures, so we keep streaming instead. Duplicate
99
+ * deliveries inside {@link SIGNAL_COALESCE_MS} are coalesced so one hangup
100
+ * logs one line, not one per delivery.
101
+ * - `SIGTERM` → `defer` the first time. A lone SIGTERM is almost always
102
+ * process-group collateral (a harness reaping the parent shell, a supervisor
103
+ * tidying up) rather than a deliberate "stop the deploy", so it only warns.
104
+ * Repeats inside {@link SIGNAL_COALESCE_MS} are duplicate *deliveries* of that
105
+ * same signal (the group delivers it, then `tsx` relays it) and are coalesced
106
+ * into the first. A SIGTERM after that window is a deliberate repeat and
107
+ * aborts. A SIGKILL follow-up (`docker stop`, most CI cancels) is uncatchable
108
+ * and still ends the process immediately, so this cannot wedge a shutdown.
109
+ * - `SIGINT` → always `abort`. Ctrl-C is unambiguous, interactive intent, so it
110
+ * stays responsive on the first press.
111
+ *
112
+ * @param signal - the signal received.
113
+ * @param msSinceFirstDeferral - ms since the first deferral of *this* signal, or
114
+ * `null` when this signal has not been deferred yet. Each signal is tracked
115
+ * separately, so a deferred SIGHUP never consumes the SIGTERM abort budget
116
+ * (and a repeated SIGHUP is deduped the same way a repeated SIGTERM is).
117
+ * @param coalesceWindowMs - see {@link SIGNAL_COALESCE_MS}.
118
+ */
119
+ export function decideSignalResponse(
120
+ signal: NodeJS.Signals,
121
+ msSinceFirstDeferral: number | null,
122
+ coalesceWindowMs: number = SIGNAL_COALESCE_MS,
123
+ ): DeploySignalResponse {
124
+ if (signal === 'SIGINT') {
125
+ return {
126
+ action: 'abort',
127
+ message:
128
+ '\n🛑 Interrupted. Stopping the CDK CLI — CloudFormation may keep applying the change set server-side.',
129
+ };
130
+ }
131
+ if (signal === 'SIGHUP') {
132
+ return {
133
+ action: 'defer',
134
+ message:
135
+ '⚠️ Ignoring SIGHUP: the deploy is still running and CloudFormation is still converging. Streaming continues; send SIGTERM twice to abort.',
136
+ coalesced: msSinceFirstDeferral !== null && msSinceFirstDeferral < coalesceWindowMs,
137
+ };
138
+ }
139
+ if (signal === 'SIGTERM' && msSinceFirstDeferral === null) {
140
+ return {
141
+ action: 'defer',
142
+ message:
143
+ '⚠️ 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).',
144
+ };
145
+ }
146
+ if (signal === 'SIGTERM' && msSinceFirstDeferral !== null && msSinceFirstDeferral < coalesceWindowMs) {
147
+ // Same signal reaching us twice (process group + a wrapper's relay), not a
148
+ // second request — keep deploying and stay quiet about the duplicate.
149
+ return {
150
+ action: 'defer',
151
+ message: '',
152
+ coalesced: true,
153
+ };
154
+ }
155
+ return {
156
+ action: 'abort',
157
+ message: `\n🛑 Received ${signal} while deploying. Stopping the CDK CLI — CloudFormation may keep applying the change set server-side.`,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Split a byte stream into whole lines across chunk boundaries.
163
+ *
164
+ * The child's output arrives in arbitrary chunks, so a naive
165
+ * `chunk.toString().split('\n')` emits torn lines (and drops the tail). This
166
+ * keeps the partial trailing line buffered until it completes; {@link flush}
167
+ * returns whatever is left when the stream ends (CDK's final line has no
168
+ * trailing newline).
169
+ */
170
+ export function createLineAssembler(): {
171
+ push(chunk: string): string[];
172
+ flush(): string[];
173
+ } {
174
+ let buffered = '';
175
+ return {
176
+ push(chunk: string): string[] {
177
+ buffered += chunk;
178
+ const lines = buffered.split('\n');
179
+ buffered = lines.pop() ?? '';
180
+ return lines.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
181
+ },
182
+ flush(): string[] {
183
+ if (!buffered) return [];
184
+ const last = buffered.endsWith('\r') ? buffered.slice(0, -1) : buffered;
185
+ buffered = '';
186
+ return last ? [last] : [];
187
+ },
188
+ };
189
+ }
190
+
191
+ /** Human-readable elapsed time (`45s`, `4m 05s`) for progress lines. */
192
+ export function formatElapsed(ms: number): string {
193
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
194
+ const minutes = Math.floor(totalSeconds / 60);
195
+ const seconds = totalSeconds % 60;
196
+ return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s`;
197
+ }
198
+
199
+ export interface CdkDeployArgsOptions {
200
+ /** Project root passed to synth as `--context projectRoot=…`. */
201
+ projectRoot: string;
202
+ /** Path (relative to the project root) CDK writes stack outputs to. */
203
+ outputsFile: string;
204
+ }
205
+
206
+ /**
207
+ * Build the `cdk deploy` argv used by `npm run deploy`.
208
+ *
209
+ * Two of these flags exist purely so the deploy is observable — losing either
210
+ * one brings back the 0-byte stdout:
211
+ *
212
+ * - `--ci`: the CDK CLI picks its log stream as `isCI ? stdout : stderr`, so
213
+ * without it every CloudFormation event goes to **stderr** and a caller
214
+ * capturing stdout (`npm run deploy > deploy.log`) sees nothing for the whole
215
+ * multi-minute deploy. With it, progress goes to stdout and only error-level
216
+ * messages stay on stderr.
217
+ * - `--progress events`: print one line per resource transition instead of the
218
+ * redrawing progress bar. The bar needs a TTY, which a piped/backgrounded
219
+ * deploy does not have, and a half-rendered bar is not a usable progress
220
+ * signal in a log file.
221
+ */
222
+ export function buildCdkDeployArgs({ projectRoot, outputsFile }: CdkDeployArgsOptions): string[] {
223
+ return [
224
+ 'cdk',
225
+ 'deploy',
226
+ '--require-approval',
227
+ 'never',
228
+ '--ci',
229
+ '--progress',
230
+ 'events',
231
+ '--outputs-file',
232
+ outputsFile,
233
+ '--context',
234
+ `projectRoot=${projectRoot}`,
235
+ ];
236
+ }
237
+
238
+ /** Unref'd sleep: a pending flush grace must never keep the process alive. */
239
+ function delay(ms: number): Promise<void> {
240
+ return new Promise((resolve) => {
241
+ setTimeout(resolve, ms).unref?.();
242
+ });
243
+ }
244
+
245
+ /** Minimal sink surface so tests can capture the relayed streams. */
246
+ export interface OutputSink {
247
+ write(chunk: string): unknown;
248
+ }
249
+
250
+ /** Minimal signal-registration surface ({@link process} satisfies it). */
251
+ export interface SignalRegistry {
252
+ on(signal: NodeJS.Signals, handler: () => void): unknown;
253
+ off(signal: NodeJS.Signals, handler: () => void): unknown;
254
+ }
255
+
256
+ export interface RunStreamingOptions {
257
+ cwd?: string;
258
+ env?: NodeJS.ProcessEnv;
259
+ /** Prefix used by the runner's own progress/status lines. Defaults to `deploy`. */
260
+ label?: string;
261
+ /** Idle gap before a heartbeat line is printed. `0` disables heartbeats. */
262
+ heartbeatMs?: number;
263
+ /** Where child stdout and runner progress lines go. Defaults to `process.stdout`. */
264
+ stdout?: OutputSink;
265
+ /** Where child stderr goes. Defaults to `process.stderr`. */
266
+ stderr?: OutputSink;
267
+ /** Injected for tests. */
268
+ now?: () => number;
269
+ /** Injected for tests: signal registration seam. */
270
+ signalTarget?: SignalRegistry;
271
+ }
272
+
273
+ /** Raised when the child exits non-zero, is killed, or the operator aborts. */
274
+ export class DeployProcessError extends Error {
275
+ readonly exitCode: number | null;
276
+ readonly signal: NodeJS.Signals | null;
277
+ readonly aborted: boolean;
278
+
279
+ constructor(
280
+ message: string,
281
+ details: { exitCode?: number | null; signal?: NodeJS.Signals | null; aborted?: boolean } = {},
282
+ ) {
283
+ super(message);
284
+ this.name = 'DeployProcessError';
285
+ this.exitCode = details.exitCode ?? null;
286
+ this.signal = details.signal ?? null;
287
+ this.aborted = details.aborted ?? false;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Run a long deployment command, relaying its output line by line as it happens
293
+ * and keeping it alive across a stray SIGTERM/SIGHUP.
294
+ *
295
+ * Behaviour that matters to callers:
296
+ * - **Streamed, non-empty stdout.** Child stdout is relayed to `stdout` the
297
+ * moment a line completes (never buffered until exit) and child stderr to
298
+ * `stderr`, so `npm run deploy | tee` shows CloudFormation progress live.
299
+ * - **Idle heartbeat.** While the child is silent for `heartbeatMs`, a
300
+ * `still deploying` line with elapsed time is written to `stdout`, so a
301
+ * ten-minute RDS resource never looks like a hung process.
302
+ * - **Own process group (POSIX only).** The child is spawned `detached` on
303
+ * POSIX, so a process-group signal aimed at the parent shell
304
+ * (`kill -TERM -pgid`, a harness reaping a backgrounded job) cannot kill the
305
+ * CDK CLI behind our back; this runner is the only thing that signals it.
306
+ * Windows has neither process groups nor OS-delivered SIGTERM/SIGHUP, so the
307
+ * signal resilience below does not apply there — a `taskkill` on the tree ends
308
+ * the deploy, and the abort path reaps by pid via `terminateProcessTree`.
309
+ * - **No stdin.** The child gets `ignore` for stdin so a backgrounded deploy
310
+ * can never be stopped by SIGTTIN trying to read a terminal it no longer
311
+ * owns; the caller must keep passing `--require-approval never`.
312
+ * - **Signal policy (POSIX).** See {@link decideSignalResponse}: a deferred
313
+ * signal only logs (which itself doubles as a progress signal on stdout),
314
+ * while an abort reaps the child tree and throws a {@link DeployProcessError}
315
+ * with `aborted: true`. Repeat deliveries of the same signal inside
316
+ * {@link SIGNAL_COALESCE_MS} log once, not once per delivery.
317
+ * - **Streams stay separated.** Child stdout and child stderr are relayed to
318
+ * their own sinks and never merged, so a deploy failure reason (which the CDK
319
+ * CLI keeps on stderr even under `--ci`) stays on stderr while progress is on
320
+ * stdout.
321
+ *
322
+ * Resolves when the child exits 0; otherwise throws {@link DeployProcessError}.
323
+ */
324
+ export async function runStreaming(
325
+ command: string,
326
+ args: string[],
327
+ options: RunStreamingOptions = {},
328
+ ): Promise<void> {
329
+ const {
330
+ cwd,
331
+ env,
332
+ label = 'deploy',
333
+ heartbeatMs = DEFAULT_HEARTBEAT_MS,
334
+ stdout = process.stdout,
335
+ stderr = process.stderr,
336
+ now = Date.now,
337
+ signalTarget = process,
338
+ } = options;
339
+
340
+ const startedAt = now();
341
+ let lastOutputAt = startedAt;
342
+ let aborting = false;
343
+ let exitObserved = false;
344
+ // Per signal, when its current deferral window opened. Kept per signal so a
345
+ // deferred SIGHUP neither consumes the SIGTERM abort budget nor silently
346
+ // swallows its own log line.
347
+ const deferredAt = new Map<NodeJS.Signals, number>();
348
+
349
+ const child: ChildProcess = spawnCommand(command, args, {
350
+ cwd,
351
+ env,
352
+ // stdin ignored (see doc comment); stdout/stderr piped so we can relay them
353
+ // line by line instead of handing the child our fds and going blind.
354
+ stdio: ['ignore', 'pipe', 'pipe'],
355
+ detached: process.platform !== 'win32',
356
+ });
357
+
358
+ const relay = (
359
+ stream: NodeJS.ReadableStream | null | undefined,
360
+ sink: OutputSink,
361
+ ): void => {
362
+ if (!stream) return;
363
+ const assembler = createLineAssembler();
364
+ stream.setEncoding('utf-8');
365
+ stream.on('data', (chunk: string) => {
366
+ lastOutputAt = now();
367
+ for (const line of assembler.push(chunk)) sink.write(`${line}\n`);
368
+ });
369
+ stream.on('end', () => {
370
+ for (const line of assembler.flush()) sink.write(`${line}\n`);
371
+ });
372
+ };
373
+ relay(child.stdout, stdout);
374
+ relay(child.stderr, stderr);
375
+
376
+ const heartbeat =
377
+ heartbeatMs > 0
378
+ ? setInterval(() => {
379
+ if (now() - lastOutputAt < heartbeatMs) return;
380
+ stdout.write(
381
+ `⏳ [${label}] still running after ${formatElapsed(now() - startedAt)} — CloudFormation is converging (pid ${child.pid ?? '?'})\n`,
382
+ );
383
+ }, heartbeatMs)
384
+ : undefined;
385
+ // Never let the heartbeat timer be the reason the process stays alive.
386
+ heartbeat?.unref?.();
387
+
388
+ const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
389
+ (resolve, reject) => {
390
+ child.once('error', reject);
391
+ child.once('exit', (code, signal) => {
392
+ exitObserved = true;
393
+ resolve({ code, signal });
394
+ });
395
+ },
396
+ );
397
+ const streamsEnded = Promise.all(
398
+ [child.stdout, child.stderr].map(
399
+ (stream) =>
400
+ new Promise<void>((resolve) => {
401
+ if (!stream) return resolve();
402
+ stream.once('end', () => resolve());
403
+ stream.once('close', () => resolve());
404
+ }),
405
+ ),
406
+ );
407
+
408
+ const handlers = new Map<NodeJS.Signals, () => void>();
409
+ const removeHandlers = (): void => {
410
+ for (const [signal, handler] of handlers) signalTarget.off(signal, handler);
411
+ handlers.clear();
412
+ };
413
+
414
+ for (const signal of DEPLOY_SIGNALS) {
415
+ const handler = (): void => {
416
+ // The deploy already finished (we are just draining pipes / reporting):
417
+ // there is nothing left to defer or abort, and reporting an abort here
418
+ // would turn a successful deploy into a failure.
419
+ if (exitObserved) return;
420
+ const openedAt = deferredAt.get(signal);
421
+ const { action, message, coalesced } = decideSignalResponse(
422
+ signal,
423
+ openedAt === undefined ? null : now() - openedAt,
424
+ );
425
+ if (action === 'defer') {
426
+ // A coalesced duplicate is the same signal arriving twice (process group
427
+ // plus a wrapper relay); log it once, not once per delivery. Anything
428
+ // outside the window is a fresh request, so it opens a new window and
429
+ // reports again instead of being muted for the rest of the deploy.
430
+ if (!coalesced) {
431
+ deferredAt.set(signal, now());
432
+ lastOutputAt = now();
433
+ stdout.write(`${message}\n`);
434
+ }
435
+ return;
436
+ }
437
+ if (aborting) return; // already tearing down; a repeat signal is a no-op
438
+ aborting = true;
439
+ stdout.write(`${message}\n`);
440
+ // Reap the whole tree (npx → cdk → node), not just the npx parent, so an
441
+ // abort cannot leave an orphaned CDK CLI still driving the stack.
442
+ void terminateProcessTree(child, ABORT_GRACE_MS);
443
+ };
444
+ handlers.set(signal, handler);
445
+ signalTarget.on(signal, handler);
446
+ }
447
+
448
+ try {
449
+ const { code, signal } = await exited;
450
+ // Let the pipes drain so a caller reading our stdout sees the child's final
451
+ // lines (CDK's summary / failure reason) before the terminal status.
452
+ await Promise.race([streamsEnded, delay(STREAM_FLUSH_GRACE_MS)]);
453
+ if (aborting) {
454
+ throw new DeployProcessError(
455
+ `${command} was aborted by an operator signal after ${formatElapsed(now() - startedAt)}`,
456
+ { exitCode: code, signal, aborted: true },
457
+ );
458
+ }
459
+ if (signal) {
460
+ throw new DeployProcessError(`${command} was terminated by signal ${signal}`, {
461
+ exitCode: code,
462
+ signal,
463
+ });
464
+ }
465
+ if (code !== 0) {
466
+ throw new DeployProcessError(
467
+ `${command} ${args.join(' ')} exited with code ${code}`,
468
+ { exitCode: code },
469
+ );
470
+ }
471
+ } finally {
472
+ if (heartbeat) clearInterval(heartbeat);
473
+ removeHandlers();
474
+ }
475
+ }
@@ -9,7 +9,7 @@ import { ensureSecrets, loadProductionEnv } from './ensure-secrets.js';
9
9
  import { applyExternalMigrations } from './external-migrations-step.js';
10
10
  import { trackCommand } from '../telemetry/trackCommand.js';
11
11
  import { getCdkTelemetryEnv } from './cdk-telemetry-env.js';
12
- import { runSync } from './run-command.js';
12
+ import { runStreaming, buildCdkDeployArgs } from './deploy-stream.js';
13
13
 
14
14
  export interface DeployOptions {
15
15
  cdkAppPath: string;
@@ -57,18 +57,18 @@ export async function deploy(options: DeployOptions) {
57
57
  console.log(' (This may take a few minutes on first deploy)');
58
58
  console.log(' - Backend API (Lambda + API Gateway)');
59
59
  console.log(' - Frontend hosting (S3 + CloudFront)');
60
-
60
+ console.log(' Streaming CloudFormation events below; the deploy keeps running if this');
61
+ console.log(' process is backgrounded (press Ctrl-C, or send SIGTERM twice, to abort).');
62
+
61
63
  try {
62
- runSync(
64
+ await runStreaming(
63
65
  "npx",
64
- [
65
- "cdk", "deploy",
66
- "--require-approval", "never",
67
- "--outputs-file", ".blocks-sandbox/outputs.json",
68
- "--context", `projectRoot=${options.projectRoot}`,
69
- ],
66
+ buildCdkDeployArgs({
67
+ projectRoot: options.projectRoot,
68
+ outputsFile: '.blocks-sandbox/outputs.json',
69
+ }),
70
70
  {
71
- stdio: 'inherit',
71
+ label: 'cdk deploy',
72
72
  cwd: options.projectRoot,
73
73
  env: {
74
74
  ...process.env,
@@ -78,7 +78,14 @@ export async function deploy(options: DeployOptions) {
78
78
  }
79
79
  );
80
80
  } catch (error) {
81
- console.error('\n❌ Deployment failed.');
81
+ // Terminal verdict on stdout: a caller that only captures stdout (the case
82
+ // that produced phantom failures) must still be able to tell a failed
83
+ // deploy from a killed process. This banner deliberately moved off stderr,
84
+ // so grepping stderr for this exact string no longer matches — the failure
85
+ // *reason* is still there. The CDK CLI keeps error-level output on stderr
86
+ // even under `--ci`, and the entrypoint prints the error itself with
87
+ // `console.error(error)`.
88
+ console.log('\n❌ Deployment failed.');
82
89
  throw error;
83
90
  }
84
91
 
@@ -2,7 +2,8 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert';
5
- import { resolveDevCorsOrigin, LOCALHOST_PATTERN } from './dev-server.js';
5
+ import { resolveDevCorsOrigin, LOCALHOST_PATTERN, buildDevCorsHeaders } from './dev-server.js';
6
+ import { CORS_MAX_AGE } from '../cors.js';
6
7
 
7
8
  describe('resolveDevCorsOrigin — dev server CORS', () => {
8
9
  it('reflects localhost origin back as-is', () => {
@@ -26,3 +27,27 @@ describe('resolveDevCorsOrigin — dev server CORS', () => {
26
27
  assert.strictEqual(resolveDevCorsOrigin('http://localhost.evil.com'), 'http://localhost:3000');
27
28
  });
28
29
  });
30
+
31
+ describe('buildDevCorsHeaders — dev server header set', () => {
32
+ it('shares one Max-Age value with the Lambda path so the two cannot drift', () => {
33
+ assert.strictEqual(buildDevCorsHeaders('http://localhost:3000')['Access-Control-Max-Age'], CORS_MAX_AGE);
34
+ });
35
+
36
+ it('sets Vary: Origin because Allow-Origin is reflected per request', () => {
37
+ assert.strictEqual(buildDevCorsHeaders('http://localhost:3000')['Vary'], 'Origin');
38
+ assert.strictEqual(buildDevCorsHeaders('https://evil.com')['Vary'], 'Origin');
39
+ });
40
+
41
+ it('reflects an allowed localhost origin', () => {
42
+ const headers = buildDevCorsHeaders('http://127.0.0.1:5173');
43
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], 'http://127.0.0.1:5173');
44
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], 'true');
45
+ });
46
+
47
+ it('does not reflect a non-localhost origin', () => {
48
+ assert.strictEqual(
49
+ buildDevCorsHeaders('https://evil.com')['Access-Control-Allow-Origin'],
50
+ 'http://localhost:3000'
51
+ );
52
+ });
53
+ });
@@ -112,4 +112,58 @@ startDevServer({ backendPath: '${join(tempDir, 'backend.ts').replace(/\\/g, '/')
112
112
  assert.ok(stdout.includes('[rpc-ok] testApi.pingVoid'), `Missing verbose success log. stdout: ${stdout}`);
113
113
  assert.ok(!stdout.includes('[rpc-err]'), `Unexpected RPC error log. stdout: ${stdout}`);
114
114
  });
115
+
116
+ it('returns a JSON usage hint (not an empty body) for a GET on the API path', async () => {
117
+ const port = await getAvailablePort();
118
+ tempDir = join(tmpdir(), `dev-404-test-${process.pid}-${Date.now()}`);
119
+ mkdirSync(tempDir, { recursive: true });
120
+ writeFileSync(join(tempDir, 'backend.ts'), `
121
+ export const testApi = {
122
+ pingVoid: async () => undefined,
123
+ };
124
+ `);
125
+ writeFileSync(join(tempDir, 'preload.mjs'), `
126
+ if (!process.loadEnvFile) {
127
+ process.loadEnvFile = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
128
+ }
129
+ `);
130
+ writeFileSync(join(tempDir, 'run-dev.ts'), `
131
+ import { startDevServer } from '${join(__dirname, 'dev-server.js').replace(/\\/g, '/')}';
132
+ startDevServer({ backendPath: '${join(tempDir, 'backend.ts').replace(/\\/g, '/')}', port: ${port} });
133
+ `);
134
+
135
+ const tsxBin = join(__dirname, '..', '..', '..', '..', 'node_modules', '.bin', 'tsx');
136
+ devProcess = spawn(tsxBin, ['--import', join(tempDir, 'preload.mjs'), join(tempDir, 'run-dev.ts')], {
137
+ cwd: tempDir,
138
+ env: { ...process.env, AWS_BLOCKS_DISABLE_TELEMETRY: '1', BLOCKS_DEV_QUIET: '1' },
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ });
141
+
142
+ let stdout = '';
143
+ let stderr = '';
144
+ devProcess.stdout?.on('data', chunk => { stdout += chunk.toString(); });
145
+ devProcess.stderr?.on('data', chunk => { stderr += chunk.toString(); });
146
+
147
+ // A GET on the API path (e.g. opening it in a browser) hits the API handler
148
+ // but not the POST branch — the exact case that used to return an empty 404.
149
+ const deadline = Date.now() + 15_000;
150
+ let response: Response | undefined;
151
+ let lastError: unknown;
152
+ while (!response && Date.now() < deadline) {
153
+ try {
154
+ response = await fetch(`http://127.0.0.1:${port}/aws-blocks/api`, { method: 'GET' });
155
+ } catch (error) {
156
+ lastError = error;
157
+ await new Promise(resolve => setTimeout(resolve, 100));
158
+ }
159
+ }
160
+
161
+ assert.ok(response, `Dev server did not respond: ${String(lastError)}\nstdout: ${stdout}\nstderr: ${stderr}`);
162
+ assert.strictEqual(response.status, 404);
163
+ assert.strictEqual(response.headers.get('content-type'), 'application/json');
164
+ const payload = await response.json() as { error?: string; expected?: { method?: string; path?: string } };
165
+ assert.match(payload.error ?? '', /POST/, `404 body should hint at the POST requirement: ${JSON.stringify(payload)}`);
166
+ assert.strictEqual(payload.expected?.method, 'POST');
167
+ assert.strictEqual(payload.expected?.path, '/aws-blocks/api');
168
+ });
115
169
  });