@aws-blocks/core 0.1.7 → 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 (53) 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/ensure-secrets.d.ts +5 -2
  12. package/dist/scripts/ensure-secrets.d.ts.map +1 -1
  13. package/dist/scripts/ensure-secrets.js +14 -6
  14. package/dist/scripts/external-migrations-step.d.ts.map +1 -1
  15. package/dist/scripts/external-migrations-step.js +5 -1
  16. package/dist/scripts/index.d.ts +1 -1
  17. package/dist/scripts/index.d.ts.map +1 -1
  18. package/dist/scripts/index.js +1 -1
  19. package/dist/scripts/sandbox.d.ts.map +1 -1
  20. package/dist/scripts/sandbox.js +10 -1
  21. package/dist/scripts/stack-id.d.ts +25 -0
  22. package/dist/scripts/stack-id.d.ts.map +1 -1
  23. package/dist/scripts/stack-id.js +25 -0
  24. package/dist/scripts/stack-id.test.js +51 -1
  25. package/dist/telemetry/client.d.ts +3 -1
  26. package/dist/telemetry/client.d.ts.map +1 -1
  27. package/dist/telemetry/client.js +20 -24
  28. package/dist/telemetry/telemetry-send-worker.d.ts +2 -0
  29. package/dist/telemetry/telemetry-send-worker.d.ts.map +1 -0
  30. package/dist/telemetry/telemetry-send-worker.js +58 -0
  31. package/dist/telemetry/telemetry.test.js +77 -1
  32. package/dist/telemetry/trackCommand.d.ts +1 -1
  33. package/dist/telemetry/trackCommand.js +3 -3
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.d.ts.map +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +1 -1
  38. package/src/db-naming.test.ts +50 -5
  39. package/src/db-naming.ts +18 -6
  40. package/src/hosting.test.ts +79 -0
  41. package/src/hosting.ts +105 -12
  42. package/src/scripts/deploy.ts +4 -2
  43. package/src/scripts/ensure-secrets.ts +17 -6
  44. package/src/scripts/external-migrations-step.ts +5 -1
  45. package/src/scripts/index.ts +1 -1
  46. package/src/scripts/sandbox.ts +10 -1
  47. package/src/scripts/stack-id.test.ts +61 -1
  48. package/src/scripts/stack-id.ts +26 -0
  49. package/src/telemetry/client.ts +22 -30
  50. package/src/telemetry/telemetry-send-worker.ts +60 -0
  51. package/src/telemetry/telemetry.test.ts +91 -1
  52. package/src/telemetry/trackCommand.ts +3 -3
  53. package/src/version.ts +1 -1
@@ -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.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.7';
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.7",
3
+ "version": "0.1.10",
4
4
  "author": "Amazon Web Services",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
  }
@@ -1428,4 +1428,83 @@ describe('Hosting', () => {
1428
1428
  template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1429
1429
  });
1430
1430
  });
1431
+
1432
+ // ── basePath prop (caller-declared source of truth) ─────────
1433
+ // Under KVS edge routing, basePath is no longer expressed as a per-behavior
1434
+ // PathPattern prefix — it lives in the KVS route table's `meta.bp`, which the
1435
+ // edge router uses for the canonical 308 + static strip. So these tests read
1436
+ // the basePath out of the RouteStoreKeys custom resource's Entries.
1437
+ describe('basePath prop', () => {
1438
+ const metaBasePath = (root: string, basePath?: string): string => {
1439
+ const app = new App();
1440
+ const stack = new Stack(app, 'BasePathStack', {
1441
+ env: { account: '123456789012', region: 'us-east-1' },
1442
+ });
1443
+ new Hosting(stack, 'Web', {
1444
+ root,
1445
+ framework: 'spa',
1446
+ buildOutputDir: 'dist',
1447
+ ...(basePath !== undefined ? { basePath } : {}),
1448
+ });
1449
+ const tpl = Template.fromStack(stack).toJSON() as {
1450
+ Resources: Record<string, { Type: string; Properties?: any }>;
1451
+ };
1452
+ const kvKeys = Object.entries(tpl.Resources).find(
1453
+ ([id, r]) =>
1454
+ r.Type === 'AWS::CloudFormation::CustomResource' &&
1455
+ /RouteStoreKeys/.test(id),
1456
+ );
1457
+ assert.ok(kvKeys, 'expected a RouteStoreKeys custom resource');
1458
+ const entries = JSON.parse(kvKeys![1].Properties.Entries);
1459
+ const meta = JSON.parse(entries.meta);
1460
+ return meta.bp as string;
1461
+ };
1462
+
1463
+ it('records basePath in the KVS route table when set (SPA, no framework base)', () => {
1464
+ createSpaBuildOutput(tmpDir);
1465
+ assert.strictEqual(metaBasePath(tmpDir, '/app'), '/app');
1466
+ });
1467
+
1468
+ it('normalizes a trailing slash (/app/ → /app)', () => {
1469
+ createSpaBuildOutput(tmpDir);
1470
+ assert.strictEqual(metaBasePath(tmpDir, '/app/'), '/app');
1471
+ });
1472
+
1473
+ it('treats "/" as no base path', () => {
1474
+ createSpaBuildOutput(tmpDir);
1475
+ assert.strictEqual(metaBasePath(tmpDir, '/'), '');
1476
+ });
1477
+ });
1478
+
1479
+ // ── P0.4: config.json ordering dependency ────────────────────
1480
+ describe('config.json deploy ordering (P0.4)', () => {
1481
+ it('BlocksConfigDeployment depends on the asset deployments', () => {
1482
+ // The asset deployments upload the whole static dir — including the
1483
+ // placeholder `.blocks-sandbox/config.json` — to the same key the
1484
+ // resolved config writes to. Without an ordering dependency the
1485
+ // placeholder can clobber the real config. The previous
1486
+ // `tryFindChild('AssetDeployment')` never matched the real child ids
1487
+ // (AssetDeploymentImmutable/Html/Mutable), so the dep was never wired.
1488
+ createSpaBuildOutput(tmpDir);
1489
+ const app = new App();
1490
+ const stack = new Stack(app, 'ConfigOrderStack');
1491
+ new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1492
+
1493
+ const tpl = Template.fromStack(stack).toJSON() as {
1494
+ Resources: Record<string, { Type: string; DependsOn?: string[] }>;
1495
+ };
1496
+ const configId = Object.keys(tpl.Resources).find(
1497
+ (id) => /BlocksConfigDeployment/.test(id) && /CustomResource/.test(id),
1498
+ );
1499
+ assert.ok(configId, 'expected a BlocksConfigDeployment custom resource');
1500
+
1501
+ const dependsOn = tpl.Resources[configId].DependsOn ?? [];
1502
+ const assetDeps = dependsOn.filter((d) => /AssetDeployment/.test(d));
1503
+ assert.ok(
1504
+ assetDeps.length >= 1,
1505
+ `BlocksConfigDeployment must DependsOn the asset deployment(s); ` +
1506
+ `found DependsOn=${JSON.stringify(dependsOn)}`,
1507
+ );
1508
+ });
1509
+ });
1431
1510
  });