@aws-blocks/core 0.4.0 → 0.5.0

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 (90) hide show
  1. package/README.md +12 -0
  2. package/dist/cdk/blocks-backend.d.ts +4 -1
  3. package/dist/cdk/blocks-backend.d.ts.map +1 -1
  4. package/dist/cdk/blocks-backend.js +56 -10
  5. package/dist/cdk/blocks-backend.test.js +71 -1
  6. package/dist/cdk/blocks-defaults.d.ts +11 -0
  7. package/dist/cdk/blocks-defaults.d.ts.map +1 -1
  8. package/dist/cdk/blocks-stack.test.js +23 -3
  9. package/dist/cdk/compute/compute.d.ts +80 -2
  10. package/dist/cdk/compute/compute.d.ts.map +1 -1
  11. package/dist/cdk/compute/compute.js +57 -3
  12. package/dist/cdk/config-registry.test.js +12 -0
  13. package/dist/cdk/dashboard-registry.d.ts +41 -0
  14. package/dist/cdk/dashboard-registry.d.ts.map +1 -0
  15. package/dist/cdk/dashboard-registry.js +61 -0
  16. package/dist/cdk/index.d.ts +67 -11
  17. package/dist/cdk/index.d.ts.map +1 -1
  18. package/dist/cdk/index.js +100 -18
  19. package/dist/cdk/internal.d.ts +3 -1
  20. package/dist/cdk/internal.d.ts.map +1 -1
  21. package/dist/cdk/internal.js +4 -1
  22. package/dist/cdk/tracer-registry.d.ts +31 -0
  23. package/dist/cdk/tracer-registry.d.ts.map +1 -0
  24. package/dist/cdk/tracer-registry.js +49 -0
  25. package/dist/cdk/vpc-requirements-registry.d.ts +33 -0
  26. package/dist/cdk/vpc-requirements-registry.d.ts.map +1 -0
  27. package/dist/cdk/vpc-requirements-registry.js +46 -0
  28. package/dist/cdk/vpc-types.d.ts +151 -0
  29. package/dist/cdk/vpc-types.d.ts.map +1 -0
  30. package/dist/cdk/vpc-types.js +3 -0
  31. package/dist/cdk/vpc.d.ts +59 -0
  32. package/dist/cdk/vpc.d.ts.map +1 -0
  33. package/dist/cdk/vpc.js +298 -0
  34. package/dist/cdk/vpc.test.d.ts +2 -0
  35. package/dist/cdk/vpc.test.d.ts.map +1 -0
  36. package/dist/cdk/vpc.test.js +285 -0
  37. package/dist/errors.d.ts +5 -0
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/errors.js +5 -0
  40. package/dist/hosting.d.ts.map +1 -1
  41. package/dist/hosting.js +2 -0
  42. package/dist/hosting.test.js +39 -1
  43. package/dist/index.cdk.d.ts +2 -1
  44. package/dist/index.cdk.d.ts.map +1 -1
  45. package/dist/index.cdk.js +1 -1
  46. package/dist/lambda-handler.js +9 -2
  47. package/dist/lambda-handler.test.js +61 -1
  48. package/dist/raw-route.d.ts +15 -1
  49. package/dist/raw-route.d.ts.map +1 -1
  50. package/dist/raw-route.js +96 -12
  51. package/dist/raw-route.test.js +332 -1
  52. package/dist/scripts/dev-server.d.ts.map +1 -1
  53. package/dist/scripts/dev-server.js +11 -0
  54. package/dist/scripts/extract-ts-types.d.ts.map +1 -1
  55. package/dist/scripts/extract-ts-types.js +107 -23
  56. package/dist/scripts/extract-ts-types.test.js +225 -26
  57. package/dist/scripts/generate-spec.d.ts.map +1 -1
  58. package/dist/scripts/generate-spec.js +14 -5
  59. package/dist/scripts/generate-spec.test.js +93 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +8 -1
  63. package/src/cdk/blocks-backend.test.ts +144 -60
  64. package/src/cdk/blocks-backend.ts +298 -239
  65. package/src/cdk/blocks-defaults.ts +12 -0
  66. package/src/cdk/blocks-stack.test.ts +32 -13
  67. package/src/cdk/compute/compute.ts +105 -3
  68. package/src/cdk/config-registry.test.ts +14 -0
  69. package/src/cdk/dashboard-registry.ts +68 -0
  70. package/src/cdk/index.ts +426 -298
  71. package/src/cdk/internal.ts +6 -2
  72. package/src/cdk/tracer-registry.ts +54 -0
  73. package/src/cdk/vpc-requirements-registry.ts +63 -0
  74. package/src/cdk/vpc-types.ts +158 -0
  75. package/src/cdk/vpc.test.ts +348 -0
  76. package/src/cdk/vpc.ts +336 -0
  77. package/src/errors.ts +5 -0
  78. package/src/hosting.test.ts +59 -1
  79. package/src/hosting.ts +3 -0
  80. package/src/index.cdk.ts +7 -0
  81. package/src/lambda-handler.test.ts +79 -1
  82. package/src/lambda-handler.ts +11 -2
  83. package/src/raw-route.test.ts +427 -1
  84. package/src/raw-route.ts +125 -12
  85. package/src/scripts/dev-server.ts +12 -1
  86. package/src/scripts/extract-ts-types.test.ts +228 -26
  87. package/src/scripts/extract-ts-types.ts +104 -20
  88. package/src/scripts/generate-spec.test.ts +101 -0
  89. package/src/scripts/generate-spec.ts +15 -5
  90. package/src/version.ts +1 -1
