@aws-blocks/core 0.1.4 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/cdk/index.d.ts +1 -1
  2. package/dist/cdk/index.d.ts.map +1 -1
  3. package/dist/cdk/index.js +1 -1
  4. package/dist/client/index.d.ts +1 -1
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/index.js +1 -1
  7. package/dist/db-naming.d.ts +17 -5
  8. package/dist/db-naming.d.ts.map +1 -1
  9. package/dist/db-naming.js +18 -6
  10. package/dist/db-naming.test.js +44 -3
  11. package/dist/errors.d.ts +28 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +27 -1
  14. package/dist/errors.test.d.ts +2 -0
  15. package/dist/errors.test.d.ts.map +1 -0
  16. package/dist/errors.test.js +47 -0
  17. package/dist/hosting.d.ts +71 -1
  18. package/dist/hosting.d.ts.map +1 -1
  19. package/dist/hosting.js +47 -8
  20. package/dist/hosting.test.js +60 -0
  21. package/dist/index.cdk.d.ts +1 -1
  22. package/dist/index.cdk.d.ts.map +1 -1
  23. package/dist/index.cdk.js +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/scripts/deploy.d.ts.map +1 -1
  28. package/dist/scripts/deploy.js +4 -2
  29. package/dist/scripts/dev-server-supervisor.test.d.ts +2 -0
  30. package/dist/scripts/dev-server-supervisor.test.d.ts.map +1 -0
  31. package/dist/scripts/dev-server-supervisor.test.js +551 -0
  32. package/dist/scripts/dev-server.d.ts +73 -0
  33. package/dist/scripts/dev-server.d.ts.map +1 -1
  34. package/dist/scripts/dev-server.js +279 -29
  35. package/dist/scripts/ensure-secrets.d.ts +5 -2
  36. package/dist/scripts/ensure-secrets.d.ts.map +1 -1
  37. package/dist/scripts/ensure-secrets.js +14 -6
  38. package/dist/scripts/external-migrations-step.d.ts.map +1 -1
  39. package/dist/scripts/external-migrations-step.js +5 -1
  40. package/dist/scripts/index.d.ts +1 -0
  41. package/dist/scripts/index.d.ts.map +1 -1
  42. package/dist/scripts/index.js +1 -0
  43. package/dist/scripts/process-tree.d.ts +126 -0
  44. package/dist/scripts/process-tree.d.ts.map +1 -0
  45. package/dist/scripts/process-tree.js +198 -0
  46. package/dist/scripts/sandbox.d.ts.map +1 -1
  47. package/dist/scripts/sandbox.js +51 -4
  48. package/dist/scripts/stack-id.d.ts +37 -0
  49. package/dist/scripts/stack-id.d.ts.map +1 -0
  50. package/dist/scripts/stack-id.js +79 -0
  51. package/dist/scripts/stack-id.test.d.ts +2 -0
  52. package/dist/scripts/stack-id.test.d.ts.map +1 -0
  53. package/dist/scripts/stack-id.test.js +104 -0
  54. package/dist/telemetry/client.d.ts +3 -1
  55. package/dist/telemetry/client.d.ts.map +1 -1
  56. package/dist/telemetry/client.js +20 -24
  57. package/dist/telemetry/telemetry-send-worker.d.ts +2 -0
  58. package/dist/telemetry/telemetry-send-worker.d.ts.map +1 -0
  59. package/dist/telemetry/telemetry-send-worker.js +58 -0
  60. package/dist/telemetry/telemetry.test.js +77 -1
  61. package/dist/telemetry/trackCommand.d.ts +1 -1
  62. package/dist/telemetry/trackCommand.js +3 -3
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.d.ts.map +1 -1
  65. package/dist/version.js +1 -1
  66. package/package.json +1 -1
  67. package/src/cdk/index.ts +1 -1
  68. package/src/client/index.ts +1 -1
  69. package/src/db-naming.test.ts +50 -5
  70. package/src/db-naming.ts +18 -6
  71. package/src/errors.test.ts +55 -0
  72. package/src/errors.ts +32 -1
  73. package/src/hosting.test.ts +79 -0
  74. package/src/hosting.ts +127 -13
  75. package/src/index.cdk.ts +1 -1
  76. package/src/index.ts +1 -1
  77. package/src/scripts/deploy.ts +4 -2
  78. package/src/scripts/dev-server-supervisor.test.ts +621 -0
  79. package/src/scripts/dev-server.ts +316 -27
  80. package/src/scripts/ensure-secrets.ts +17 -6
  81. package/src/scripts/external-migrations-step.ts +5 -1
  82. package/src/scripts/index.ts +1 -0
  83. package/src/scripts/process-tree.ts +245 -0
  84. package/src/scripts/sandbox.ts +50 -4
  85. package/src/scripts/stack-id.test.ts +123 -0
  86. package/src/scripts/stack-id.ts +87 -0
  87. package/src/telemetry/client.ts +22 -30
  88. package/src/telemetry/telemetry-send-worker.ts +60 -0
  89. package/src/telemetry/telemetry.test.ts +91 -1
  90. package/src/telemetry/trackCommand.ts +3 -3
  91. package/src/version.ts +1 -1
