@aws-blocks/core 0.3.0 → 0.4.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 (100) hide show
  1. package/README.md +13 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +32 -0
  4. package/dist/api.test.js +22 -0
  5. package/dist/bb-utils.d.ts +1 -0
  6. package/dist/bb-utils.d.ts.map +1 -1
  7. package/dist/bb-utils.js +3 -0
  8. package/dist/cdk/apigateway-account.d.ts +33 -0
  9. package/dist/cdk/apigateway-account.d.ts.map +1 -0
  10. package/dist/cdk/apigateway-account.js +60 -0
  11. package/dist/cdk/blocks-backend.d.ts +8 -0
  12. package/dist/cdk/blocks-backend.d.ts.map +1 -1
  13. package/dist/cdk/blocks-backend.js +17 -4
  14. package/dist/cdk/blocks-backend.test.js +17 -0
  15. package/dist/cdk/blocks-defaults.d.ts +65 -1
  16. package/dist/cdk/blocks-defaults.d.ts.map +1 -1
  17. package/dist/cdk/blocks-defaults.js +17 -1
  18. package/dist/cdk/blocks-defaults.test.js +17 -0
  19. package/dist/cdk/blocks-stack.test.js +21 -0
  20. package/dist/cdk/compute/compute-registry.d.ts +19 -0
  21. package/dist/cdk/compute/compute-registry.d.ts.map +1 -0
  22. package/dist/cdk/compute/compute-registry.js +38 -0
  23. package/dist/cdk/compute/compute.d.ts +2 -0
  24. package/dist/cdk/compute/compute.d.ts.map +1 -1
  25. package/dist/cdk/compute/compute.js +8 -0
  26. package/dist/cdk/compute/default-compute-factory.d.ts +1 -0
  27. package/dist/cdk/compute/default-compute-factory.d.ts.map +1 -1
  28. package/dist/cdk/config-registry.d.ts +34 -4
  29. package/dist/cdk/config-registry.d.ts.map +1 -1
  30. package/dist/cdk/config-registry.js +83 -25
  31. package/dist/cdk/config-registry.test.d.ts +2 -0
  32. package/dist/cdk/config-registry.test.d.ts.map +1 -0
  33. package/dist/cdk/config-registry.test.js +115 -0
  34. package/dist/cdk/index.d.ts +30 -2
  35. package/dist/cdk/index.d.ts.map +1 -1
  36. package/dist/cdk/index.js +42 -2
  37. package/dist/client/index.d.ts +1 -1
  38. package/dist/client/index.d.ts.map +1 -1
  39. package/dist/client/index.js +1 -1
  40. package/dist/common/config.d.ts +34 -0
  41. package/dist/common/config.d.ts.map +1 -1
  42. package/dist/common/config.js +45 -3
  43. package/dist/common/config.test.js +19 -0
  44. package/dist/common/index.d.ts +8 -0
  45. package/dist/common/index.d.ts.map +1 -1
  46. package/dist/errors.d.ts +16 -0
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js +20 -0
  49. package/dist/hosting.d.ts +9 -0
  50. package/dist/hosting.d.ts.map +1 -1
  51. package/dist/hosting.js +10 -1
  52. package/dist/hosting.test.js +24 -0
  53. package/dist/index.cdk.d.ts +2 -2
  54. package/dist/index.cdk.d.ts.map +1 -1
  55. package/dist/index.cdk.js +2 -2
  56. package/dist/index.d.ts +1 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/lambda-handler.d.ts +17 -0
  60. package/dist/lambda-handler.d.ts.map +1 -1
  61. package/dist/lambda-handler.js +52 -3
  62. package/dist/lambda-handler.test.js +130 -1
  63. package/dist/scripts/sandbox-empty-buckets.test.d.ts +2 -0
  64. package/dist/scripts/sandbox-empty-buckets.test.d.ts.map +1 -0
  65. package/dist/scripts/sandbox-empty-buckets.test.js +171 -0
  66. package/dist/scripts/sandbox.d.ts +54 -0
  67. package/dist/scripts/sandbox.d.ts.map +1 -1
  68. package/dist/scripts/sandbox.js +163 -25
  69. package/dist/version.d.ts +1 -1
  70. package/dist/version.js +1 -1
  71. package/package.json +3 -1
  72. package/src/api.test.ts +25 -0
  73. package/src/api.ts +39 -0
  74. package/src/bb-utils.ts +3 -0
  75. package/src/cdk/apigateway-account.ts +66 -0
  76. package/src/cdk/blocks-backend.test.ts +24 -0
  77. package/src/cdk/blocks-backend.ts +17 -4
  78. package/src/cdk/blocks-defaults.test.ts +21 -0
  79. package/src/cdk/blocks-defaults.ts +67 -1
  80. package/src/cdk/blocks-stack.test.ts +25 -0
  81. package/src/cdk/compute/compute-registry.ts +45 -0
  82. package/src/cdk/compute/compute.ts +10 -0
  83. package/src/cdk/compute/default-compute-factory.ts +1 -0
  84. package/src/cdk/config-registry.test.ts +135 -0
  85. package/src/cdk/config-registry.ts +92 -34
  86. package/src/cdk/index.ts +45 -2
  87. package/src/client/index.ts +1 -1
  88. package/src/common/config.test.ts +21 -0
  89. package/src/common/config.ts +47 -3
  90. package/src/common/index.ts +8 -0
  91. package/src/errors.ts +21 -0
  92. package/src/hosting.test.ts +28 -0
  93. package/src/hosting.ts +21 -1
  94. package/src/index.cdk.ts +4 -1
  95. package/src/index.ts +1 -1
  96. package/src/lambda-handler.test.ts +141 -1
  97. package/src/lambda-handler.ts +54 -2
  98. package/src/scripts/sandbox-empty-buckets.test.ts +191 -0
  99. package/src/scripts/sandbox.ts +185 -24
  100. package/src/version.ts +1 -1