package/src/cdk/vpc.ts ADDED
@@ -0,0 +1,336 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Annotations } from 'aws-cdk-lib';
5
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
6
+ import type { Construct } from 'constructs';
7
+ import { getVpcRequirements as getRegisteredVpcRequirements } from './vpc-requirements-registry.js';
8
+ import type { BlocksVpcOptions, SubnetRole, SubnetScope, VpcContext, VpcRequirements } from './vpc-types.js';
9
+
10
+ const VPC_CONTEXT_KEY = Symbol.for('BLOCKS_VPC_CONTEXT');
11
+
12
+ /** Map a subnet role to its concrete CDK subnet type. */
13
+ function subnetTypeForRole(role: SubnetRole): ec2.SubnetType {
14
+ switch (role) {
15
+ case 'isolated':
16
+ return ec2.SubnetType.PRIVATE_ISOLATED;
17
+ case 'public':
18
+ return ec2.SubnetType.PUBLIC;
19
+ case 'private-with-egress':
20
+ return ec2.SubnetType.PRIVATE_WITH_EGRESS;
21
+ }
22
+ }
23
+
24
+ /** Does the VPC actually contain at least one subnet of the given role? */
25
+ function vpcHasRole(vpc: ec2.IVpc, role: SubnetRole): boolean {
26
+ switch (role) {
27
+ case 'isolated':
28
+ return vpc.isolatedSubnets.length > 0;
29
+ case 'public':
30
+ return vpc.publicSubnets.length > 0;
31
+ case 'private-with-egress':
32
+ return vpc.privateSubnets.length > 0;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Set the VPC context on a scope (BlocksStack or BlocksBackend).
38
+ * Called during stack creation when `vpc` prop is provided.
39
+ * @internal
40
+ */
41
+ export function setVpcContext(scope: Construct, context: VpcContext): void {
42
+ (scope as any)[VPC_CONTEXT_KEY] = context;
43
+ }
44
+
45
+ /**
46
+ * Get the VPC context from a scope by walking up the construct tree.
47
+ * Used by BBs (e.g., bb-data) to discover the shared VPC.
48
+ * @internal
49
+ */
50
+ export function getVpcContext(scope: Construct): VpcContext | undefined {
51
+ let current: Construct | undefined = scope;
52
+ while (current) {
53
+ const ctx = (current as any)[VPC_CONTEXT_KEY] as VpcContext | undefined;
54
+ if (ctx) return ctx;
55
+ current = current.node.scope as Construct | undefined;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ const LAZY_VPC_KEY = Symbol.for('BLOCKS_LAZY_VPC');
61
+
62
+ /**
63
+ * Does any registered Building Block *require* a VPC (i.e. cannot function
64
+ * without one)? Used to decide whether to lazily derive a VPC when the customer
65
+ * didn't provide one. A BB that merely benefits from endpoints does NOT count —
66
+ * only `requiresVpc: true`.
67
+ * @internal
68
+ */
69
+ export function anyRequirementNeedsVpc(scope: Construct): boolean {
70
+ return getRegisteredVpcRequirements(scope).some((r) => r.requirements.requiresVpc === true);
71
+ }
72
+
73
+ /**
74
+ * Get the framework-owned VPC for a stack, creating one on first use.
75
+ *
76
+ * VPC is a **derived** resource: when a Building Block (or, later, a container
77
+ * compute) requires one and the customer didn't bring their own, Blocks
78
+ * materializes a sensible default here — the same create-if-absent pattern
79
+ * `bb-data` uses for Aurora, lifted to the framework so the whole app shares a
80
+ * single VPC. Keyed by a Symbol on the stack so it's a true singleton: the first
81
+ * caller creates, everyone else reuses. Passing a customer VPC (`vpc:` prop)
82
+ * pre-seeds this via {@link initializeVpc}, so this default is only built when
83
+ * nothing was provided.
84
+ *
85
+ * The default has both a `private-with-egress` tier (for a runtime that needs
86
+ * outbound access — the derivation trigger) and public subnets for the NAT
87
+ * gateway. NAT has a real cost, so this is only created on genuine need, and
88
+ * `create()` emits a notice when it does.
89
+ * @internal
90
+ */
91
+ export function getOrCreateVpc(scope: Construct): ec2.IVpc {
92
+ const holder = scope as any;
93
+ const existing = holder[LAZY_VPC_KEY] as ec2.IVpc | undefined;
94
+ if (existing) return existing;
95
+ const vpc = new ec2.Vpc(scope, 'BlocksVpc', { maxAzs: 2, natGateways: 1 });
96
+ holder[LAZY_VPC_KEY] = vpc;
97
+ return vpc;
98
+ }
99
+
100
+ /**
101
+ * Initialize VPC support on a Blocks scope (BlocksStack or BlocksBackend).
102
+ * Creates the security group, sets VPC context, and returns the VpcContext
103
+ * that is used for Lambda placement configuration.
104
+ *
105
+ * Called during setupBlocksInfra when `vpc` prop is present.
106
+ * @internal
107
+ */
108
+ export function initializeVpc(scope: Construct, options: BlocksVpcOptions): VpcContext {
109
+ const { network: vpc, subnets } = options;
110
+
111
+ // Default Lambda placement to private-with-egress. Guard the default: if the
112
+ // caller didn't specify `subnets` and the VPC has no such tier, fail now with
113
+ // an actionable message instead of letting Lambda placement throw a cryptic
114
+ // CDK error later. (A caller who *explicitly* passes an isolated selection is
115
+ // honored — per-BB `requiresEgress` validation catches BBs that can't run there.)
116
+ if (!subnets && !vpcHasRole(vpc, 'private-with-egress')) {
117
+ throw new Error(
118
+ `VPC '${vpc.vpcId}' has no private-with-egress subnets, which Blocks uses for Lambda ` +
119
+ `placement by default. Add a PRIVATE_WITH_EGRESS subnet tier (a private subnet with a ` +
120
+ `NAT gateway), or pass 'vpc.subnets' to choose a different placement explicitly. ` +
121
+ `See packages/blocks/VPC.md.`,
122
+ );
123
+ }
124
+
125
+ const resolvedSubnets = subnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS };
126
+
127
+ const computeSecurityGroup = new ec2.SecurityGroup(scope, 'BlocksLambdaSg', {
128
+ vpc,
129
+ description: 'Security group for Blocks Lambda functions in VPC',
130
+ allowAllOutbound: true,
131
+ });
132
+
133
+ const context: VpcContext = {
134
+ vpc,
135
+ computeSecurityGroup,
136
+ computeSubnets: resolvedSubnets,
137
+ selectSubnets(scope: SubnetScope, role: SubnetRole, opts?: { fallback?: SubnetRole }): ec2.SubnetSelection {
138
+ if (vpcHasRole(vpc, role)) {
139
+ return { subnetType: subnetTypeForRole(role) };
140
+ }
141
+ // Requested role is absent. Fall back only if the BB explicitly opted in
142
+ // AND the fallback role actually exists — never downgrade silently.
143
+ if (opts?.fallback && vpcHasRole(vpc, opts.fallback)) {
144
+ return { subnetType: subnetTypeForRole(opts.fallback) };
145
+ }
146
+ const wanted = opts?.fallback ? `'${role}' (or '${opts.fallback}')` : `'${role}'`;
147
+ throw new Error(
148
+ `${scope.fullId} needs a ${wanted} subnet, but VPC '${vpc.vpcId}' has none. ` +
149
+ `Add a matching subnet tier to your VPC (e.g. a 'subnetConfiguration' entry of the ` +
150
+ `required type), or place the Building Block differently. ` +
151
+ `See packages/blocks/VPC.md for guidance.`,
152
+ );
153
+ },
154
+ };
155
+
156
+ setVpcContext(scope, context);
157
+ return context;
158
+ }
159
+
160
+ /**
161
+ * Finalize VPC: query all BuildingBlockScope children for their VPC requirements,
162
+ * deduplicate, and provision endpoints.
163
+ * Called after all BBs are constructed (alongside finalizeConfigRegistry).
164
+ * @internal
165
+ */
166
+ export function finalizeVpc(scope: Construct, options: BlocksVpcOptions): void {
167
+ const { network: vpc, subnets } = options;
168
+
169
+ // Whether the runtime's placement provides egress (outbound internet). This is
170
+ // the only capability the requirement check needs — a BB either needs egress
171
+ // or it doesn't. Resolved from the actual selected subnets rather than guessed
172
+ // from a subnet role, so an explicit subnet *list* (no subnetType) is handled
173
+ // correctly. `undefined` = couldn't determine (e.g. an imported VPC whose
174
+ // subnets aren't known at synth) → we skip validation with a warning rather
175
+ // than fabricate an answer.
176
+ const placementHasEgress: boolean | undefined = placementProvidesEgress(vpc, subnets);
177
+
178
+ const gatewayEndpoints: ec2.GatewayVpcEndpointAwsService[] = [];
179
+ const interfaceEndpoints: ec2.InterfaceVpcEndpointAwsService[] = [];
180
+
181
+ // Pull requirements from the central registry — every BuildingBlockScope
182
+ // self-registers in its constructor, so this is a faithful census without a
183
+ // separate tree walk (mirrors how config/compute registries are consumed).
184
+ for (const { fullId, requirements: reqs } of getRegisteredVpcRequirements(scope)) {
185
+ if (reqs.gatewayEndpoints) {
186
+ gatewayEndpoints.push(...reqs.gatewayEndpoints);
187
+ }
188
+ if (reqs.interfaceEndpoints) {
189
+ interfaceEndpoints.push(...reqs.interfaceEndpoints);
190
+ }
191
+ // Validate the BB's runtime egress need against where the runtime is
192
+ // actually placed. We never move the runtime (that's the customer's
193
+ // explicit choice) — an unsatisfiable requirement is a synth error, not a
194
+ // silent relocation. This turns an otherwise-silent runtime failure (e.g.
195
+ // DSQL in an isolated Lambda: deploys clean, times out on every call) into
196
+ // an actionable build error.
197
+ if (reqs.requiresEgress) {
198
+ if (placementHasEgress === false) {
199
+ throw new Error(
200
+ `${fullId} requires its runtime to reach the internet (outbound egress), but the ` +
201
+ `Blocks runtime is placed in subnets with no egress route. ` +
202
+ `Set 'vpc.subnets' to a 'private-with-egress' (or 'public') selection, or remove ` +
203
+ `the Building Block that needs it. See packages/blocks/VPC.md.`,
204
+ );
205
+ }
206
+ if (placementHasEgress === undefined) {
207
+ // Couldn't determine egress (e.g. imported VPC with unknown subnets).
208
+ // Don't fabricate a pass/fail — warn so a real mismatch isn't silent.
209
+ Annotations.of(scope).addWarningV2(
210
+ 'blocks:vpc:egress-unverified',
211
+ `${fullId} requires runtime egress, but Blocks couldn't determine whether the ` +
212
+ `configured subnets provide it (e.g. an imported VPC). Ensure the runtime's subnets ` +
213
+ `have an outbound internet route. See packages/blocks/VPC.md.`,
214
+ );
215
+ }
216
+ }
217
+ }
218
+
219
+ if (options.provisionEndpoints === false) {
220
+ return;
221
+ }
222
+
223
+ // Always add the S3 gateway endpoint. The runtime pulls config/secrets and
224
+ // migrations from S3 at cold start, so an in-VPC runtime needs it. Gateway
225
+ // endpoints are free (route-table entries, no ENI), so this is unconditional.
226
+ gatewayEndpoints.push(ec2.GatewayVpcEndpointAwsService.S3);
227
+
228
+ // Provision gateway endpoints (deduplicated). Gateway endpoints attach to
229
+ // route tables, not ENIs, so they have no security-group layer.
230
+ const provisionedGateway = new Set<string>();
231
+
232
+ for (const service of gatewayEndpoints) {
233
+ const key = service.name;
234
+ if (provisionedGateway.has(key)) continue;
235
+ provisionedGateway.add(key);
236
+
237
+ const constructId = `VpcGw${key.replace(/[^a-zA-Z0-9]/g, '')}`;
238
+ new ec2.GatewayVpcEndpoint(scope, constructId, { vpc, service });
239
+ }
240
+
241
+ // Always add CloudWatch Logs — every in-VPC Lambda needs it for log delivery,
242
+ // regardless of which Building Blocks are present. SSM is NOT added here: it
243
+ // flows from BB requirements (AppSetting and the auth blocks, which compose
244
+ // AppSetting, declare it), so an app that uses neither doesn't pay for an
245
+ // unused interface endpoint.
246
+ interfaceEndpoints.push(ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS);
247
+
248
+ // Dedicated security group for the interface endpoints. Without an explicit
249
+ // SG, CDK creates a default that allows 443 from the entire VPC CIDR — on a
250
+ // bring-your-own VPC that exposes every endpoint to unrelated workloads. Scope
251
+ // ingress to just the Blocks Lambda SG so only our functions can reach them.
252
+ const ctx = getVpcContext(scope);
253
+ const endpointSecurityGroup = new ec2.SecurityGroup(scope, 'BlocksVpcEndpointSg', {
254
+ vpc,
255
+ description: 'Blocks interface VPC endpoints — 443 from the Blocks Lambda only',
256
+ allowAllOutbound: true,
257
+ });
258
+ if (ctx) {
259
+ endpointSecurityGroup.addIngressRule(
260
+ ec2.Peer.securityGroupId(ctx.computeSecurityGroup.securityGroupId),
261
+ ec2.Port.tcp(443),
262
+ 'HTTPS from Blocks Lambda',
263
+ );
264
+ }
265
+
266
+ // Provision interface endpoints (deduplicated)
267
+ const provisionedInterface = new Set<string>();
268
+
269
+ for (const service of interfaceEndpoints) {
270
+ const key = service.name;
271
+ if (provisionedInterface.has(key)) continue;
272
+ provisionedInterface.add(key);
273
+
274
+ const constructId = `VpcIf${key.replace(/[^a-zA-Z0-9]/g, '')}`;
275
+ new ec2.InterfaceVpcEndpoint(scope, constructId, {
276
+ vpc,
277
+ service,
278
+ privateDnsEnabled: true,
279
+ securityGroups: [endpointSecurityGroup],
280
+ // `open: false` suppresses CDK's default "allow 443 from the whole VPC
281
+ // CIDR" ingress rule. Our dedicated SG already allows 443 from just the
282
+ // Blocks Lambda SG; without this, CDK would re-widen access to the entire
283
+ // VPC — the exact broadening this dedicated SG exists to prevent.
284
+ open: false,
285
+ });
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Does the runtime's subnet placement provide egress (an outbound internet
291
+ * route)? Returns `true`/`false` when determinable, or `undefined` when it
292
+ * can't be determined at synth (e.g. an imported VPC whose subnets aren't
293
+ * known) — callers should warn rather than assume.
294
+ *
295
+ * Resolved from the actual subnets, not from a subnet *role*, so an explicit
296
+ * subnet **list** (which carries no `subnetType`) is handled correctly. For a
297
+ * multi-subnet selection, egress is only reported when **every** selected
298
+ * subnet has it (a runtime may land in any of them).
299
+ */
300
+ function placementProvidesEgress(vpc: ec2.IVpc, selection?: ec2.SubnetSelection): boolean | undefined {
301
+ // Default placement (no selection) is PRIVATE_WITH_EGRESS — see initializeVpc.
302
+ if (!selection) {
303
+ return vpc.privateSubnets.length > 0 ? true : undefined;
304
+ }
305
+ // Explicit type: egress iff it's the egress tier; isolated/public are known.
306
+ if (selection.subnetType !== undefined) {
307
+ switch (selection.subnetType) {
308
+ case ec2.SubnetType.PRIVATE_WITH_EGRESS:
309
+ return true;
310
+ case ec2.SubnetType.PUBLIC:
311
+ return true; // public subnets route to an internet gateway
312
+ case ec2.SubnetType.PRIVATE_ISOLATED:
313
+ return false;
314
+ default:
315
+ return undefined;
316
+ }
317
+ }
318
+ // Explicit subnet list (or filter): resolve the concrete subnets and check
319
+ // that all of them are egress-capable. If resolution yields nothing usable,
320
+ // we can't tell.
321
+ try {
322
+ const { subnets: selected } = vpc.selectSubnets(selection);
323
+ if (selected.length === 0) return undefined;
324
+ // A subnet is egress-capable if it's one of the VPC's private (with-egress)
325
+ // or public subnets. Isolated subnets are in neither list.
326
+ const egressCapable = new Set<string>([
327
+ ...vpc.privateSubnets.map((s) => s.subnetId),
328
+ ...vpc.publicSubnets.map((s) => s.subnetId),
329
+ ]);
330
+ // If the VPC exposes no subnet inventory (imported VPC), we can't classify.
331
+ if (egressCapable.size === 0 && vpc.isolatedSubnets.length === 0) return undefined;
332
+ return selected.every((s) => egressCapable.has(s.subnetId));
333
+ } catch {
334
+ return undefined;
335
+ }
336
+ }
package/src/errors.ts CHANGED
@@ -48,6 +48,11 @@ export class ApiError extends Error {
48
48
  * when `retriable === true`; non-retriable errors (expired session,
49
49
  * tampered envelope, too-many-attempts lockouts) require restarting the
50
50
  * flow. Defaults to `false` when unspecified.
51
+ *
52
+ * This marks whether the *kind* of failure is retriable in principle, not a
53
+ * guarantee that a given retry will succeed — e.g. an optimistic-lock
54
+ * conflict against a missing row is flagged retriable, yet a blind retry
55
+ * fails identically.
51
56
  */
52
57
  readonly retriable: boolean;
53
58
 
@@ -13,7 +13,7 @@ import { App, Duration, Stack } from 'aws-cdk-lib';
13
13
  import { Match, Template } from 'aws-cdk-lib/assertions';
14
14
  import { BLOCKS_RPC_PREFIX } from './constants.js';
15
15
  import { type BlocksStackApi, Hosting } from './hosting.js';
16
- import { clearRouteRegistry, registerRoute } from './raw-route.js';
16
+ import { clearRouteRegistry, compilePath, registerRoute, type RegisteredRoute } from './raw-route.js';
17
17
 
18
18
  // ================================================================
19
19
  // Hosting construct tests
@@ -736,6 +736,50 @@ describe('Hosting', () => {
736
736
  assert.ok(patterns.includes('/users/*'), 'Should have /users/* behavior for parameterized RawRoute');
737
737
  });
738
738
 
739
+ it('adds a CloudFront behavior for a route another core copy registered', () => {
740
+ createSpaBuildOutput(tmpDir);
741
+
742
+ // Synth has the same split as dispatch: a bundle can hold more than one
743
+ // copy of @aws-blocks/core, and this loop reads whichever route table
744
+ // its own copy owns. Register the way a second copy would — straight
745
+ // into the shared state, not through this copy's registerRoute() — and
746
+ // the behavior must still be emitted. With a module-local registry it
747
+ // never is, and the route 404s at CloudFront before Lambda is reached.
748
+ const REGISTRY_KEY = '__AWS_BLOCKS_RAW_ROUTE_REGISTRY_V1__';
749
+ const state = (globalThis as typeof globalThis & { [REGISTRY_KEY]?: { routes: RegisteredRoute[] } })[
750
+ REGISTRY_KEY
751
+ ];
752
+ assert.ok(state, `route registry state must live on globalThis['${REGISTRY_KEY}']`);
753
+
754
+ const { pattern, paramNames } = compilePath('/from-other-copy');
755
+ state.routes.push({
756
+ method: 'GET',
757
+ path: '/from-other-copy',
758
+ pattern,
759
+ paramNames,
760
+ handler: async () => {},
761
+ });
762
+
763
+ const app = new App();
764
+ const stack = new Stack(app, 'ForeignRouteBehaviorStack');
765
+
766
+ new Hosting(stack, 'Hosting', {
767
+ root: tmpDir,
768
+ api: MOCK_API,
769
+ });
770
+
771
+ const template = Template.fromStack(stack);
772
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
773
+ const distKeys = Object.keys(distributions);
774
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
775
+ const patterns = (distConfig.CacheBehaviors ?? []).map((b: any) => b.PathPattern);
776
+
777
+ assert.ok(
778
+ patterns.includes('/from-other-copy'),
779
+ `synth must see routes from every core copy, got: ${JSON.stringify(patterns)}`,
780
+ );
781
+ });
782
+
739
783
  it('proxies the reserved /aws-blocks/auth subtree with a single behavior', () => {
740
784
  createSpaBuildOutput(tmpDir);
741
785
 
@@ -1568,6 +1612,20 @@ describe('Hosting', () => {
1568
1612
  `BlocksConfigDeployment must DependsOn the asset deployment(s); ` +
1569
1613
  `found DependsOn=${JSON.stringify(dependsOn)}`,
1570
1614
  );
1615
+
1616
+ const routeCutoverId = Object.entries(tpl.Resources).find(
1617
+ ([id, resource]) =>
1618
+ resource.Type === 'AWS::CloudFormation::CustomResource' &&
1619
+ /RouteStoreKeys/.test(id),
1620
+ )?.[0];
1621
+ assert.ok(routeCutoverId, 'expected a RouteStoreKeys cutover resource');
1622
+
1623
+ const routeCutoverDeps = tpl.Resources[routeCutoverId].DependsOn ?? [];
1624
+ assert.ok(
1625
+ routeCutoverDeps.includes(configId),
1626
+ `RouteStoreKeys must DependsOn BlocksConfigDeployment; ` +
1627
+ `found DependsOn=${JSON.stringify(routeCutoverDeps)}`,
1628
+ );
1571
1629
  });
1572
1630
  });
1573
1631
 
package/src/hosting.ts CHANGED
@@ -788,6 +788,9 @@ export class Hosting extends Construct {
788
788
  for (const dep of assetDeployments) {
789
789
  configDeployment.node.addDependency(dep);
790
790
  }
791
+
792
+ // Delay route cutover until resolved config replaces the static placeholder.
793
+ hosting.addBuildAssetDependency(configDeployment);
791
794
  }
792
795
 
793
796
  // ── 9. Register public origin + CORS hosting origin into S3 config ──
package/src/index.cdk.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  type ValueKind,
19
19
  } from '@aws-blocks/hosting';
20
20
  export { type ApiHandler, ApiNamespace, type BlocksContext } from './api.js';
21
+ export type { BlocksVpcOptions, ScopeOptions, SubnetRole, VpcContext, VpcRequirements } from './cdk/index.js';
21
22
  export {
22
23
  BlocksBackend,
23
24
  type BlocksBackendProps,
@@ -25,14 +26,20 @@ export {
25
26
  BlocksPresets,
26
27
  BlocksStack,
27
28
  type BlocksThrottling,
29
+ BuildingBlockScope,
28
30
  blocksNodejsBundling,
29
31
  type CoreBlocksBackendProps,
30
32
  type CoreBlocksStackProps,
31
33
  DEFAULT_NODE_RUNTIME,
32
34
  ensureApiGatewayAccount,
33
35
  finalizeConfigRegistry,
36
+ finalizeDashboards,
37
+ finalizeTracing,
34
38
  getConfigLocation,
39
+ getVpcContext,
35
40
  registerConfig,
41
+ registerDashboardFinalizer,
42
+ registerTracer,
36
43
  SandboxDisableDeletionProtection,
37
44
  Scope,
38
45
  SHARED_HANDLER_TIMEOUT_SECONDS,
@@ -5,7 +5,7 @@ import { describe, it, beforeEach } from 'node:test';
5
5
  import assert from 'node:assert';
6
6
  import { createLambdaHandler, _resetCorsPatterns, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost, TransientConfigError } from './lambda-handler.js';
7
7
  import type { LambdaContext } from './lambda-handler.js';
8
- import { registerRoute, clearRouteRegistry } from './raw-route.js';
8
+ import { registerRoute, clearRouteRegistry, getRegisteredRoutes } from './raw-route.js';
9
9
  import { decodeRpcResponse } from './rpc.js';
10
10
  import { _resetConfigCache, _setS3Fetcher } from './common/config.js';
11
11
  import type { BlocksContext } from './api.js';
@@ -414,6 +414,84 @@ describe('createLambdaHandler — RawRoute body handling', () => {
414
414
 
415
415
  // ── Cookie extraction tests ─────────────────────────────────────────────────
416
416
 
417
+ // ── Unmatched RawRoute diagnostics ──────────────────────────────────────────
418
+ //
419
+ // A request that matches no RawRoute used to return 404 without a trace in
420
+ // CloudWatch, which is what made the duplicate-core-copy bug invisible.
421
+
422
+ describe('createLambdaHandler — unmatched RawRoute diagnostics', () => {
423
+ /** Run `fn` with console.error captured; returns the collected lines. */
424
+ async function captureErrors(fn: () => Promise<void>): Promise<string[]> {
425
+ const lines: string[] = [];
426
+ const original = console.error;
427
+ console.error = (...args: unknown[]) => {
428
+ lines.push(args.map((a) => String(a)).join(' '));
429
+ };
430
+ try {
431
+ await fn();
432
+ } finally {
433
+ console.error = original;
434
+ }
435
+ return lines;
436
+ }
437
+
438
+ it('logs method, path and route count when no RawRoute matches', async () => {
439
+ registerRoute({ method: 'GET', path: '/known', handler: async (ctx) => ctx.response.send({ ok: true }) });
440
+
441
+ let result: any;
442
+ const logged = await captureErrors(async () => {
443
+ result = await invoke({}, makeEvent({ httpMethod: 'GET', path: '/unknown', body: null }));
444
+ });
445
+
446
+ assert.strictEqual(result.statusCode, 404);
447
+ // The count must match the registry the dispatcher actually consulted —
448
+ // a "0 routes registered" line is the fingerprint of a split registry.
449
+ const expectedCount = getRegisteredRoutes().length;
450
+ assert.ok(expectedCount > 0, 'precondition: routes are registered');
451
+ assert.ok(
452
+ logged.some(
453
+ (line) =>
454
+ line.includes('No RawRoute matched GET /unknown') &&
455
+ line.includes(`${expectedCount} routes registered`),
456
+ ),
457
+ `expected a diagnostic for the unmatched route, got: ${JSON.stringify(logged)}`,
458
+ );
459
+ });
460
+
461
+ it('does not log when a RawRoute matches', async () => {
462
+ registerRoute({ method: 'GET', path: '/known', handler: async (ctx) => ctx.response.send({ ok: true }) });
463
+
464
+ const logged = await captureErrors(async () => {
465
+ const result = await invoke({}, makeEvent({ httpMethod: 'GET', path: '/known', body: null }));
466
+ assert.strictEqual(result.statusCode, 200);
467
+ });
468
+
469
+ assert.deepStrictEqual(logged, []);
470
+ });
471
+
472
+ it('names duplicate core copies as the likely cause when more than one is loaded', async () => {
473
+ const REGISTRY_KEY = '__AWS_BLOCKS_RAW_ROUTE_REGISTRY_V1__';
474
+ const state = (globalThis as typeof globalThis & { [REGISTRY_KEY]?: { copies: number } })[REGISTRY_KEY];
475
+ assert.ok(state, `route registry state must live on globalThis['${REGISTRY_KEY}']`);
476
+
477
+ const originalCopies = state.copies;
478
+ state.copies = 2;
479
+ try {
480
+ const logged = await captureErrors(async () => {
481
+ const result = await invoke({}, makeEvent({ httpMethod: 'GET', path: '/unknown', body: null }));
482
+ assert.strictEqual(result.statusCode, 404);
483
+ });
484
+
485
+ assert.ok(
486
+ logged.some((line) => line.includes('2 copies of @aws-blocks/core are loaded')),
487
+ `expected the copy count in the diagnostic, got: ${JSON.stringify(logged)}`,
488
+ );
489
+ } finally {
490
+ state.copies = originalCopies;
491
+ }
492
+ });
493
+ });
494
+
417
495
  describe('createLambdaHandler — cookie extraction', () => {
418
496
  it('extracts cookies from lowercase "cookie" header (API Gateway v2)', async () => {
419
497
  let capturedCookies = '';
@@ -5,7 +5,7 @@
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import { ApiError } from './errors.js';
7
7
  import { BLOCKS_RPC_PREFIX } from './constants.js';
8
- import { matchRoute, lockRouteRegistry } from './raw-route.js';
8
+ import { matchRoute, lockRouteRegistry, getRegisteredRoutes, getLoadedCoreCopies } from './raw-route.js';
9
9
  import { registerBuiltinRoutes } from './builtin-routes.js';
10
10
  import { loadConfigToProcessEnv, isConfigResolved } from './common/config.js';
11
11
  import {
@@ -529,8 +529,17 @@ function createHandler(backend: any) {
529
529
  if (matched) {
530
530
  return handleRawRoute(event, matched.route, matched.params, corsHeaders, signal);
531
531
  }
532
- // No RawRoute matched and path is not the RPC endpoint — return 404
532
+ // No RawRoute matched and path is not the RPC endpoint — return 404.
533
+ // Log it: an unmatched route used to be entirely silent, which is what
534
+ // made a split route registry (duplicate @aws-blocks/core copies)
535
+ // undiagnosable from CloudWatch alone. The path is already in the API
536
+ // Gateway access logs, so this adds no new category of data.
533
537
  if (!requestPath.startsWith(BLOCKS_RPC_PREFIX)) {
538
+ const copies = getLoadedCoreCopies();
539
+ const copiesNote = copies > 1 ? ` — ${copies} copies of @aws-blocks/core are loaded` : '';
540
+ console.error(
541
+ `No RawRoute matched ${httpMethod} ${requestPath} (${getRegisteredRoutes().length} routes registered)${copiesNote}`,
542
+ );
534
543
  return {
535
544
  statusCode: 404,
536
545
  headers: {