@@ -0,0 +1,104 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, it, afterEach } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ import { getStackId, getSandboxId, getStackName } from './stack-id.js';
9
+ describe('getStackId', () => {
10
+ let tmpDir;
11
+ afterEach(() => {
12
+ if (tmpDir)
13
+ rmSync(tmpDir, { recursive: true, force: true });
14
+ });
15
+ it('reads stackId from .blocks/config.json', () => {
16
+ tmpDir = join(tmpdir(), `stack-id-test-${Date.now()}`);
17
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
18
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-abc123' }));
19
+ assert.strictEqual(getStackId(tmpDir), 'test-abc123');
20
+ });
21
+ it('throws actionable error when config is missing', () => {
22
+ tmpDir = join(tmpdir(), `stack-id-test-missing-${Date.now()}`);
23
+ mkdirSync(tmpDir, { recursive: true });
24
+ assert.throws(() => getStackId(tmpDir), /\.blocks\/config\.json not found/);
25
+ });
26
+ it('throws actionable error when stackId key is missing', () => {
27
+ tmpDir = join(tmpdir(), `stack-id-test-nokey-${Date.now()}`);
28
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
29
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ other: 'value' }));
30
+ assert.throws(() => getStackId(tmpDir), /\.blocks\/config\.json not found/);
31
+ });
32
+ });
33
+ describe('getSandboxId', () => {
34
+ let tmpDir;
35
+ afterEach(() => {
36
+ if (tmpDir)
37
+ rmSync(tmpDir, { recursive: true, force: true });
38
+ });
39
+ it('generates and persists a sandbox id', () => {
40
+ tmpDir = join(tmpdir(), `sandbox-id-test-${Date.now()}`);
41
+ mkdirSync(tmpDir, { recursive: true });
42
+ const id = getSandboxId(tmpDir);
43
+ assert.match(id, /^[a-z0-9]+-[a-f0-9]{6}$/);
44
+ // Verify persisted
45
+ const stored = readFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'utf-8').trim();
46
+ assert.strictEqual(stored, id);
47
+ });
48
+ it('returns existing id on subsequent calls', () => {
49
+ tmpDir = join(tmpdir(), `sandbox-id-test-idem-${Date.now()}`);
50
+ mkdirSync(join(tmpDir, '.blocks-sandbox'), { recursive: true });
51
+ writeFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'alice-abc123');
52
+ assert.strictEqual(getSandboxId(tmpDir), 'alice-abc123');
53
+ });
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.4";
1
+ export declare const CORE_VERSION = "0.1.10";
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.4';
2
+ export const CORE_VERSION = '0.1.10';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws-blocks/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.10",
4
4
  "author": "Amazon Web Services",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cdk/index.ts CHANGED
@@ -22,7 +22,7 @@ export { SandboxDisableDeletionProtection } from './mixins.js';
22
22
  export { registerConfig, finalizeConfigRegistry } from './config-registry.js';
23
23
  export { synthGuard } from './synth-guard.js';
24
24
  export type { ScopeOptions } from '../index.js';
25
- export { ApiError, isBlocksError } from '../errors.js';
25
+ export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js';
26
26
 
27
27
  export class BlocksStack extends cdk.Stack implements BaseBlocksStack {
28
28
  public readonly id: string;
@@ -316,5 +316,5 @@ export function ApiNamespaceClient<T extends Record<string, (...args: any[]) =>
316
316
  });
317
317
  }
318
318
 
319
- export { ApiError, isBlocksError } from '../errors.js';
319
+ export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js';
320
320
  export { Scope, type ScopeOptions } from '../common/index.js';
@@ -1,9 +1,13 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { test, describe } from 'node:test';
4
+ import { test, describe, afterEach } from 'node:test';
5
5
  import assert from 'node:assert';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
6
9
  import { extractDbRef, dbConnectionParameterName } from './db-naming.js';
10
+ import { getStackName } from './scripts/stack-id.js';
7
11
 
