@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
@@ -1 +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"}
1
+ {"version":3,"file":"process-tree.d.ts","sourceRoot":"","sources":["../../src/scripts/process-tree.ts"],"names":[],"mappings":"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"}
@@ -19,10 +19,11 @@ import { spawnSync } from 'node:child_process';
19
19
  * denied): such a run did NOT reap the tree, so the caller must fall back to a
20
20
  * direct `child.kill` rather than treat the leak as handled. (`child.kill`
21
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.
22
+ * strictly correct — we never silently swallow a failed tree-kill.) Runs with a
23
+ * 3s `timeout` so a wedged `taskkill` can't stall teardown; a timed-out run
24
+ * surfaces as `{error}` and degrades to the fallback. Never throws.
24
25
  */
25
- export function windowsTreeKill(pid, runner = (command, args) => spawnSync(command, args, { stdio: 'ignore', windowsHide: true })) {
26
+ export function windowsTreeKill(pid, runner = (command, args) => spawnSync(command, args, { stdio: 'ignore', windowsHide: true, timeout: 3000 })) {
26
27
  try {
27
28
  const { status, error } = runner('taskkill', ['/T', '/F', '/PID', String(pid)]);
28
29
  // Couldn't even spawn taskkill (e.g. not on PATH) → not handled; fall back.
@@ -84,6 +85,85 @@ export function killFrontendTree(child, signal = 'SIGTERM', platform = process.p
84
85
  // Process already exited; nothing to do.
85
86
  }
86
87
  }
88
+ /**
89
+ * Find the PIDs of processes holding a TCP *listener* on `port`, so a fresh dev
90
+ * server can reclaim a port left bound by a crashed / SIGKILL'd predecessor (its
91
+ * orphaned backend, or a detached Vite grandchild) instead of colliding on it.
92
+ * This mirrors the `lsof -ti:<port>` discovery the `cleanup` script already uses
93
+ * — it does NOT introduce a new port-to-PID mechanism.
94
+ *
95
+ * - **POSIX**: `lsof -ti tcp:<port> -sTCP:LISTEN` — `-t` prints bare PIDs and the
96
+ * `-sTCP:LISTEN` state filter restricts the match to the *listener*, so a
97
+ * transient client socket on the same port is never targeted.
98
+ * - **Windows**: `netstat -ano -p tcp`, keeping the trailing PID column of
99
+ * `LISTENING` rows whose local address ends in `:<port>`.
100
+ *
101
+ * Best-effort and never throws: a missing tool, a non-zero exit ("nothing is
102
+ * listening"), or unparseable output all yield `[]`. The `spawnSync` runs with a
103
+ * 3s `timeout` so a hung `lsof`/`netstat` (e.g. an unresponsive NFS mount) can't
104
+ * block the event loop during startup — a timed-out probe returns `{error}`,
105
+ * which the `catch` degrades to `[]`. PIDs `<= 1` are dropped
106
+ * defensively (never target init / the whole current group). `runner`/`platform`
107
+ * are injected for tests.
108
+ */
109
+ export function findListenerPids(port, runner = (command, args) => spawnSync(command, args, { encoding: 'utf-8', windowsHide: true, timeout: 3000 }), platform = process.platform) {
110
+ try {
111
+ const pids = new Set();
112
+ if (platform === 'win32') {
113
+ const { stdout } = runner('netstat', ['-ano', '-p', 'tcp']);
114
+ if (!stdout)
115
+ return [];
116
+ for (const line of stdout.split(/\r?\n/)) {
117
+ if (!/LISTENING/i.test(line))
118
+ continue;
119
+ // Columns: Proto Local-Address Foreign-Address State PID
120
+ const cols = line.trim().split(/\s+/);
121
+ const local = cols[1] ?? '';
122
+ if (!local.endsWith(`:${port}`))
123
+ continue;
124
+ const pid = Number(cols[cols.length - 1]);
125
+ if (Number.isInteger(pid) && pid > 1)
126
+ pids.add(pid);
127
+ }
128
+ return [...pids];
129
+ }
130
+ const { stdout } = runner('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN']);
131
+ if (!stdout)
132
+ return [];
133
+ for (const token of stdout.split(/\s+/)) {
134
+ const pid = Number(token.trim());
135
+ if (Number.isInteger(pid) && pid > 1)
136
+ pids.add(pid);
137
+ }
138
+ return [...pids];
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ }
144
+ /**
145
+ * Force-terminate whatever process (and, on POSIX, its process group) currently
146
+ * holds a port — used by the dev server's startup / EADDRINUSE *reclaim* path on
147
+ * a PID discovered via {@link findListenerPids}, i.e. a process this dev server
148
+ * did NOT spawn. Reuses {@link killFrontendTree} (POSIX `-pid` group kill /
149
+ * Windows `taskkill /T`, with a direct-`kill` fallback) so reclaim reaps exactly
150
+ * like our own frontend teardown — no bespoke kill mechanism. Best-effort; never
151
+ * throws (a since-exited PID just yields ESRCH, swallowed by killFrontendTree).
152
+ */
153
+ export function killListenerTree(pid, signal = 'SIGTERM', platform = process.platform, killFn = (p, s) => process.kill(p, s), winTreeKill = windowsTreeKill) {
154
+ killFrontendTree({
155
+ pid,
156
+ kill: (s) => {
157
+ try {
158
+ process.kill(pid, s ?? signal);
159
+ return true;
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ },
165
+ }, signal, platform, killFn, winTreeKill);
166
+ }
87
167
  const defaultSleep = (ms) => new Promise((res) => {
88
168
  setTimeout(res, ms).unref?.();
89
169
  });
@@ -1 +1 @@
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,+BAyLzD;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"}
@@ -41,7 +41,9 @@ export async function startSandbox(options) {
41
41
  process.env.BLOCKS_STAGE = 'sandbox';
42
42
  // Provision connection string to SSM SecureString.
43
43
  // On first deploy, creates the parameter. On subsequent deploys, updates if changed.
44
- 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());
45
47
  if (secrets.created.length > 0) {
46
48
  console.log(`🔐 Created secrets: ${secrets.created.join(', ')}`);
47
49
  }
@@ -59,6 +61,13 @@ export async function startSandbox(options) {
59
61
  try {
60
62
  runSync("npm", [
61
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",
62
71
  "--require-approval", "never",
63
72
  "--outputs-file", `${outDir}/outputs.json`,
64
73
  "--context", `projectRoot=${process.cwd()}`,
@@ -7,6 +7,31 @@ export declare function getStackId(projectRoot?: string): string;
7
7
  * Get or create a per-machine sandbox identifier.
8
8
  * Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
9
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.
10
15
  */
11
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;
12
37
  //# sourceMappingURL=stack-id.d.ts.map
@@ -1 +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;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAWzD"}
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"}
@@ -29,6 +29,11 @@ export function getStackId(projectRoot) {
29
29
  * Get or create a per-machine sandbox identifier.
30
30
  * Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
31
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.
32
37
  */
33
38
  export function getSandboxId(projectRoot) {
34
39
  const root = projectRoot || process.cwd();
@@ -44,6 +49,26 @@ export function getSandboxId(projectRoot) {
44
49
  writeFileSync(filePath, id);
45
50
  return id;
46
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
+ }
47
72
  function getUsername() {
48
73
  try {
49
74
  return execSync('git config user.name', { encoding: 'utf-8' }).trim();
@@ -5,7 +5,7 @@ import assert from 'node:assert';
5
5
  import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { tmpdir } from 'node:os';
8
- import { getStackId, getSandboxId } from './stack-id.js';
8
+ import { getStackId, getSandboxId, getStackName } from './stack-id.js';
9
9
  describe('getStackId', () => {
10
10
  let tmpDir;
11
11
  afterEach(() => {
@@ -52,3 +52,53 @@ describe('getSandboxId', () => {
52
52
  assert.strictEqual(getSandboxId(tmpDir), 'alice-abc123');
53
53
  });
54
54
  });
55
+ describe('getStackName', () => {
56
+ let tmpDir;
57
+ afterEach(() => {
58
+ if (tmpDir)
59
+ rmSync(tmpDir, { recursive: true, force: true });
60
+ });
61
+ it('production is <stackId>-prod', () => {
62
+ tmpDir = join(tmpdir(), `stack-name-prod-${Date.now()}`);
63
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
64
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'my-app-k7x2mf' }));
65
+ assert.strictEqual(getStackName({ sandbox: false, projectRoot: tmpDir }), 'my-app-k7x2mf-prod');
66
+ });
67
+ it('sandbox is <stackId>-<sandboxId>', () => {
68
+ tmpDir = join(tmpdir(), `stack-name-sbx-${Date.now()}`);
69
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
70
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'my-app-k7x2mf' }));
71
+ mkdirSync(join(tmpDir, '.blocks-sandbox'), { recursive: true });
72
+ writeFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'alice-0d7e1c');
73
+ assert.strictEqual(getStackName({ sandbox: true, projectRoot: tmpDir }), 'my-app-k7x2mf-alice-0d7e1c');
74
+ });
75
+ it('throws actionable error when config is missing (fail fast, no silent fallback)', () => {
76
+ tmpDir = join(tmpdir(), `stack-name-missing-${Date.now()}`);
77
+ mkdirSync(tmpDir, { recursive: true });
78
+ assert.throws(() => getStackName({ sandbox: false, projectRoot: tmpDir }), /\.blocks\/config\.json not found/);
79
+ });
80
+ });
81
+ describe('getStackName sandbox id (get-or-create)', () => {
82
+ let tmpDir;
83
+ afterEach(() => {
84
+ if (tmpDir)
85
+ rmSync(tmpDir, { recursive: true, force: true });
86
+ });
87
+ it('creates and persists sandbox-id.txt on first call when missing', () => {
88
+ tmpDir = join(tmpdir(), `stack-name-getorcreate-${Date.now()}`);
89
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
90
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-app' }));
91
+ // No .blocks-sandbox dir yet — getStackName creates the id rather than throwing.
92
+ const name = getStackName({ sandbox: true, projectRoot: tmpDir });
93
+ assert.match(name, /^test-app-[a-z0-9]+-[a-f0-9]{6}$/);
94
+ // Persisted so later callers/processes resolve the identical name.
95
+ const stored = readFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'utf-8').trim();
96
+ assert.strictEqual(name, `test-app-${stored}`);
97
+ });
98
+ it('reuses the same id across calls', () => {
99
+ tmpDir = join(tmpdir(), `stack-name-getorcreate-idem-${Date.now()}`);
100
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
101
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-app' }));
102
+ assert.strictEqual(getStackName({ sandbox: true, projectRoot: tmpDir }), getStackName({ sandbox: true, projectRoot: tmpDir }));
103
+ });
104
+ });
@@ -52,7 +52,9 @@ export declare function buildAndSendEvent(opts: BuildAndSendEventOptions): void;
52
52
  /**
53
53
  * Send a pre-built telemetry event to the collection endpoint.
54
54
  *
55
- * Fire-and-forget: no retry, 500ms timeout, all errors silently swallowed.
55
+ * Spawns a detached subprocess that performs the HTTPS POST independently of
56
+ * the parent CLI process. This ensures the request completes even when the
57
+ * parent exits on failure paths before an in-process request would flush.
56
58
  * Debug output available via `NODE_DEBUG=blocks-telemetry`.
57
59
  */
58
60
  export declare function sendEvent(event: BlocksTelemetryEvent): void;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/telemetry/client.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,oBAAoB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAajF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,GAAG,SAAS,CAWzD;AAwBD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,wBAAwB,GAAG,oBAAoB,CAwD/E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,wBAAwB,GAAG,IAAI,CAatE;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,oBAAoB,GAAG,IAAI,CAyC3D"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/telemetry/client.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,oBAAoB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAYjF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,GAAG,SAAS,CAWzD;AAwBD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,wBAAwB,GAAG,oBAAoB,CAwD/E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,wBAAwB,GAAG,IAAI,CAatE;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,oBAAoB,GAAG,IAAI,CAgC3D"}
@@ -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';
@@ -11,7 +11,6 @@ import { getInstallationId, getProjectId, generateEventId } from './identifiers.
11
11
  // Debug log: writes to stderr via NODE_DEBUG=blocks-telemetry (developer-facing, for troubleshooting)
12
12
  const debug = debuglog('blocks-telemetry');
13
13
  const DEFAULT_ENDPOINT = 'https://blocks-telemetry.us-east-1.api.aws/metrics';
14
- const TIMEOUT_MS = 500;
15
14
  const TELEMETRY_VERSION = '1.0.0';
16
15
  function getEndpoint() {
17
16
  return process.env.BLOCKS_TELEMETRY_ENDPOINT || DEFAULT_ENDPOINT;
@@ -172,7 +171,9 @@ export function buildAndSendEvent(opts) {
172
171
  /**
173
172
  * Send a pre-built telemetry event to the collection endpoint.
174
173
  *
175
- * Fire-and-forget: no retry, 500ms timeout, all errors silently swallowed.
174
+ * Spawns a detached subprocess that performs the HTTPS POST independently of
175
+ * the parent CLI process. This ensures the request completes even when the
176
+ * parent exits on failure paths before an in-process request would flush.
176
177
  * Debug output available via `NODE_DEBUG=blocks-telemetry`.
177
178
  */
178
179
  export function sendEvent(event) {
@@ -187,27 +188,22 @@ export function sendEvent(event) {
187
188
  catch { }
188
189
  }
189
190
  debug('sending event to %s (%d bytes)', endpoint, Buffer.byteLength(payload));
190
- const url = new URL(endpoint);
191
- const isHttps = url.protocol === 'https:';
192
- const requestFn = isHttps ? httpsRequest : httpRequest;
193
- const req = requestFn({
194
- hostname: url.hostname,
195
- port: url.port || (isHttps ? '443' : '80'),
196
- path: url.pathname + url.search,
197
- method: 'POST',
198
- headers: {
199
- 'Content-Type': 'application/json',
200
- 'Content-Length': Buffer.byteLength(payload),
201
- },
202
- timeout: TIMEOUT_MS,
203
- }, (res) => {
204
- debug('event sent (status=%d)', res.statusCode);
205
- res.resume();
191
+ const dir = path.dirname(fileURLToPath(import.meta.url));
192
+ const workerPath = path.join(dir, 'telemetry-send-worker.js');
193
+ const child = spawn(process.execPath, [workerPath, endpoint], {
194
+ detached: true,
195
+ stdio: ['pipe', 'ignore', 'ignore'],
196
+ // Clear NODE_OPTIONS so inherited flags (e.g. --conditions=cdk) don't interfere
197
+ env: { ...process.env, NODE_OPTIONS: '' },
206
198
  });
207
- req.on('error', (err) => { debug('send failed: %s', err.message); });
208
- req.on('timeout', () => { debug('send timed out'); req.destroy(); });
209
- req.write(payload);
210
- req.end();
199
+ child.stdin.on('error', (err) => { debug('stdin write failed: %s', err.message); });
200
+ // Payload is small (<1KB JSON) so it fits in the kernel pipe buffer (~64KB)
201
+ // and survives the parent closing its fd on exit.
202
+ child.stdin.write(payload);
203
+ child.stdin.end();
204
+ child.on('error', (err) => { debug('spawn failed: %s', err.message); });
205
+ child.unref();
206
+ debug('spawned telemetry subprocess (pid=%d)', child.pid);
211
207
  }
212
208
  catch {
213
209
  // Telemetry must never throw or affect the user's command
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=telemetry-send-worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry-send-worker.d.ts","sourceRoot":"","sources":["../../src/telemetry/telemetry-send-worker.ts"],"names":[],"mappings":""}
@@ -0,0 +1,58 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Telemetry send worker — spawned as a detached subprocess.
5
+ * Reads JSON payload from stdin, POSTs it to the endpoint (argv[2]).
6
+ *
7
+ * Uses only Node built-ins — no project imports — so the compiled .js
8
+ * runs with bare `node` (no tsx needed).
9
+ */
10
+ import { request as httpsRequest } from 'node:https';
11
+ import { request as httpRequest } from 'node:http';
12
+ const TIMEOUT_MS = 500;
13
+ const debug = (process.env.NODE_DEBUG || '').includes('blocks-telemetry');
14
+ const endpoint = process.argv[2];
15
+ if (!endpoint)
16
+ process.exit(1);
17
+ let payload = '';
18
+ process.stdin.setEncoding('utf-8');
19
+ process.stdin.on('data', (chunk) => { payload += chunk; });
20
+ process.stdin.on('end', () => {
21
+ try {
22
+ const url = new URL(endpoint);
23
+ const isHttps = url.protocol === 'https:';
24
+ const requestFn = isHttps ? httpsRequest : httpRequest;
25
+ const req = requestFn({
26
+ hostname: url.hostname,
27
+ port: url.port || (isHttps ? '443' : '80'),
28
+ path: url.pathname + url.search,
29
+ method: 'POST',
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'Content-Length': Buffer.byteLength(payload),
33
+ },
34
+ timeout: TIMEOUT_MS,
35
+ }, (res) => {
36
+ res.resume();
37
+ if (debug)
38
+ process.stderr.write(`BLOCKS-TELEMETRY: sent (status=${res.statusCode})\n`);
39
+ process.exit(0);
40
+ });
41
+ req.on('error', (e) => {
42
+ if (debug)
43
+ process.stderr.write(`BLOCKS-TELEMETRY: error: ${e.message}\n`);
44
+ process.exit(1);
45
+ });
46
+ req.on('timeout', () => {
47
+ if (debug)
48
+ process.stderr.write(`BLOCKS-TELEMETRY: timed out\n`);
49
+ req.destroy();
50
+ process.exit(1);
51
+ });
52
+ req.write(payload);
53
+ req.end();
54
+ }
55
+ catch {
56
+ process.exit(1);
57
+ }
58
+ });
@@ -10,7 +10,7 @@ import { isCI, detectOS, detectNodeVersion, detectPackageManager, detectAgent, c
10
10
  import { trackCommand, classifyError } from './trackCommand.js';
11
11
  import { buildAndSendEvent, buildEvent, sendEvent, getTelemetryFilePath } from './client.js';
12
12
  import { getInstallationId, getProjectId, generateEventId } from './identifiers.js';
13
- import { spawnSync } from 'node:child_process';
13
+ import { spawnSync, spawn as spawnChild } from 'node:child_process';
14
14
  import { Scope } from '../common/index.js';
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
16
  describe('telemetry/consent', () => {
@@ -453,6 +453,82 @@ describe('telemetry/client', () => {
453
453
  process.env = { ...originalEnv };
454
454
  });
455
455
  });
456
+ describe('telemetry/send-worker', () => {
457
+ it('worker POSTs payload from stdin to endpoint', async () => {
458
+ const received = [];
459
+ const server = await new Promise((resolve) => {
460
+ const s = createServer((req, res) => {
461
+ let body = '';
462
+ req.on('data', (chunk) => { body += chunk; });
463
+ req.on('end', () => {
464
+ received.push(body);
465
+ res.writeHead(200);
466
+ res.end();
467
+ });
468
+ });
469
+ s.listen(0, '127.0.0.1', () => resolve(s));
470
+ });
471
+ const addr = server.address();
472
+ const endpoint = `http://127.0.0.1:${addr.port}/collect`;
473
+ const payload = JSON.stringify({ test: true, command: 'dev' });
474
+ const workerPath = join(__dirname, 'telemetry-send-worker.js');
475
+ const exitCode = await new Promise((resolve) => {
476
+ const proc = spawnChild(process.execPath, [workerPath, endpoint], {
477
+ stdio: ['pipe', 'ignore', 'ignore'],
478
+ env: { ...process.env, NODE_OPTIONS: '' },
479
+ });
480
+ proc.stdin.write(payload);
481
+ proc.stdin.end();
482
+ proc.on('close', (code) => resolve(code));
483
+ });
484
+ assert.strictEqual(exitCode, 0);
485
+ assert.strictEqual(received.length, 1);
486
+ assert.deepStrictEqual(JSON.parse(received[0]), { test: true, command: 'dev' });
487
+ server.close();
488
+ });
489
+ it('worker exits with 1 on unreachable endpoint', async () => {
490
+ const payload = JSON.stringify({ test: true });
491
+ const workerPath = join(__dirname, 'telemetry-send-worker.js');
492
+ const exitCode = await new Promise((resolve) => {
493
+ const proc = spawnChild(process.execPath, [workerPath, 'http://127.0.0.1:1/unreachable'], {
494
+ stdio: ['pipe', 'ignore', 'ignore'],
495
+ env: { ...process.env, NODE_OPTIONS: '' },
496
+ });
497
+ proc.stdin.write(payload);
498
+ proc.stdin.end();
499
+ proc.on('close', (code) => resolve(code));
500
+ });
501
+ assert.strictEqual(exitCode, 1);
502
+ });
503
+ it('worker writes debug output to stderr when NODE_DEBUG is set', async () => {
504
+ const server = await new Promise((resolve) => {
505
+ const s = createServer((req, res) => {
506
+ let body = '';
507
+ req.on('data', (chunk) => { body += chunk; });
508
+ req.on('end', () => { res.writeHead(200); res.end(); });
509
+ });
510
+ s.listen(0, '127.0.0.1', () => resolve(s));
511
+ });
512
+ const addr = server.address();
513
+ const endpoint = `http://127.0.0.1:${addr.port}/collect`;
514
+ const payload = JSON.stringify({ test: true });
515
+ const workerPath = join(__dirname, 'telemetry-send-worker.js');
516
+ const result = await new Promise((resolve) => {
517
+ const proc = spawnChild(process.execPath, [workerPath, endpoint], {
518
+ stdio: ['pipe', 'ignore', 'pipe'],
519
+ env: { ...process.env, NODE_OPTIONS: '', NODE_DEBUG: 'blocks-telemetry' },
520
+ });
521
+ let stderr = '';
522
+ proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
523
+ proc.stdin.write(payload);
524
+ proc.stdin.end();
525
+ proc.on('close', (code) => resolve({ code, stderr }));
526
+ });
527
+ assert.strictEqual(result.code, 0);
528
+ assert.ok(result.stderr.includes('BLOCKS-TELEMETRY: sent (status=200)'), `Expected debug output, got: ${result.stderr}`);
529
+ server.close();
530
+ });
531
+ });
456
532
  describe('telemetry/trackCommand integration', () => {
457
533
  const originalEnv = { ...process.env };
458
534
  afterEach(() => {
@@ -12,7 +12,7 @@ export declare function classifyError(error: unknown): {
12
12
  *
13
13
  * Measures wall-clock duration, classifies errors, and sends a single telemetry event
14
14
  * after the command completes (success or failure). The telemetry send is fire-and-forget
15
- * and bounded by a 5s timeout — it will never delay the command or affect its exit code.
15
+ * and bounded by a 500ms timeout — it will never delay the command or affect its exit code.
16
16
  *
17
17
  * If telemetry is disabled (via env var or config), the function is executed directly
18
18
  * with zero overhead.
@@ -20,10 +20,10 @@ export function classifyError(error) {
20
20
  if (msg.includes('cdk synth') || msg.includes('synthesis')) {
21
21
  return { code: 'CDK_SYNTH_FAILED', phase: 'synth' };
22
22
  }
23
- if (msg.includes('cdk deploy') || msg.includes('deployment failed')) {
23
+ if (msg.includes('cdk deploy') || msg.includes('deployment failed') || (msg.includes('cdk') && msg.includes('deploy') && msg.includes('exited with code'))) {
24
24
  return { code: 'CDK_DEPLOY_FAILED', phase: 'deploy' };
25
25
  }
26
- if (msg.includes('cdk destroy') || msg.includes('destroy failed')) {
26
+ if (msg.includes('cdk destroy') || msg.includes('destroy failed') || (msg.includes('cdk') && msg.includes('destroy') && msg.includes('exited with code'))) {
27
27
  return { code: 'CDK_DESTROY_FAILED', phase: 'destroy' };
28
28
  }
29
29
  if (msg.includes('npm install') || msg.includes('npm err')) {
@@ -48,7 +48,7 @@ export function classifyError(error) {
48
48
  *
49
49
  * Measures wall-clock duration, classifies errors, and sends a single telemetry event
50
50
  * after the command completes (success or failure). The telemetry send is fire-and-forget
51
- * and bounded by a 5s timeout — it will never delay the command or affect its exit code.
51
+ * and bounded by a 500ms timeout — it will never delay the command or affect its exit code.
52
52
  *
53
53
  * If telemetry is disabled (via env var or config), the function is executed directly
54
54
  * with zero overhead.
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CORE_VERSION = "0.1.7";
1
+ export declare const CORE_VERSION = "0.1.11";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,YAAY,UAAU,CAAC"}
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,YAAY,WAAW,CAAC"}
package/dist/version.js 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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws-blocks/core",
3
- "version": "0.1.7",
3
+ "version": "0.1.11",
4
4
  "author": "Amazon Web Services",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",