@aws-blocks/core 0.1.7 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/db-naming.d.ts +17 -5
  2. package/dist/db-naming.d.ts.map +1 -1
  3. package/dist/db-naming.js +18 -6
  4. package/dist/db-naming.test.js +44 -3
  5. package/dist/hosting.d.ts +49 -0
  6. package/dist/hosting.d.ts.map +1 -1
  7. package/dist/hosting.js +47 -8
  8. package/dist/hosting.test.js +60 -0
  9. package/dist/scripts/deploy.d.ts.map +1 -1
  10. package/dist/scripts/deploy.js +4 -2
  11. package/dist/scripts/dev-server-reclaim.test.d.ts +2 -0
  12. package/dist/scripts/dev-server-reclaim.test.d.ts.map +1 -0
  13. package/dist/scripts/dev-server-reclaim.test.js +352 -0
  14. package/dist/scripts/dev-server.d.ts +168 -0
  15. package/dist/scripts/dev-server.d.ts.map +1 -1
  16. package/dist/scripts/dev-server.js +357 -25
  17. package/dist/scripts/ensure-secrets.d.ts +5 -2
  18. package/dist/scripts/ensure-secrets.d.ts.map +1 -1
  19. package/dist/scripts/ensure-secrets.js +14 -6
  20. package/dist/scripts/external-migrations-step.d.ts.map +1 -1
  21. package/dist/scripts/external-migrations-step.js +5 -1
  22. package/dist/scripts/index.d.ts +1 -1
  23. package/dist/scripts/index.d.ts.map +1 -1
  24. package/dist/scripts/index.js +1 -1
  25. package/dist/scripts/process-tree.d.ts +41 -2
  26. package/dist/scripts/process-tree.d.ts.map +1 -1
  27. package/dist/scripts/process-tree.js +83 -3
  28. package/dist/scripts/sandbox.d.ts.map +1 -1
  29. package/dist/scripts/sandbox.js +10 -1
  30. package/dist/scripts/stack-id.d.ts +25 -0
  31. package/dist/scripts/stack-id.d.ts.map +1 -1
  32. package/dist/scripts/stack-id.js +25 -0
  33. package/dist/scripts/stack-id.test.js +51 -1
  34. package/dist/telemetry/client.d.ts +3 -1
  35. package/dist/telemetry/client.d.ts.map +1 -1
  36. package/dist/telemetry/client.js +20 -24
  37. package/dist/telemetry/telemetry-send-worker.d.ts +2 -0
  38. package/dist/telemetry/telemetry-send-worker.d.ts.map +1 -0
  39. package/dist/telemetry/telemetry-send-worker.js +58 -0
  40. package/dist/telemetry/telemetry.test.js +77 -1
  41. package/dist/telemetry/trackCommand.d.ts +1 -1
  42. package/dist/telemetry/trackCommand.js +3 -3
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.d.ts.map +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
  47. package/src/db-naming.test.ts +50 -5
  48. package/src/db-naming.ts +18 -6
  49. package/src/hosting.test.ts +79 -0
  50. package/src/hosting.ts +105 -12
  51. package/src/scripts/deploy.ts +4 -2
  52. package/src/scripts/dev-server-reclaim.test.ts +430 -0
  53. package/src/scripts/dev-server.ts +428 -25
  54. package/src/scripts/ensure-secrets.ts +17 -6
  55. package/src/scripts/external-migrations-step.ts +5 -1
  56. package/src/scripts/index.ts +1 -1
  57. package/src/scripts/process-tree.ts +101 -3
  58. package/src/scripts/sandbox.ts +10 -1
  59. package/src/scripts/stack-id.test.ts +61 -1
  60. package/src/scripts/stack-id.ts +26 -0
  61. package/src/telemetry/client.ts +22 -30
  62. package/src/telemetry/telemetry-send-worker.ts +60 -0
  63. package/src/telemetry/telemetry.test.ts +91 -1
  64. package/src/telemetry/trackCommand.ts +3 -3
  65. package/src/version.ts +1 -1
@@ -41,13 +41,14 @@ interface TreeKillResult {
41
41
  * denied): such a run did NOT reap the tree, so the caller must fall back to a
42
42
  * direct `child.kill` rather than treat the leak as handled. (`child.kill`
43
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.
44
+ * strictly correct — we never silently swallow a failed tree-kill.) Runs with a
45
+ * 3s `timeout` so a wedged `taskkill` can't stall teardown; a timed-out run
46
+ * surfaces as `{error}` and degrades to the fallback. Never throws.
46
47
  */