@@ -4,14 +4,19 @@
4
4
  import * as cdk from 'aws-cdk-lib';
5
5
  import * as s3 from 'aws-cdk-lib/aws-s3';
6
6
  import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
7
- import * as iam from 'aws-cdk-lib/aws-iam';
8
7
  import type { Construct } from 'constructs';
8
+ import type { Compute } from './compute/compute.js';
9
9
 
10
10
  const REGISTRY_KEY = Symbol.for('BLOCKS_CONFIG_REGISTRY');
11
11
 
12
+ /** The object key of the config JSON under {@link getConfigLocation}'s bucket. */
13
+ const CONFIG_KEY = 'blocks-config.json';
14
+
12
15
  interface ConfigRegistryState {
13
16
  entries: Map<string, unknown>;
14
17
  finalized: boolean;
18
+ /** The shared config bucket, created once per stack by {@link getConfigLocation}. */
19
+ bucket?: s3.Bucket;
15
20
  }
16
21
 
17
22
  /**
@@ -45,62 +50,115 @@ export function registerConfig(scope: Construct, key: string, value: unknown): v
45
50
  registry.entries.set(key, value);
46
51
  }
47
52
 
53
+ /**
54
+ * Ensure the shared config bucket exists and return where the config JSON lives
55
+ * (`{ bucketName, key }`). The bucket is created **once per stack** (memoized on
56
+ * the registry) and this is idempotent — the first caller creates it, later
57
+ * callers get the same bucket regardless of order.
58
+ *
59
+ * Any compute that loads config at runtime (`loadConfigToProcessEnv()`) injects
60
+ * these two values as `BLOCKS_CONFIG_BUCKET` / `BLOCKS_CONFIG_KEY`. The Lambda
61
+ * handler gets them from {@link finalizeConfigRegistry}; other compute that runs
62
+ * as the shared execution role (e.g. the Agent BB's AgentCore Runtime) calls this
63
+ * at construction to inject them too, so it loads the same app config the handler
64
+ * does. IAM is not granted here — `finalizeConfigRegistry` grants read on the config
65
+ * object to the shared execution role, which such compute inherits.
66
+ *
67
+ * @param scope - Any construct in the stack; the bucket is created under the stack.
68
+ */
69
+ export function getConfigLocation(scope: Construct): { bucketName: string; key: string } {
70
+ return { bucketName: ensureConfigBucket(scope).bucketName, key: CONFIG_KEY };
71
+ }
72
+
73
+ /**
74
+ * Create-or-return the shared config bucket (memoized on the per-stack registry). Created under the
75
+ * owning `BlocksStack`/`BlocksBackend` (`globalThis.CURRENT_BLOCKS_STACK` — the construct finalize
76
+ * historically used), so its logical ID is stable regardless of which caller creates it first: a
77
+ * co-located BB (e.g. the AgentCore Runtime, a deep construct) may be the first to call it, and a
78
+ * `BlocksBackend` embedded in a customer stack must keep `Blocks/BlocksConfigBucket` (no replacement).
79
+ * Falls back to the stack when no owner is registered (isolated unit tests). Returns a concrete
80
+ * `s3.Bucket` so callers don't need a non-null assertion.
81
+ */
82
+ function ensureConfigBucket(scope: Construct): s3.Bucket {
83
+ const stack = cdk.Stack.of(scope);
84
+ const registry = getRegistry(stack);
85
+ if (!registry.bucket) {
86
+ const owner = ((globalThis as any).CURRENT_BLOCKS_STACK as Construct | undefined) ?? stack;
87
+ registry.bucket = new s3.Bucket(owner, 'BlocksConfigBucket', {
88
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
89
+ autoDeleteObjects: true,
90
+ encryption: s3.BucketEncryption.S3_MANAGED,
91
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
92
+ lifecycleRules: [
93
+ { noncurrentVersionExpiration: cdk.Duration.days(1) },
94
+ ],
95
+ });
96
+ }
97
+ return registry.bucket;
98
+ }
99
+
48
100
  /**
49
101
  * Finalize the config registry: create an S3 bucket, upload the config JSON,
50
- * set env vars on the handler, and grant read access.
102
+ * grant read to the shared execution role, and stamp the config coordinates
103
+ * (`BLOCKS_CONFIG_BUCKET` / `BLOCKS_CONFIG_KEY`) onto every compute.
104
+ *
105
+ * Read access is granted once to the shared role (`root.executionRole`) rather
106
+ * than to a single function, so every compute that assumes the role can read
107
+ * the object. The bucket/key coordinates can't live on a role (env vars are
108
+ * per-compute), so they are set on each compute via `setEnv`.
51
109
  *
52
110
  * Must be called after all BBs are constructed (i.e., after the backendCDKPath
53
111
  * import completes in BlocksStack.create() / BlocksBackend.create()).
54
112
  *
55
- * @param scope - The CDK construct to create resources under
56
- * @param handler - The Lambda function that needs to read the config
113
+ * @param root - The construct to create the config resources under (also used
114
+ * to locate the owning stack).
115
+ * @param executionRole - The shared role every compute assumes; config read is
116
+ * granted to it once.
117
+ * @param computes - The computes to stamp `BLOCKS_CONFIG_BUCKET` / `BLOCKS_CONFIG_KEY` on.
57
118
  */
