@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,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
  });
package/src/hosting.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import {
27
27
  detectFramework,
28
28
  getAdapter,
29
+ normalizeBasePath,
29
30
  type FrameworkAdapterFn,
30
31
  } from '@aws-blocks/hosting/adapters';
31
32
  import type {
@@ -150,6 +151,27 @@ export interface HostingProps {
150
151
  /** Supply a custom adapter when using an unsupported framework. */
151
152
  customAdapter?: FrameworkAdapterFn;
152
153
 
154
+ /**
155
+ * URL prefix the whole site is served under (Next.js `basePath`, Astro
156
+ * `base`, Nuxt `app.baseURL`). When set, CloudFront behaviors are prefixed
157
+ * with it and the bare root issues a 308 redirect to `/<basePath>/`.
158
+ *
159
+ * Declaring it here is the recommended source of truth: the value is
160
+ * caller-provided rather than reverse-engineered from build output, so it
161
+ * can't drift with framework/bundler internals. When omitted, the adapter
162
+ * falls back to detecting the framework's own base-path config from the
163
+ * build output.
164
+ *
165
+ * Format: leading slash, no trailing slash (e.g. `'/app'`). A trailing
166
+ * slash or bare `'/'` is normalized/ignored.
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * new Hosting(stack, 'Web', { root, framework: 'nuxt', basePath: '/app' });
171
+ * ```
172
+ */
173
+ basePath?: string;
174
+
153
175
  // ── Blocks backend integration ────────────────────────────────────
154
176
  /**
155
177
  * The Blocks backend stack (or any object with `apiUrl`).
@@ -204,6 +226,36 @@ export interface HostingProps {
204
226
  countries: string[];
205
227
  };
206
228
 
229
+ /**
230
+ * Overrides for the adjustable AWS Service Quotas the CloudFront
231
+ * distribution draws on. Each field maps to a named AWS quota you can
232
+ * request an increase on:
233
+ *
234
+ * - `cacheBehaviors` — "Cache behaviors per distribution" (default 25).
235
+ * Consumed by routed paths, prerendered pages, per-pattern header
236
+ * rules, assetPrefix, and the error-page behavior.
237
+ * - `edgeFunctions` — Lambda@Edge associations per distribution
238
+ * (default 25). Consumed by `runtime: 'edge'` routes.
239
+ * - `headerPolicies` — "Response headers policies per AWS account"
240
+ * (default 20, account-wide).
241
+ *
242
+ * Omitted fields use the AWS default. Set a field ONLY to match a quota
243
+ * increase AWS has actually granted — synth cannot verify your real quota,
244
+ * so an over-set value does not raise the AWS ceiling; it just moves the
245
+ * failure from a clear synth error to an opaque CloudFormation rollback.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * // After AWS grants "Cache behaviors per distribution" = 50:
250
+ * new Hosting(stack, 'Web', { root, quotas: { cacheBehaviors: 50 } });
251
+ * ```
252
+ */
253
+ quotas?: {
254
+ cacheBehaviors?: number;
255
+ edgeFunctions?: number;
256
+ headerPolicies?: number;
257
+ };
258
+
207
259
  /**
208
260
  * Build cache configuration. When enabled, provisions an S3 bucket for
209
261
  * framework build caches (e.g. Next.js .next/cache) and exports the bucket
@@ -401,6 +453,23 @@ export class Hosting extends Construct {
401
453
  manifest.buildId = generateBuildId();
402
454
  }
403
455
 
456
+ // ── 4b'. basePath: prop is the source of truth ───────────────
457
+ // A caller-declared `basePath` overrides whatever the adapter
458
+ // detected from build output. This is the robust path: the value
459
+ // is provided rather than reverse-engineered from framework/bundler
460
+ // internals (which drift across versions). When the prop is omitted,
461
+ // the adapter's detected `manifest.basePath` (if any) stands.
462
+ if (props.basePath !== undefined) {
463
+ const normalized = normalizeBasePath(props.basePath);
464
+ if (normalized) {
465
+ manifest.basePath = normalized;
466
+ } else {
467
+ // Explicit '/' (or empty) means "no base path" — clear any value
468
+ // the adapter may have detected so the prop genuinely wins.
469
+ delete manifest.basePath;
470
+ }
471
+ }
472
+
404
473
  // ── 4c. Prevent duplicate error pages ────────────────────────
405
474
  // The adapter may auto-detect error pages (e.g. SPA adapter finds
406
475
  // 404.html in build output and sets manifest.errorPages). When the
@@ -470,11 +539,12 @@ export class Hosting extends Construct {
470
539
  storage: props.retainOnDelete != null
471
540
  ? { retainOnDelete: props.retainOnDelete }
472
541
  : undefined,
473
- cdn: (props.contentSecurityPolicy || props.priceClass || props.geoRestriction)
542
+ cdn: (props.contentSecurityPolicy || props.priceClass || props.geoRestriction || props.quotas)
474
543
  ? {
475
544
  contentSecurityPolicy: props.contentSecurityPolicy,
476
545
  priceClass: props.priceClass,
477
546
  geoRestriction: props.geoRestriction,
547
+ quotas: props.quotas,
478
548
  }
479
549
  : undefined,
480
550
  logging: props.logging,
@@ -492,17 +562,26 @@ export class Hosting extends Construct {
492
562
  }
493
563
 
494
564
  // ── 7a. Inject Blocks env vars into compute functions ───────────
495
- const primaryFunction = hosting.computeFunctions.values().next().value as cdk.aws_lambda.Function | undefined;
565
+ // Lambda@Edge functions (edge-runtime routes) do NOT support environment
566
+ // variables — they surface in computeFunctions as EdgeFunction/IVersion
567
+ // without an `addEnvironment` method. Skip any function that can't take
568
+ // env vars instead of crashing (`fn.addEnvironment is not a function`).
569
+ const canAddEnv = (
570
+ fn: unknown,
571
+ ): fn is cdk.aws_lambda.Function =>
572
+ typeof (fn as { addEnvironment?: unknown })?.addEnvironment === 'function';
573
+
574
+ const primaryFunction = [...hosting.computeFunctions.values()].find(
575
+ canAddEnv,
576
+ );
496
577
 
497
578
  for (const [, fn] of hosting.computeFunctions) {
579
+ if (!canAddEnv(fn)) continue; // Lambda@Edge: no env var support
498
580
  if (props.api) {
499
- (fn as cdk.aws_lambda.Function).addEnvironment('BLOCKS_API_URL', props.api.apiUrl);
581
+ fn.addEnvironment('BLOCKS_API_URL', props.api.apiUrl);
500
582
  }
501
583
  if (props.backendConfig) {
502
- (fn as cdk.aws_lambda.Function).addEnvironment(
503
- 'BLOCKS_CONFIG',
504
- JSON.stringify(props.backendConfig),
505
- );
584
+ fn.addEnvironment('BLOCKS_CONFIG', JSON.stringify(props.backendConfig));
506
585
  }
507
586
  }
508
587
 
@@ -521,11 +600,25 @@ export class Hosting extends Construct {
521
600
  cacheControl: [s3deploy.CacheControl.fromString('public, max-age=60, must-revalidate')],
522
601
  });
523
602
 
524
- // Ensure config deployment runs after the hosting construct's
525
- // asset deployment so the resolved config.json is not overwritten.
526
- const assetDeployment = hosting.node.tryFindChild('AssetDeployment') as Construct | undefined;
527
- if (assetDeployment) {
528
- configDeployment.node.addDependency(assetDeployment);
603
+ // Ensure the config deployment runs AFTER the hosting construct's
604
+ // asset deployments. Those deployments upload the whole static dir
605
+ // which includes the *placeholder* `.blocks-sandbox/config.json`
606
+ // (`{_placeholder:true}`) written during synth — to the same
607
+ // `builds/<id>/.blocks-sandbox/config.json` key this deployment writes
608
+ // the resolved config to. Without an ordering dependency the
609
+ // placeholder can land last and clobber the real config.
610
+ //
611
+ // We depend on EVERY BucketDeployment under the hosting construct
612
+ // rather than a single hard-coded child id: the real children are
613
+ // `AssetDeploymentImmutable` / `AssetDeploymentHtml` / `...Mutable`
614
+ // (and vary by deploy shape), so the previous
615
+ // `tryFindChild('AssetDeployment')` never matched and the dependency
616
+ // was silently never wired.
617
+ const assetDeployments = hosting.node
618
+ .findAll()
619
+ .filter((c): c is s3deploy.BucketDeployment => c instanceof s3deploy.BucketDeployment);
620
+ for (const dep of assetDeployments) {
621
+ configDeployment.node.addDependency(dep);
529
622
  }
530
623
  }
531
624
 
@@ -25,8 +25,10 @@ export async function deploy(options: DeployOptions) {
25
25
 
26
26
  process.env.BLOCKS_STAGE = 'production';
27
27
 
28
- // Provision secrets for production
29
- const secrets = await ensureSecrets('production');
28
+ // Provision secrets for production. projectRoot must match the root cdk
29
+ // synth uses (passed as --context below) so the written parameter name
30
+ // equals the one the app resolves at synth.
31
+ const secrets = await ensureSecrets('production', options.projectRoot);
30
32
  if (secrets.created.length > 0 || secrets.updated.length > 0) {
31
33
  console.log(`🔐 Secrets provisioned: ${[...secrets.created, ...secrets.updated].join(', ')}`);
32
34
  }