8
12
  describe('extractDbRef', () => {
9
13
  test('pooler form (postgres.{ref}@) yields ref', () => {
@@ -39,10 +43,51 @@ describe('extractDbRef', () => {
39
43
  });
40
44
 
41
45
  describe('dbConnectionParameterName', () => {
42
- test('composes the stage into the SSM name', () => {
43
- assert.strictEqual(
44
- dbConnectionParameterName('production'),
45
- '/blocks/production/db-connection-string',
46
+ test('formats stack name into parameter path', () => {
47
+ assert.strictEqual(dbConnectionParameterName('my-app-k7x2mf-prod'), '/my-app-k7x2mf-prod-db-url');
48
+ });
49
+
50
+ test('two distinct stack names produce distinct parameter names', () => {
51
+ assert.notStrictEqual(
52
+ dbConnectionParameterName('app-a-111111-prod'),
53
+ dbConnectionParameterName('app-b-222222-prod'),
46
54
  );
47
55
  });
48
56
  });
57
+
58
+ describe('cross-site invariant: write name == read name', () => {
59
+ let tmpDir: string;
60
+ let originalCwd: string;
61
+
62
+ afterEach(() => {
63
+ if (originalCwd) process.chdir(originalCwd);
64
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
65
+ });
66
+
67
+ function setupProject(stackId: string, sandboxId: string): string {
68
+ tmpDir = mkdtempSync(join(tmpdir(), 'cross-site-'));
69
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
70
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId }));
71
+ mkdirSync(join(tmpDir, '.blocks-sandbox'), { recursive: true });
72
+ writeFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), sandboxId);
73
+ return tmpDir;
74
+ }
75
+
76
+ test('write-side name == read-side name (sandbox)', () => {
77
+ const root = setupProject('my-app-k7x2mf', 'alice-0d7e1c');
78
+ const writeName = dbConnectionParameterName(getStackName({ sandbox: true, projectRoot: root }));
79
+ originalCwd = process.cwd();
80
+ process.chdir(root);
81
+ const readName = dbConnectionParameterName(getStackName({ sandbox: true }));
82
+ assert.strictEqual(writeName, readName);
83
+ });
84
+
85
+ test('write-side name == read-side name (production)', () => {
86
+ const root = setupProject('my-app-k7x2mf', 'alice-0d7e1c');
87
+ const writeName = dbConnectionParameterName(getStackName({ sandbox: false, projectRoot: root }));
88
+ originalCwd = process.cwd();
89
+ process.chdir(root);
90
+ const readName = dbConnectionParameterName(getStackName({ sandbox: false }));
91
+ assert.strictEqual(writeName, readName);
92
+ });
93
+ });
package/src/db-naming.ts CHANGED
@@ -6,9 +6,15 @@
6
6
  * database connection string, and for the project ref derived from a Postgres
7
7
  * connection string.
8
8
  *
9
- * Both the deploy-time provisioner (`ensure-secrets`) and the `db pull`
10
- * generated code derive this name from the stage alone. Keep this the only
11
- * place the name is constructed.
9
+ * The connection-string parameter name is **stack-scoped** (embeds the
10
+ * deployment's stack name), so two Blocks apps in the same account + region +
11
+ * stage get distinct names and cannot overwrite each other's credentials.
12
+ *
13
+ * Two call sites compute the name: the pre-deploy writer (`ensure-secrets`) and
14
+ * the `db pull` generated wiring at synth. Both pass the result of
15
+ * `getStackName({ sandbox, projectRoot })` into this function, so they produce
16
+ * the same name by construction. The runtime Lambda does not call this function;
17
+ * it reads the name recorded at synth.
12
18
  */
13
19
 
14
20
  /**
@@ -35,7 +41,13 @@ export function extractDbRef(connectionString: string): string {
35
41
  throw new Error('Cannot extract database identifier from connection string.');
36
42
  }
37
43
 
38
- /** SSM parameter name storing the connection string for a given stage. */
39
- export function dbConnectionParameterName(stage: string): string {
40
- return `/blocks/${stage}/db-connection-string`;
44
+ /**
45
+ * SSM SecureString parameter name for a deployment's external database
46
+ * connection string.
47
+ *
48
+ * Pure string transform: `/<stackName>-db-url`. The caller is responsible for
49
+ * computing the stack name via `getStackName({ sandbox, projectRoot })`.
50
+ */
51
+ export function dbConnectionParameterName(stackName: string): string {
52
+ return `/${stackName}-db-url`;
41
53
  }
@@ -0,0 +1,55 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, it } from 'node:test';
5
+ import assert from 'node:assert';
6
+ import { ApiError, isBlocksError, hasAuthError } from './errors.js';
7
+
8
+ describe('isBlocksError', () => {
9
+ it('matches a thrown ApiError by name', () => {
10
+ const e = new ApiError('nope', 401, { name: 'InvalidCredentialsException' });
11
+ assert.ok(isBlocksError(e, 'InvalidCredentialsException'));
12
+ });
13
+
14
+ it('does not match a different name', () => {
15
+ const e = new ApiError('nope', 401, { name: 'InvalidCredentialsException' });
16
+ assert.ok(!isBlocksError(e, 'SomeOtherException'));
17
+ });
18
+
19
+ it('does not match a plain object (not an Error)', () => {
20
+ assert.ok(!isBlocksError({ name: 'InvalidCredentialsException' }, 'InvalidCredentialsException'));
21
+ });
22
+ });
23
+
24
+ describe('hasAuthError', () => {
25
+ it('matches a state carrying the given errorName', () => {
26
+ const state = { state: 'signedOut', errorName: 'InvalidCredentialsException' } as const;
27
+ assert.ok(hasAuthError(state, 'InvalidCredentialsException'));
28
+ });
29
+
30
+ it('does not match a different errorName', () => {
31
+ const state = { errorName: 'InvalidCredentialsException' };
32
+ assert.ok(!hasAuthError(state, 'UserAlreadyExistsException'));
33
+ });
34
+
35
+ it('does not match a state with no errorName', () => {
36
+ const state: { errorName?: string } = {};
37
+ assert.ok(!hasAuthError(state, 'InvalidCredentialsException'));
38
+ });
39
+
40
+ it('is safe on null / undefined', () => {
41
+ assert.ok(!hasAuthError(null, 'InvalidCredentialsException'));
42
+ assert.ok(!hasAuthError(undefined, 'InvalidCredentialsException'));
43
+ });
44
+
45
+ it('narrows the errorName to the matched literal', () => {
46
+ const state: { errorName?: string } = { errorName: 'InvalidCredentialsException' };
47
+ if (hasAuthError(state, 'InvalidCredentialsException')) {
48
+ // Type-level: state.errorName is narrowed to the literal.
49
+ const name: 'InvalidCredentialsException' = state.errorName;
50
+ assert.strictEqual(name, 'InvalidCredentialsException');
51
+ } else {
52
+ assert.fail('expected match');
53
+ }
54
+ });
55
+ });
package/src/errors.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ /**
5
+ * The `name` an `ApiError` falls back to when no structured error name is
6
+ * given. A name equal to this carries no BB-level meaning, so consumers
7
+ * branching on the structured identity should treat it as "no name".
8
+ */
9
+ export const DEFAULT_API_ERROR_NAME = 'ApiError';
10
+
4
11
  /**
5
12
  * Error subclass for errors that cross the wire between server and client.
6
13
  *
@@ -46,7 +53,7 @@ export class ApiError extends Error {
46
53
 
47
54
  constructor(message: string, status: number, options?: { name?: string; cause?: unknown; retriable?: boolean }) {
48
55
  super(message, options?.cause ? { cause: options.cause } : undefined);
49
- this.name = options?.name ?? 'ApiError';
56
+ this.name = options?.name ?? DEFAULT_API_ERROR_NAME;
50
57
  this.status = status;
51
58
  this.retriable = options?.retriable ?? false;
52
59
  }
@@ -70,3 +77,27 @@ export class ApiError extends Error {
70
77
  export function isBlocksError<N extends string>(e: unknown, name: N): e is Error & { name: N } {
71
78
  return e instanceof Error && e.name === name;
72
79
  }
80
+
81
+ /**
82
+ * Type guard for branching on a failed `AuthState` (the recommended
83
+ * `setAuthState` client path) by its structured `errorName`.
84
+ *
85
+ * The returned state is a plain object, not a thrown `Error`, so
86
+ * `isBlocksError` does not apply — use this on the value returned by
87
+ * `setAuthState`/`getAuthState`. Match on the BB error constant, never on
88
+ * the human-facing `error` string.
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const next = await authApi.setAuthState({ action: 'signIn', username, password });
93
+ * if (hasAuthError(next, AuthBasicErrors.InvalidCredentials)) {
94
+ * // unknown user → fall back to sign-up
95
+ * }
96
+ * ```
97
+ */
98
+ export function hasAuthError<T extends { errorName?: string }, N extends string>(
99
+ state: T | null | undefined,
100
+ name: N,
101
+ ): state is T & { errorName: N } {
102
+ return state?.errorName === name;
103
+ }