58
119
  export function finalizeConfigRegistry(
59
- scope: Construct,
60
- handler: cdk.aws_lambda.IFunction,
120
+ root: Construct,
121
+ executionRole: cdk.aws_iam.IRole,
122
+ computes: readonly Compute[],
61
123
  ): void {
62
- const stack = cdk.Stack.of(scope);
124
+ const stack = cdk.Stack.of(root);
63
125
  const registry = getRegistry(stack);
64
126
 
65
127
  if (registry.finalized) return;
66
128
  registry.finalized = true;
67
129
 
68
- if (registry.entries.size === 0) return;
69
-
70
- const configBucket = new s3.Bucket(scope, 'BlocksConfigBucket', {
71
- removalPolicy: cdk.RemovalPolicy.DESTROY,
72
- autoDeleteObjects: true,
73
- encryption: s3.BucketEncryption.S3_MANAGED,
74
- blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
75
- lifecycleRules: [
76
- { noncurrentVersionExpiration: cdk.Duration.days(1) },
77
- ],
78
- });
130
+ // Nothing to do only if no config was registered AND no bucket was created (via
131
+ // getConfigLocation). If a co-located BB created the bucket, still upload (even an empty {}) and
132
+ // wire the handler so that compute's loadConfigToProcessEnv() resolves instead of 404-ing forever.
133
+ if (registry.entries.size === 0 && !registry.bucket) return;
79
134
 
80
- const configKey = 'blocks-config.json';
135
+ // Ensure the bucket exists (a co-located BB may already have created it via getConfigLocation).
136
+ const configBucket = ensureConfigBucket(root);
137
+ const configKey = CONFIG_KEY;
81
138
 
82
139
  const configObject = cdk.Lazy.any({
83
140
  produce: () => Object.fromEntries(registry.entries),
84
141
  });
85
142
 
86
- const deployment = new s3deploy.BucketDeployment(scope, 'BlocksConfigDeployment', {
143
+ new s3deploy.BucketDeployment(root, 'BlocksConfigDeployment', {
87
144
  sources: [s3deploy.Source.jsonData(configKey, configObject)],
88
145
  destinationBucket: configBucket,
89
146
  prune: false,
90
147
  });
91
148
 
92
- (handler as cdk.aws_lambda.Function).addEnvironment(
93
- 'BLOCKS_CONFIG_BUCKET',
94
- configBucket.bucketName,
95
- );
96
- (handler as cdk.aws_lambda.Function).addEnvironment(
97
- 'BLOCKS_CONFIG_KEY',
98
- configKey,
99
- );
100
-
101
- // Scope IAM grant to the specific config key
102
- (handler as cdk.aws_lambda.Function).addToRolePolicy(new iam.PolicyStatement({
103
- actions: ['s3:GetObject'],
104
- resources: [`${configBucket.bucketArn}/${configKey}`],
105
- }));
149
+ // Grant read once to the shared role (scoped to the config key), so every
150
+ // compute assuming the role can read it. We intentionally use `grantRead`
151
+ // (which also adds s3:GetBucket*/s3:List* alongside s3:GetObject*) rather
152
+ // than a hand-rolled GetObject-only statement: this is a dedicated,
153
+ // block-all-public, config-only bucket, so the broader action set carries
154
+ // negligible exposure, and grantRead stays correct automatically if the
155
+ // bucket ever moves to KMS encryption (it would add kms:Decrypt).
156
+ configBucket.grantRead(executionRole, configKey);
157
+
158
+ // Stamp the config coordinates on every compute — env vars can't live on a
159
+ // role, so each compute needs them to locate the object at runtime.
160
+ for (const compute of computes) {
161
+ compute.setEnv('BLOCKS_CONFIG_BUCKET', configBucket.bucketName);
162
+ compute.setEnv('BLOCKS_CONFIG_KEY', configKey);
163
+ }
106
164
  }
package/src/cdk/index.ts CHANGED
@@ -17,6 +17,7 @@ import { addBlocksStackMetadata } from './stack-metadata.js';
17
17
  import { finalizeConfigRegistry } from './config-registry.js';
18
18
  import { type BlocksDefaults, BlocksPresets } from './blocks-defaults.js';
19
19
  import type { Compute } from './compute/compute.js';
20
+ import { getComputes } from './compute/compute-registry.js';
20
21
  import type { DefaultComputeFactory, LambdaShapedCompute } from './compute/default-compute-factory.js';
21
22
 
22
23
  export {
@@ -28,9 +29,11 @@ export {
28
29
  export { DEFAULT_NODE_RUNTIME } from './node-version.js';
29
30
  export { blocksNodejsBundling } from './bundling.js';
30
31
  export { SandboxDisableDeletionProtection } from './mixins.js';
31
- export { registerConfig, finalizeConfigRegistry } from './config-registry.js';
32
+ export { registerConfig, finalizeConfigRegistry, getConfigLocation } from './config-registry.js';
33
+ export { ensureApiGatewayAccount } from './apigateway-account.js';
32
34
  export {
33
35
  type BlocksDefaults,
36
+ type BlocksThrottling,
34
37
  BlocksPresets,
35
38
  } from './blocks-defaults.js';
36
39
  export { synthGuard } from './synth-guard.js';
@@ -56,6 +59,12 @@ export interface CoreBlocksStackProps extends BlocksStackProps {
56
59
  export class BlocksStack extends cdk.Stack implements BaseBlocksStack {
57
60
  public readonly id: string;
58
61
  public readonly backendHandlerPath: string;
62
+ /**
63
+ * Path to the app's backend module (`props.backendCDKPath`). Exposed so Building Blocks that
64
+ * co-bundle the backend at synth (e.g. the Agent BB's AgentCore Runtime) can discover it via
65
+ * `globalThis.CURRENT_BLOCKS_STACK.backendModulePath`.
66
+ */
67
+ public readonly backendModulePath: string;
59
68
  /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */
60
69
  public readonly executionRole: cdk.aws_iam.IRole;
61
70
  /** Infrastructure defaults for Building Blocks created under this stack. */
@@ -75,6 +84,10 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack {
75
84
  get apiUrl(): string {
76
85
  return this.requireDefaultCompute().apiUrl;
77
86
  }
87
+ /** The default compute's handler CloudWatch log group. `bb-logger` reconfigures its retention. */
88
+ get handlerLogGroup(): cdk.aws_logs.ILogGroup {
89
+ return this.requireDefaultCompute().logGroup;
90
+ }
78
91
 
79
92
  private requireDefaultCompute(): LambdaShapedCompute {
80
93
  if (!this._defaultCompute) {
@@ -88,6 +101,7 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack {
88
101
  this.id = id;
89
102
  this.backendHandlerPath = props.backendHandlerPath;
90
103
  this.defaults = props.defaults;
104
+ this.backendModulePath = props.backendCDKPath;
91
105
 
92
106
  // Set globalThis so Building Blocks attach directly to this stack
93
107
  (globalThis as any).CURRENT_BLOCKS_STACK = this;
@@ -124,7 +138,7 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack {
124
138
  }
125
139
  }
126
140
  // Finalize BB config → S3 (after all BBs have registered their config)
127
- finalizeConfigRegistry(stack, stack.handler);
141
+ finalizeConfigRegistry(stack, stack.executionRole, getComputes(stack));
128
142
 
129
143
  new cdk.CfnOutput(stack, 'ApiUrl', { value: stack.apiUrl });
130
144
 
@@ -220,6 +234,24 @@ export class Scope extends Construct {
220
234
  return defaultCompute;
221
235
  }
222
236
 
237
+ /**
238
+ * The stack's default compute, **ignoring** any per-scope `_compute`
239
+ * assignment (unlike {@link compute}, which resolves the nearest assigned
240
+ * one). Use when a resource is a stack-level singleton that must bind to one
241
+ * deterministic compute regardless of the block's resolved compute — e.g.
242
+ * Realtime's shared WebSocket route integration, where one WebSocket API
243
+ * integrates to a single target and connection bookkeeping is compute-agnostic.
244
+ *
245
+ * @internal Not a customer surface; for framework/BB singleton infra only.
246
+ */
247
+ get defaultCompute(): Compute {
248
+ const defaultCompute = this.root._defaultCompute;
249
+ if (!defaultCompute) {
250
+ throw new Error('Default compute not initialized — BlocksStack/BlocksBackend.create() must run before resolving `defaultCompute`.');
251
+ }
252
+ return defaultCompute;
253
+ }
254
+
223
255
  /**
224
256
  * The backend entry file the owning BlocksStack/BlocksBackend runs — the
225
257
  * single handler entry shared across the whole app.
@@ -244,6 +276,17 @@ export class Scope extends Construct {
244
276
  return name;
245
277
  }
246
278
 
279
+ /**
280
+ * The shared handler Lambda's CloudWatch log group (the default compute's).
281
+ * Resolves the same way as {@link handler} — via the owning
282
+ * BlocksStack/BlocksBackend. `bb-logger` uses this to reconfigure retention on
283
+ * the single, framework-owned group rather than creating a second one that
284
+ * would collide on the log-group name.
285
+ */
286
+ get handlerLogGroup(): cdk.aws_logs.ILogGroup {
287
+ return this.root.handlerLogGroup;
288
+ }
289
+
247
290
  get fullId(): string {
248
291
  return computeScopeFullId(this);
249
292
  }
@@ -316,5 +316,5 @@ export function ApiNamespaceClient<T extends Record<string, (...args: any[]) =>
316
316
  });
317
317
  }
318
318
 
319
- export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js';
319
+ export { ApiError, blocksError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js';
320
320
  export { Scope, type ScopeOptions } from '../common/index.js';
@@ -131,6 +131,27 @@ describe('config — S3 errors', () => {
131
131
  assert.strictEqual(result, '', 'Should return empty string (empty config) on 404');
132
132
  });
133
133
 
134
+ it('does not cache a not-found result — a later call re-fetches once the config exists', async () => {
135
+ process.env.BLOCKS_CONFIG_BUCKET = 'test-bucket';
136
+ process.env.BLOCKS_CONFIG_KEY = 'blocks-config.json';
137
+
138
+ // Simulate the transient post-deploy window: the object isn't written yet on the first
139
+ // fetch, then becomes available. A not-found result must NOT be cached, or the container
140
+ // would be poisoned with empty config for its lifetime.
141
+ let call = 0;
142
+ const notFound = new Error('The specified key does not exist.');
143
+ (notFound as any).name = 'NoSuchKey';
144
+ _setS3Fetcher(async () => {
145
+ call++;
146
+ if (call === 1) throw notFound;
147
+ return JSON.stringify({ READY_KEY: 'ready-value' });
148
+ });
149
+
150
+ assert.strictEqual(await getConfig('READY_KEY'), '', 'first call sees the not-yet-written config as empty');
151
+ assert.strictEqual(await getConfig('READY_KEY'), 'ready-value', 'later call re-fetches once the config exists');
152
+ assert.strictEqual(call, 2, 'a not-found result must not be cached — S3 is retried');
153
+ });
154
+
134
155
  it('throws with clear message on S3 network error', async () => {
135
156
  process.env.BLOCKS_CONFIG_BUCKET = 'test-bucket';
136
157
  process.env.BLOCKS_CONFIG_KEY = 'blocks-config.json';
@@ -60,9 +60,13 @@ async function loadConfigFromS3(): Promise<Record<string, string>> {
60
60
  || error?.Code === 'NoSuchKey'
61
61
  || error?.$metadata?.httpStatusCode === 404;
62
62
  if (isNotFound) {
63
- console.warn(`[Blocks] Config file not found in S3 (${bucket}/${key}), proceeding with empty config`);
64
- configCache = {};
65
- return configCache;
63
+ // Do NOT cache the empty result: a 404 right after deploy is usually the config file
64
+ // not being readable yet (the transient window before the BucketDeployment settles), not
65
+ // a genuinely config-less app. Caching {} here would poison the container for its whole
66
+ // lifetime. Returning without caching lets the next invocation re-fetch and pick up the
67
+ // real config once it's present. (Apps that truly have no config just re-check cheaply.)
68
+ console.warn(`[Blocks] Config file not found in S3 (${bucket}/${key}), proceeding with empty config (will retry on next request)`);
69
+ return {};
66
70
  }
67
71
  const msg = error instanceof Error ? error.message : String(error);
68
72
  throw new Error(`[Blocks] Failed to load config from S3 (${bucket}/${key}): ${msg}`);
@@ -152,6 +156,46 @@ export async function loadConfigToProcessEnv(): Promise<void> {
152
156
  }
153
157
  }
154
158
 
159
+ /**
160
+ * Whether a config load has resolved and cached a result.
161
+ *
162
+ * Returns `true` once `loadConfigFromS3()` has cached a config object — either a
163
+ * successful S3 load, a genuinely-empty `{}` config, or the no-bucket local-dev
164
+ * path (both of which cache `{}`). Returns `false` in the initial state AND
165
+ * after a transient not-found miss: the 404 path deliberately does NOT cache its
166
+ * empty result (see `loadConfigFromS3`), so a `false` return immediately after
167
+ * awaiting a load uniquely identifies the post-deploy S3 window where
168
+ * blocks-config.json isn't readable yet.
169
+ *
170
+ * Callers use this to tell a transient-empty load (retry on the next request)
171
+ * apart from a legitimately-config-less app (do nothing) without reaching into
172
+ * the module's private cache.
173
+ */
174
+ export function isConfigResolved(): boolean {
175
+ return configCache !== null;
176
+ }
177
+
178
+ /**
179
+ * Sanitize a Blocks identifier into a valid config/env-var key segment:
180
+ * uppercase, with every non-alphanumeric character replaced by `_`.
181
+ *
182
+ * Config entries are loaded into `process.env` at runtime (see
183
+ * {@link loadConfigToProcessEnv}), so a key must be a valid env-var name. This
184
+ * is the single rule both sides of a key share: whoever *writes* a config key
185
+ * (`registerConfig(\`PREFIX_${sanitizeConfigKey(id)}\`, …)`) and whoever *reads*
186
+ * it (`getConfigSync(\`PREFIX_${sanitizeConfigKey(id)}\`)`) MUST go through this
187
+ * function so they reconstruct a byte-identical key — a hand-inlined variant
188
+ * that drifts silently misses the lookup at runtime.
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * registerConfig(this, `BLOCKS_QUEUE_URL_${sanitizeConfigKey(this.fullId)}`, url);
193
+ * ```
194
+ */
195
+ export function sanitizeConfigKey(id: string): string {
196
+ return id.toUpperCase().replace(/[^A-Z0-9]/g, '_');
197
+ }
198
+
155
199
  /**
156
200
  * Reset the config cache. **For testing only.**
157
201
  */
@@ -338,6 +338,14 @@ export interface BlocksStackProps extends StackProps {
338
338
 
339
339
  export class BlocksStack {
340
340
  public readonly id: string;
341
+ /**
342
+ * Path to the app's backend module (`props.backendCDKPath`). A typed contract for Building
343
+ * Blocks that co-bundle the backend at synth time and read it off
344
+ * `globalThis.CURRENT_BLOCKS_STACK` (the CDK `BlocksStack` / `BlocksBackend` set it). Declared
345
+ * here so those consumers can type against this shared `BlocksStack` contract — which the CDK
346
+ * `BlocksStack` / `BlocksBackend` implements — instead of `any`.
347
+ */
348
+ declare readonly backendModulePath: string;
341
349
  constructor(scope: Construct, id: string, props: BlocksStackProps) {
342
350
  this.id = id;
343
351
  }
package/src/errors.ts CHANGED
@@ -78,6 +78,27 @@ export function isBlocksError<N extends string>(e: unknown, name: N): e is Error
78
78
  return e instanceof Error && e.name === name;
79
79
  }
80
80
 
81
+ /**
82
+ * Build a named `Error` whose `name` is a BB error constant, so it is matchable
83
+ * with {@link isBlocksError} on both server and client. The name is also
84
+ * prefixed into the message for readable logs.
85
+ *
86
+ * This is the producer half of the {@link isBlocksError} contract: throw via
87
+ * this helper so the `name` a consumer matches on is set consistently. It has
88
+ * no runtime dependencies, so it is safe to use in every bundle — mock,
89
+ * aws-runtime, and CDK synth.
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * throw blocksError(KVStoreErrors.ConditionalCheckFailed, 'Key already exists');
94
+ * ```
95
+ */
96
+ export function blocksError(name: string, message: string): Error {
97
+ const err = new Error(`${name}: ${message}`);
98
+ err.name = name;
99
+ return err;
100
+ }
101
+
81
102
  /**
82
103
  * Type guard for branching on a failed `AuthState` (the recommended
83
104
  * `setAuthState` client path) by its structured `errorName`.
@@ -484,6 +484,34 @@ describe('Hosting', () => {
484
484
  }
485
485
  assert.ok(foundRetain, 'At least one bucket should have Retain deletion policy');
486
486
  });
487
+
488
+ it('forwards buildRetentionDays to the hosting bucket lifecycle rule (#480)', () => {
489
+ createSpaBuildOutput(tmpDir);
490
+
491
+ const app = new App();
492
+ const stack = new Stack(app, 'RetentionStack');
493
+
494
+ new Hosting(stack, 'Hosting', {
495
+ root: tmpDir,
496
+ api: MOCK_API,
497
+ buildRetentionDays: 90,
498
+ });
499
+
500
+ const template = Template.fromStack(stack);
501
+ // Before the fix, core dropped buildRetentionDays (only retainOnDelete was
502
+ // forwarded), so this asserted 30. It must now reach the DeleteOldBuilds
503
+ // rule as 90.
504
+ template.hasResourceProperties('AWS::S3::Bucket', {
505
+ LifecycleConfiguration: Match.objectLike({
506
+ Rules: Match.arrayWith([
507
+ Match.objectLike({
508
+ Id: 'DeleteOldBuilds',
509
+ ExpirationInDays: 90,
510
+ }),
511
+ ]),
512
+ }),
513
+ });
514
+ });
487
515
  });
488
516
 
489
517
  // ── API integration tests ────────────────────────────────────
package/src/hosting.ts CHANGED
@@ -264,6 +264,16 @@ export interface HostingProps {
264
264
  /** Retain the S3 bucket when the stack is deleted. Default: false. */
265
265
  retainOnDelete?: boolean;
266
266
 
267
+ /**
268
+ * Days to retain SUPERSEDED build artifacts in S3 before they are expired.
269
+ * Default: 30. The build currently referenced by CloudFront KVS (`meta.b`)
270
+ * is NEVER expired regardless of this value — only builds that have been
271
+ * replaced by a newer deploy are cleaned up (issue #480). Raise this to keep
272
+ * a longer rollback window. Must be at least `skewProtection.maxAge`
273
+ * (converted to days), or synth throws `InvalidSkewProtectionMaxAgeError`.
274
+ */
275
+ buildRetentionDays?: number;
276
+
267
277
  /** Custom Content-Security-Policy header value. */
268
278
  contentSecurityPolicy?: string;
269
279
 
@@ -671,7 +681,17 @@ export class Hosting extends Construct {
671
681
  environment: Object.keys(plainEnv).length ? plainEnv : undefined,
672
682
  domain: resolvedDomain,
673
683
  waf: props.waf,
674
- storage: props.retainOnDelete != null ? { retainOnDelete: props.retainOnDelete } : undefined,
684
+ storage:
685
+ props.retainOnDelete != null || props.buildRetentionDays != null
686
+ ? {
687
+ ...(props.retainOnDelete != null
688
+ ? { retainOnDelete: props.retainOnDelete }
689
+ : {}),
690
+ ...(props.buildRetentionDays != null
691
+ ? { buildRetentionDays: props.buildRetentionDays }
692
+ : {}),
693
+ }
694
+ : undefined,
675
695
  cdn:
676
696
  props.contentSecurityPolicy || props.priceClass || props.geoRestriction || props.quotas
677
697
  ? {
package/src/index.cdk.ts CHANGED
@@ -24,11 +24,14 @@ export {
24
24
  type BlocksDefaults,
25
25
  BlocksPresets,
26
26
  BlocksStack,
27
+ type BlocksThrottling,
27
28
  blocksNodejsBundling,
28
29
  type CoreBlocksBackendProps,
29
30
  type CoreBlocksStackProps,
30
31
  DEFAULT_NODE_RUNTIME,
32
+ ensureApiGatewayAccount,
31
33
  finalizeConfigRegistry,
34
+ getConfigLocation,
32
35
  registerConfig,
33
36
  SandboxDisableDeletionProtection,
34
37
  Scope,
@@ -44,7 +47,7 @@ export {
44
47
  registerSdkIdentifiers,
45
48
  } from './common/sdk-registry.js';
46
49
  export { BLOCKS_AUTH_PREFIX, BLOCKS_RPC_PREFIX } from './constants.js';
47
- export { ApiError, DEFAULT_API_ERROR_NAME, hasAuthError, isBlocksError } from './errors.js';
50
+ export { ApiError, blocksError, DEFAULT_API_ERROR_NAME, hasAuthError, isBlocksError } from './errors.js';
48
51
  export {
49
52
  type BlocksStackApi,
50
53
  type ComputeConfig,
package/src/index.ts CHANGED
@@ -25,7 +25,7 @@ export {
25
25
  registerSdkIdentifiers,
26
26
  } from './common/sdk-registry.js';
27
27
  export { BLOCKS_AUTH_PREFIX, BLOCKS_RPC_PREFIX } from './constants.js';
28
- export { ApiError, DEFAULT_API_ERROR_NAME, hasAuthError, isBlocksError } from './errors.js';
28
+ export { ApiError, blocksError, DEFAULT_API_ERROR_NAME, hasAuthError, isBlocksError } from './errors.js';
29
29
  export {
30
30
  clearRouteRegistry,
31
31
  getRegisteredRoutes,