47
48
  export function windowsTreeKill(
48
49
  pid: number,
49
50
  runner: (command: string, args: readonly string[]) => TreeKillResult = (command, args) =>
50
- spawnSync(command, args as string[], { stdio: 'ignore', windowsHide: true }),
51
+ spawnSync(command, args as string[], { stdio: 'ignore', windowsHide: true, timeout: 3000 }),
51
52
  ): boolean {
52
53
  try {
53
54
  const { status, error } = runner('taskkill', ['/T', '/F', '/PID', String(pid)]);
@@ -113,6 +114,103 @@ export function killFrontendTree(
113
114
  }
114
115
  }
115
116
 
117
+ /** Subset of a `spawnSync` result that {@link findListenerPids} inspects. */
118
+ interface CommandOutput {
119
+ stdout?: string | null;
120
+ status?: number | null;
121
+ error?: Error;
122
+ }
123
+
124
+ /**
125
+ * Find the PIDs of processes holding a TCP *listener* on `port`, so a fresh dev
126
+ * server can reclaim a port left bound by a crashed / SIGKILL'd predecessor (its
127
+ * orphaned backend, or a detached Vite grandchild) instead of colliding on it.
128
+ * This mirrors the `lsof -ti:<port>` discovery the `cleanup` script already uses
129
+ * — it does NOT introduce a new port-to-PID mechanism.
130
+ *
131
+ * - **POSIX**: `lsof -ti tcp:<port> -sTCP:LISTEN` — `-t` prints bare PIDs and the
132
+ * `-sTCP:LISTEN` state filter restricts the match to the *listener*, so a
133
+ * transient client socket on the same port is never targeted.
134
+ * - **Windows**: `netstat -ano -p tcp`, keeping the trailing PID column of
135
+ * `LISTENING` rows whose local address ends in `:<port>`.
136
+ *
137
+ * Best-effort and never throws: a missing tool, a non-zero exit ("nothing is
138
+ * listening"), or unparseable output all yield `[]`. The `spawnSync` runs with a
139
+ * 3s `timeout` so a hung `lsof`/`netstat` (e.g. an unresponsive NFS mount) can't
140
+ * block the event loop during startup — a timed-out probe returns `{error}`,
141
+ * which the `catch` degrades to `[]`. PIDs `<= 1` are dropped
142
+ * defensively (never target init / the whole current group). `runner`/`platform`
143
+ * are injected for tests.
144
+ */
145
+ export function findListenerPids(
146
+ port: number,
147
+ runner: (command: string, args: readonly string[]) => CommandOutput = (command, args) =>
148
+ spawnSync(command, args as string[], { encoding: 'utf-8', windowsHide: true, timeout: 3000 }),
149
+ platform: NodeJS.Platform = process.platform,
150
+ ): number[] {
151
+ try {
152
+ const pids = new Set<number>();
153
+ if (platform === 'win32') {
154
+ const { stdout } = runner('netstat', ['-ano', '-p', 'tcp']);
155
+ if (!stdout) return [];
156
+ for (const line of stdout.split(/\r?\n/)) {
157
+ if (!/LISTENING/i.test(line)) continue;
158
+ // Columns: Proto Local-Address Foreign-Address State PID
159
+ const cols = line.trim().split(/\s+/);
160
+ const local = cols[1] ?? '';
161
+ if (!local.endsWith(`:${port}`)) continue;
162
+ const pid = Number(cols[cols.length - 1]);
163
+ if (Number.isInteger(pid) && pid > 1) pids.add(pid);
164
+ }
165
+ return [...pids];
166
+ }
167
+ const { stdout } = runner('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN']);
168
+ if (!stdout) return [];
169
+ for (const token of stdout.split(/\s+/)) {
170
+ const pid = Number(token.trim());
171
+ if (Number.isInteger(pid) && pid > 1) pids.add(pid);
172
+ }
173
+ return [...pids];
174
+ } catch {
175
+ return [];
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Force-terminate whatever process (and, on POSIX, its process group) currently
181
+ * holds a port — used by the dev server's startup / EADDRINUSE *reclaim* path on
182
+ * a PID discovered via {@link findListenerPids}, i.e. a process this dev server
183
+ * did NOT spawn. Reuses {@link killFrontendTree} (POSIX `-pid` group kill /
184
+ * Windows `taskkill /T`, with a direct-`kill` fallback) so reclaim reaps exactly
185
+ * like our own frontend teardown — no bespoke kill mechanism. Best-effort; never
186
+ * throws (a since-exited PID just yields ESRCH, swallowed by killFrontendTree).
187
+ */
188
+ export function killListenerTree(
189
+ pid: number,
190
+ signal: NodeJS.Signals = 'SIGTERM',
191
+ platform: NodeJS.Platform = process.platform,
192
+ killFn: (pid: number, signal: NodeJS.Signals) => void = (p, s) => process.kill(p, s),
193
+ winTreeKill: (pid: number) => boolean = windowsTreeKill,
194
+ ): void {
195
+ killFrontendTree(
196
+ {
197
+ pid,
198
+ kill: (s) => {
199
+ try {
200
+ process.kill(pid, s ?? signal);
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ }
205
+ },
206
+ },
207
+ signal,
208
+ platform,
209
+ killFn,
210
+ winTreeKill,
211
+ );
212
+ }
213
+
116
214
  /** Child surface {@link terminateProcessTree} needs: a tree to kill plus exit state to await. */
117
215
  export interface AwaitableChild extends KillableProcess {
118
216
  exitCode: number | null;
@@ -51,7 +51,9 @@ export async function startSandbox(options: SandboxOptions) {
51
51
 
52
52
  // Provision connection string to SSM SecureString.
53
53
  // On first deploy, creates the parameter. On subsequent deploys, updates if changed.
54
- const secrets = await ensureSecrets('sandbox');
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());
55
57
  if (secrets.created.length > 0) {
56
58
  console.log(`🔐 Created secrets: ${secrets.created.join(', ')}`);
57
59
  }
@@ -75,6 +77,13 @@ export async function startSandbox(options: SandboxOptions) {
75
77
  "npm",
76
78
  [
77
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",
78
87
  "--require-approval", "never",
79
88
  "--outputs-file", `${outDir}/outputs.json`,
80
89
  "--context", `projectRoot=${process.cwd()}`,
@@ -7,7 +7,7 @@ import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { tmpdir } from 'node:os';
9
9
 
10
- import { getStackId, getSandboxId } from './stack-id.js';
10
+ import { getStackId, getSandboxId, getStackName } from './stack-id.js';
11
11
 
12
12
  describe('getStackId', () => {
13
13
  let tmpDir: string;
@@ -61,3 +61,63 @@ describe('getSandboxId', () => {
61
61
  assert.strictEqual(getSandboxId(tmpDir), 'alice-abc123');
62
62
  });
63
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
+ });
@@ -38,6 +38,11 @@ export function getStackId(projectRoot?: string): string {
38
38
  * Get or create a per-machine sandbox identifier.
39
39
  * Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
40
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.
41
46
  */
42
47
  export function getSandboxId(projectRoot?: string): string {
43
48
  const root = projectRoot || process.cwd();
@@ -52,6 +57,27 @@ export function getSandboxId(projectRoot?: string): string {
52
57
  return id;
53
58
  }
54
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
+
55
81
  function getUsername(): string {
56
82
  try {
57
83
  return execSync('git config user.name', { encoding: 'utf-8' }).trim();
@@ -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
- * Fire-and-forget: no retry, 500ms timeout, all errors silently swallowed.
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 url = new URL(endpoint);
198
- const isHttps = url.protocol === 'https:';
199
- const requestFn = isHttps ? httpsRequest : httpRequest;
200
-
201
- const req = requestFn(
202
- {
203
- hostname: url.hostname,
204
- port: url.port || (isHttps ? '443' : '80'),
205
- path: url.pathname + url.search,
206
- method: 'POST',
207
- headers: {
208
- 'Content-Type': 'application/json',
209
- 'Content-Length': Buffer.byteLength(payload),
210
- },
211
- timeout: TIMEOUT_MS,
212
- },
213
- (res) => {
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
  }
@@ -0,0 +1,60 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Telemetry send worker — spawned as a detached subprocess.
6
+ * Reads JSON payload from stdin, POSTs it to the endpoint (argv[2]).
7
+ *
8
+ * Uses only Node built-ins — no project imports — so the compiled .js
9
+ * runs with bare `node` (no tsx needed).
10
+ */
11
+
12
+ import { request as httpsRequest } from 'node:https';
13
+ import { request as httpRequest } from 'node:http';
14
+
15
+ const TIMEOUT_MS = 500;
16
+ const debug = (process.env.NODE_DEBUG || '').includes('blocks-telemetry');
17
+
18
+ const endpoint = process.argv[2];
19
+ if (!endpoint) process.exit(1);
20
+
21
+ let payload = '';
22
+ process.stdin.setEncoding('utf-8');
23
+ process.stdin.on('data', (chunk: string) => { payload += chunk; });
24
+ process.stdin.on('end', () => {
25
+ try {
26
+ const url = new URL(endpoint);
27
+ const isHttps = url.protocol === 'https:';
28
+ const requestFn = isHttps ? httpsRequest : httpRequest;
29
+
30
+ const req = requestFn({
31
+ hostname: url.hostname,
32
+ port: url.port || (isHttps ? '443' : '80'),
33
+ path: url.pathname + url.search,
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ 'Content-Length': Buffer.byteLength(payload),
38
+ },
39
+ timeout: TIMEOUT_MS,
40
+ }, (res) => {
41
+ res.resume();
42
+ if (debug) process.stderr.write(`BLOCKS-TELEMETRY: sent (status=${res.statusCode})\n`);
43
+ process.exit(0);
44
+ });
45
+
46
+ req.on('error', (e) => {
47
+ if (debug) process.stderr.write(`BLOCKS-TELEMETRY: error: ${(e as Error).message}\n`);
48
+ process.exit(1);
49
+ });
50
+ req.on('timeout', () => {
51
+ if (debug) process.stderr.write(`BLOCKS-TELEMETRY: timed out\n`);
52
+ req.destroy();
53
+ process.exit(1);
54
+ });
55
+ req.write(payload);
56
+ req.end();
57
+ } catch {
58
+ process.exit(1);
59
+ }
60
+ });
@@ -11,7 +11,7 @@ import { isCI, detectOS, detectNodeVersion, detectPackageManager, detectAgent, c
11
11
  import { trackCommand, classifyError } from './trackCommand.js';
12
12
  import { buildAndSendEvent, buildEvent, sendEvent, getTelemetryFilePath } from './client.js';
13
13
  import { getInstallationId, getProjectId, generateEventId } from './identifiers.js';
14
- import { spawnSync } from 'node:child_process';
14
+ import { spawnSync, spawn as spawnChild } from 'node:child_process';
15
15
  import type { BlocksTelemetryEvent } from './types.js';
16
16
  import { Scope, OFFICIAL_BB_NAMES } from '../common/index.js';
17
17
  import type { ScopeParent } from '../common/index.js';
@@ -533,6 +533,96 @@ describe('telemetry/client', () => {
533
533
  });
534
534
  });
535
535
 
536
+ describe('telemetry/send-worker', () => {
537
+ it('worker POSTs payload from stdin to endpoint', async () => {
538
+ const received: string[] = [];
539
+
540
+ const server: Server = await new Promise((resolve) => {
541
+ const s = createServer((req, res) => {
542
+ let body = '';
543
+ req.on('data', (chunk) => { body += chunk; });
544
+ req.on('end', () => {
545
+ received.push(body);
546
+ res.writeHead(200);
547
+ res.end();
548
+ });
549
+ });
550
+ s.listen(0, '127.0.0.1', () => resolve(s));
551
+ });
552
+
553
+ const addr = server.address() as { port: number };
554
+ const endpoint = `http://127.0.0.1:${addr.port}/collect`;
555
+ const payload = JSON.stringify({ test: true, command: 'dev' });
556
+ const workerPath = join(__dirname, 'telemetry-send-worker.js');
557
+
558
+ const exitCode = await new Promise<number | null>((resolve) => {
559
+ const proc = spawnChild(process.execPath, [workerPath, endpoint], {
560
+ stdio: ['pipe', 'ignore', 'ignore'],
561
+ env: { ...process.env, NODE_OPTIONS: '' },
562
+ });
563
+ proc.stdin!.write(payload);
564
+ proc.stdin!.end();
565
+ proc.on('close', (code) => resolve(code));
566
+ });
567
+
568
+ assert.strictEqual(exitCode, 0);
569
+ assert.strictEqual(received.length, 1);
570
+ assert.deepStrictEqual(JSON.parse(received[0]), { test: true, command: 'dev' });
571
+
572
+ server.close();
573
+ });
574
+
575
+ it('worker exits with 1 on unreachable endpoint', async () => {
576
+ const payload = JSON.stringify({ test: true });
577
+ const workerPath = join(__dirname, 'telemetry-send-worker.js');
578
+
579
+ const exitCode = await new Promise<number | null>((resolve) => {
580
+ const proc = spawnChild(process.execPath, [workerPath, 'http://127.0.0.1:1/unreachable'], {
581
+ stdio: ['pipe', 'ignore', 'ignore'],
582
+ env: { ...process.env, NODE_OPTIONS: '' },
583
+ });
584
+ proc.stdin!.write(payload);
585
+ proc.stdin!.end();
586
+ proc.on('close', (code) => resolve(code));
587
+ });
588
+
589
+ assert.strictEqual(exitCode, 1);
590
+ });
591
+
592
+ it('worker writes debug output to stderr when NODE_DEBUG is set', async () => {
593
+ const server: Server = await new Promise((resolve) => {
594
+ const s = createServer((req, res) => {
595
+ let body = '';
596
+ req.on('data', (chunk) => { body += chunk; });
597
+ req.on('end', () => { res.writeHead(200); res.end(); });
598
+ });
599
+ s.listen(0, '127.0.0.1', () => resolve(s));
600
+ });
601
+
602
+ const addr = server.address() as { port: number };
603
+ const endpoint = `http://127.0.0.1:${addr.port}/collect`;
604
+ const payload = JSON.stringify({ test: true });
605
+ const workerPath = join(__dirname, 'telemetry-send-worker.js');
606
+
607
+ const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
608
+ const proc = spawnChild(process.execPath, [workerPath, endpoint], {
609
+ stdio: ['pipe', 'ignore', 'pipe'],
610
+ env: { ...process.env, NODE_OPTIONS: '', NODE_DEBUG: 'blocks-telemetry' },
611
+ });
612
+ let stderr = '';
613
+ proc.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
614
+ proc.stdin!.write(payload);
615
+ proc.stdin!.end();
616
+ proc.on('close', (code) => resolve({ code, stderr }));
617
+ });
618
+
619
+ assert.strictEqual(result.code, 0);
620
+ assert.ok(result.stderr.includes('BLOCKS-TELEMETRY: sent (status=200)'), `Expected debug output, got: ${result.stderr}`);
621
+
622
+ server.close();
623
+ });
624
+ });
625
+
536
626
  describe('telemetry/trackCommand integration', () => {
537
627
  const originalEnv = { ...process.env };
538
628
 
@@ -22,10 +22,10 @@ export function classifyError(error: unknown): { code: string; phase: string } {
22
22
  if (msg.includes('cdk synth') || msg.includes('synthesis')) {
23
23
  return { code: 'CDK_SYNTH_FAILED', phase: 'synth' };
24
24
  }
25
- if (msg.includes('cdk deploy') || msg.includes('deployment failed')) {
25
+ if (msg.includes('cdk deploy') || msg.includes('deployment failed') || (msg.includes('cdk') && msg.includes('deploy') && msg.includes('exited with code'))) {
26
26
  return { code: 'CDK_DEPLOY_FAILED', phase: 'deploy' };
27
27
  }
28
- if (msg.includes('cdk destroy') || msg.includes('destroy failed')) {
28
+ if (msg.includes('cdk destroy') || msg.includes('destroy failed') || (msg.includes('cdk') && msg.includes('destroy') && msg.includes('exited with code'))) {
29
29
  return { code: 'CDK_DESTROY_FAILED', phase: 'destroy' };
30
30
  }
31
31
  if (msg.includes('npm install') || msg.includes('npm err')) {
@@ -52,7 +52,7 @@ export function classifyError(error: unknown): { code: string; phase: string } {
52
52
  *
53
53
  * Measures wall-clock duration, classifies errors, and sends a single telemetry event
54
54
  * after the command completes (success or failure). The telemetry send is fire-and-forget
55
- * and bounded by a 5s timeout — it will never delay the command or affect its exit code.
55
+ * and bounded by a 500ms timeout — it will never delay the command or affect its exit code.
56
56
  *
57
57
  * If telemetry is disabled (via env var or config), the function is executed directly
58
58
  * with zero overhead.
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.1.7';
2
+ export const CORE_VERSION = '0.1.11';