@aws-blocks/core 0.3.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 (147) hide show
  1. package/README.md +25 -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 +11 -0
  12. package/dist/cdk/blocks-backend.d.ts.map +1 -1
  13. package/dist/cdk/blocks-backend.js +72 -13
  14. package/dist/cdk/blocks-backend.test.js +88 -1
  15. package/dist/cdk/blocks-defaults.d.ts +76 -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 +44 -3
  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 +82 -2
  24. package/dist/cdk/compute/compute.d.ts.map +1 -1
  25. package/dist/cdk/compute/compute.js +64 -2
  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 +127 -0
  34. package/dist/cdk/dashboard-registry.d.ts +41 -0
  35. package/dist/cdk/dashboard-registry.d.ts.map +1 -0
  36. package/dist/cdk/dashboard-registry.js +61 -0
  37. package/dist/cdk/index.d.ts +90 -6
  38. package/dist/cdk/index.d.ts.map +1 -1
  39. package/dist/cdk/index.js +135 -13
  40. package/dist/cdk/internal.d.ts +3 -1
  41. package/dist/cdk/internal.d.ts.map +1 -1
  42. package/dist/cdk/internal.js +4 -1
  43. package/dist/cdk/tracer-registry.d.ts +31 -0
  44. package/dist/cdk/tracer-registry.d.ts.map +1 -0
  45. package/dist/cdk/tracer-registry.js +49 -0
  46. package/dist/cdk/vpc-requirements-registry.d.ts +33 -0
  47. package/dist/cdk/vpc-requirements-registry.d.ts.map +1 -0
  48. package/dist/cdk/vpc-requirements-registry.js +46 -0
  49. package/dist/cdk/vpc-types.d.ts +151 -0
  50. package/dist/cdk/vpc-types.d.ts.map +1 -0
  51. package/dist/cdk/vpc-types.js +3 -0
  52. package/dist/cdk/vpc.d.ts +59 -0
  53. package/dist/cdk/vpc.d.ts.map +1 -0
  54. package/dist/cdk/vpc.js +298 -0
  55. package/dist/cdk/vpc.test.d.ts +2 -0
  56. package/dist/cdk/vpc.test.d.ts.map +1 -0
  57. package/dist/cdk/vpc.test.js +285 -0
  58. package/dist/client/index.d.ts +1 -1
  59. package/dist/client/index.d.ts.map +1 -1
  60. package/dist/client/index.js +1 -1
  61. package/dist/common/config.d.ts +34 -0
  62. package/dist/common/config.d.ts.map +1 -1
  63. package/dist/common/config.js +45 -3
  64. package/dist/common/config.test.js +19 -0
  65. package/dist/common/index.d.ts +8 -0
  66. package/dist/common/index.d.ts.map +1 -1
  67. package/dist/errors.d.ts +21 -0
  68. package/dist/errors.d.ts.map +1 -1
  69. package/dist/errors.js +25 -0
  70. package/dist/hosting.d.ts +9 -0
  71. package/dist/hosting.d.ts.map +1 -1
  72. package/dist/hosting.js +12 -1
  73. package/dist/hosting.test.js +63 -1
  74. package/dist/index.cdk.d.ts +3 -2
  75. package/dist/index.cdk.d.ts.map +1 -1
  76. package/dist/index.cdk.js +2 -2
  77. package/dist/index.d.ts +1 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +1 -1
  80. package/dist/lambda-handler.d.ts +17 -0
  81. package/dist/lambda-handler.d.ts.map +1 -1
  82. package/dist/lambda-handler.js +61 -5
  83. package/dist/lambda-handler.test.js +191 -2
  84. package/dist/raw-route.d.ts +15 -1
  85. package/dist/raw-route.d.ts.map +1 -1
  86. package/dist/raw-route.js +96 -12
  87. package/dist/raw-route.test.js +332 -1
  88. package/dist/scripts/dev-server.d.ts.map +1 -1
  89. package/dist/scripts/dev-server.js +11 -0
  90. package/dist/scripts/extract-ts-types.d.ts.map +1 -1
  91. package/dist/scripts/extract-ts-types.js +107 -23
  92. package/dist/scripts/extract-ts-types.test.js +225 -26
  93. package/dist/scripts/generate-spec.d.ts.map +1 -1
  94. package/dist/scripts/generate-spec.js +14 -5
  95. package/dist/scripts/generate-spec.test.js +93 -0
  96. package/dist/scripts/sandbox-empty-buckets.test.d.ts +2 -0
  97. package/dist/scripts/sandbox-empty-buckets.test.d.ts.map +1 -0
  98. package/dist/scripts/sandbox-empty-buckets.test.js +171 -0
  99. package/dist/scripts/sandbox.d.ts +54 -0
  100. package/dist/scripts/sandbox.d.ts.map +1 -1
  101. package/dist/scripts/sandbox.js +163 -25
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/package.json +10 -1
  105. package/src/api.test.ts +25 -0
  106. package/src/api.ts +39 -0
  107. package/src/bb-utils.ts +3 -0
  108. package/src/cdk/apigateway-account.ts +66 -0
  109. package/src/cdk/blocks-backend.test.ts +168 -60
  110. package/src/cdk/blocks-backend.ts +299 -227
  111. package/src/cdk/blocks-defaults.test.ts +21 -0
  112. package/src/cdk/blocks-defaults.ts +79 -1
  113. package/src/cdk/blocks-stack.test.ts +57 -13
  114. package/src/cdk/compute/compute-registry.ts +45 -0
  115. package/src/cdk/compute/compute.ts +114 -2
  116. package/src/cdk/compute/default-compute-factory.ts +1 -0
  117. package/src/cdk/config-registry.test.ts +149 -0
  118. package/src/cdk/config-registry.ts +92 -34
  119. package/src/cdk/dashboard-registry.ts +68 -0
  120. package/src/cdk/index.ts +427 -256
  121. package/src/cdk/internal.ts +6 -2
  122. package/src/cdk/tracer-registry.ts +54 -0
  123. package/src/cdk/vpc-requirements-registry.ts +63 -0
  124. package/src/cdk/vpc-types.ts +158 -0
  125. package/src/cdk/vpc.test.ts +348 -0
  126. package/src/cdk/vpc.ts +336 -0
  127. package/src/client/index.ts +1 -1
  128. package/src/common/config.test.ts +21 -0
  129. package/src/common/config.ts +47 -3
  130. package/src/common/index.ts +8 -0
  131. package/src/errors.ts +26 -0
  132. package/src/hosting.test.ts +87 -1
  133. package/src/hosting.ts +24 -1
  134. package/src/index.cdk.ts +11 -1
  135. package/src/index.ts +1 -1
  136. package/src/lambda-handler.test.ts +220 -2
  137. package/src/lambda-handler.ts +65 -4
  138. package/src/raw-route.test.ts +427 -1
  139. package/src/raw-route.ts +125 -12
  140. package/src/scripts/dev-server.ts +12 -1
  141. package/src/scripts/extract-ts-types.test.ts +228 -26
  142. package/src/scripts/extract-ts-types.ts +104 -20
  143. package/src/scripts/generate-spec.test.ts +101 -0
  144. package/src/scripts/generate-spec.ts +15 -5
  145. package/src/scripts/sandbox-empty-buckets.test.ts +191 -0
  146. package/src/scripts/sandbox.ts +185 -24
  147. package/src/version.ts +1 -1
package/src/index.cdk.ts CHANGED
@@ -18,18 +18,28 @@ 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,
24
25
  type BlocksDefaults,
25
26
  BlocksPresets,
26
27
  BlocksStack,
28
+ type BlocksThrottling,
29
+ BuildingBlockScope,
27
30
  blocksNodejsBundling,
28
31
  type CoreBlocksBackendProps,
29
32
  type CoreBlocksStackProps,
30
33
  DEFAULT_NODE_RUNTIME,
34
+ ensureApiGatewayAccount,
31
35
  finalizeConfigRegistry,
36
+ finalizeDashboards,
37
+ finalizeTracing,
38
+ getConfigLocation,
39
+ getVpcContext,
32
40
  registerConfig,
41
+ registerDashboardFinalizer,
42
+ registerTracer,
33
43
  SandboxDisableDeletionProtection,
34
44
  Scope,
35
45
  SHARED_HANDLER_TIMEOUT_SECONDS,
@@ -44,7 +54,7 @@ export {
44
54
  registerSdkIdentifiers,
45
55
  } from './common/sdk-registry.js';
46
56
  export { BLOCKS_AUTH_PREFIX, BLOCKS_RPC_PREFIX } from './constants.js';
47
- export { ApiError, DEFAULT_API_ERROR_NAME, hasAuthError, isBlocksError } from './errors.js';
57
+ export { ApiError, blocksError, DEFAULT_API_ERROR_NAME, hasAuthError, isBlocksError } from './errors.js';
48
58
  export {
49
59
  type BlocksStackApi,
50
60
  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,
@@ -3,10 +3,11 @@
3
3
 
4
4
  import { describe, it, beforeEach } from 'node:test';
5
5
  import assert from 'node:assert';
6
- import { createLambdaHandler, _resetCorsPatterns, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost } from './lambda-handler.js';
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
+ import { _resetConfigCache, _setS3Fetcher } from './common/config.js';
10
11
  import type { BlocksContext } from './api.js';
11
12
 
12
13
  beforeEach(() => {
@@ -29,6 +30,145 @@ async function invoke(backend: any, event: any): Promise<any> {
29
30
  return handler(event) as any;
30
31
  }
31
32
 
33
+ // ── init self-heal (retry on failed initialization) ─────────────────────────
34
+
35
+ describe('createLambdaHandler — init self-heal', () => {
36
+ it('retries initialize() on a later request instead of caching a failed init', async () => {
37
+ let initCalls = 0;
38
+ const backend = {
39
+ api: (_ctx: BlocksContext) => ({
40
+ async echo(msg: string) {
41
+ return { msg };
42
+ },
43
+ }),
44
+ };
45
+ // Fail the first initialization (e.g. config not readable yet in the brief post-deploy
46
+ // window), then succeed. The SAME handler instance must recover — a cached rejected
47
+ // initPromise would poison the container and fail every subsequent request.
48
+ const backendFactory = async () => {
49
+ initCalls++;
50
+ if (initCalls === 1) throw new Error('transient init failure');
51
+ return backend;
52
+ };
53
+ const handler = createLambdaHandler(backendFactory);
54
+
55
+ // 1st request: init fails, so the handler rejects.
56
+ await assert.rejects(() => handler(makeEvent()) as any, /transient init failure/);
57
+
58
+ // 2nd request: init is retried and succeeds → 200 (not a re-thrown cached rejection).
59
+ const res = (await handler(makeEvent())) as any;
60
+ assert.strictEqual(res.statusCode, 200);
61
+ assert.strictEqual(initCalls, 2, 'initialize() must be retried on the next request, not cached');
62
+ });
63
+
64
+ it('recovers from a transient-empty (post-deploy 404) config load on the next request', async () => {
65
+ // Reproduces the poisoned-container bug: the first S3 load hits the transient
66
+ // post-deploy window (NoSuchKey), so config resolves empty; the handler must
67
+ // NOT lock in that empty config, and the NEXT request must re-fetch and pick
68
+ // up the now-present config.
69
+ _resetConfigCache();
70
+ process.env.BLOCKS_CONFIG_BUCKET = 'test-bucket';
71
+ process.env.BLOCKS_CONFIG_KEY = 'blocks-config.json';
72
+ delete process.env.SELFHEAL_KEY;
73
+
74
+ let fetchCall = 0;
75
+ const notFound = new Error('The specified key does not exist.');
76
+ (notFound as any).name = 'NoSuchKey';
77
+ _setS3Fetcher(async () => {
78
+ fetchCall++;
79
+ if (fetchCall === 1) throw notFound; // config not readable yet
80
+ return JSON.stringify({ SELFHEAL_KEY: 'ready' });
81
+ });
82
+
83
+ let backendImports = 0;
84
+ const handler = createLambdaHandler(async () => {
85
+ backendImports++;
86
+ return {
87
+ api: (_ctx: BlocksContext) => ({
88
+ async echo(msg: string) { return { msg, cfg: process.env.SELFHEAL_KEY }; },
89
+ }),
90
+ };
91
+ });
92
+
93
+ try {
94
+ // 1st request: transient-empty load → initialize() throws TransientConfigError
95
+ // BEFORE importing the backend (so we never lock in empty config), and the
96
+ // request rejects.
97
+ await assert.rejects(() => handler(makeEvent()) as any, TransientConfigError);
98
+ assert.strictEqual(backendImports, 0, 'backend must NOT be imported while config is unresolved');
99
+
100
+ // 2nd request: config now present → initialize() re-runs, re-fetches, and
101
+ // injects the config into process.env before importing the backend.
102
+ const res = (await handler(makeEvent())) as any;
103
+ assert.strictEqual(res.statusCode, 200);
104
+ const body = JSON.parse(res.body);
105
+ assert.strictEqual(body.result.cfg, 'ready', 'now-present config was injected on the retry');
106
+ assert.strictEqual(backendImports, 1, 'backend imported exactly once, on the successful retry');
107
+ assert.strictEqual(fetchCall, 2, 'S3 was re-fetched on the retry (transient miss is not cached)');
108
+ } finally {
109
+ _resetConfigCache();
110
+ delete process.env.BLOCKS_CONFIG_BUCKET;
111
+ delete process.env.BLOCKS_CONFIG_KEY;
112
+ delete process.env.SELFHEAL_KEY;
113
+ }
114
+ });
115
+
116
+ it('does NOT re-init for a config-less app (no bucket, local dev) — initialize runs once', async () => {
117
+ _resetConfigCache();
118
+ delete process.env.BLOCKS_CONFIG_BUCKET;
119
+ delete process.env.BLOCKS_CONFIG_KEY;
120
+
121
+ let backendImports = 0;
122
+ const handler = createLambdaHandler(async () => {
123
+ backendImports++;
124
+ return { api: (_ctx: BlocksContext) => ({ async echo(msg: string) { return { msg }; } }) };
125
+ });
126
+
127
+ try {
128
+ const r1 = (await handler(makeEvent())) as any;
129
+ const r2 = (await handler(makeEvent())) as any;
130
+ assert.strictEqual(r1.statusCode, 200);
131
+ assert.strictEqual(r2.statusCode, 200);
132
+ assert.strictEqual(backendImports, 1, 'no re-init for a genuinely config-less (local dev) app');
133
+ } finally {
134
+ _resetConfigCache();
135
+ }
136
+ });
137
+
138
+ it('does NOT re-init or spin for a genuinely-empty ({}) S3 config', async () => {
139
+ // A real, readable empty config must be treated as resolved — distinct from
140
+ // the transient 404 miss — so the container does not throw/retry forever nor
141
+ // re-fetch S3 on every request.
142
+ _resetConfigCache();
143
+ process.env.BLOCKS_CONFIG_BUCKET = 'test-bucket';
144
+ process.env.BLOCKS_CONFIG_KEY = 'blocks-config.json';
145
+
146
+ let fetchCall = 0;
147
+ _setS3Fetcher(async () => { fetchCall++; return JSON.stringify({}); });
148
+
149
+ let backendImports = 0;
150
+ const handler = createLambdaHandler(async () => {
151
+ backendImports++;
152
+ return { api: (_ctx: BlocksContext) => ({ async echo(msg: string) { return { msg }; } }) };
153
+ });
154
+
155
+ try {
156
+ const r1 = (await handler(makeEvent())) as any;
157
+ const r2 = (await handler(makeEvent())) as any;
158
+ const r3 = (await handler(makeEvent())) as any;
159
+ assert.strictEqual(r1.statusCode, 200);
160
+ assert.strictEqual(r2.statusCode, 200);
161
+ assert.strictEqual(r3.statusCode, 200);
162
+ assert.strictEqual(backendImports, 1, 'genuinely-empty config resolves; no re-init');
163
+ assert.strictEqual(fetchCall, 1, 'S3 fetched once and cached — no per-request re-fetch spin');
164
+ } finally {
165
+ _resetConfigCache();
166
+ delete process.env.BLOCKS_CONFIG_BUCKET;
167
+ delete process.env.BLOCKS_CONFIG_KEY;
168
+ }
169
+ });
170
+ });
171
+
32
172
  // ── RPC body tests ──────────────────────────────────────────────────────────
33
173
 
34
174
  describe('createLambdaHandler — RPC body handling', () => {
@@ -274,6 +414,84 @@ describe('createLambdaHandler — RawRoute body handling', () => {
274
414
 
275
415
  // ── Cookie extraction tests ─────────────────────────────────────────────────
276
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
+
277
495
  describe('createLambdaHandler — cookie extraction', () => {
278
496
  it('extracts cookies from lowercase "cookie" header (API Gateway v2)', async () => {
279
497
  let capturedCookies = '';
@@ -5,9 +5,9 @@
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
- import { loadConfigToProcessEnv } from './common/config.js';
10
+ import { loadConfigToProcessEnv, isConfigResolved } from './common/config.js';
11
11
  import {
12
12
  parseRpcRequest,
13
13
  successResponse,
@@ -297,6 +297,26 @@ export function createLambdaHandler(backendFactory: () => Promise<any>) {
297
297
  async function initialize() {
298
298
  await loadConfigToProcessEnv();
299
299
 
300
+ // If the app has config coordinates (BLOCKS_CONFIG_BUCKET/KEY set) but the
301
+ // load didn't actually resolve the config, we're in the transient post-deploy
302
+ // S3 window where blocks-config.json isn't readable yet. loadConfigFromS3()
303
+ // deliberately does NOT cache that empty result, so throw a typed transient
304
+ // error here — BEFORE importing the backend — so the createLambdaHandler()
305
+ // catch resets initPromise and the next request re-runs initialize(), which
306
+ // re-fetches from S3 and picks up the now-present config. Without this throw,
307
+ // initialize() would succeed with empty config, `handler` would be assigned,
308
+ // and the `if (!handler)` guard below would never fire again, poisoning the
309
+ // container for its whole life (it would import the backend against empty
310
+ // process.env and serve "not configured" 500s forever).
311
+ //
312
+ // A genuinely config-less app does NOT throw: the no-bucket local-dev path
313
+ // and a real empty `{}` config both cache their result, so isConfigResolved()
314
+ // is true and the retry never spins. Once the blob is readable the successful
315
+ // load caches, so there is no unbounded per-request S3 re-fetch either.
316
+ if (process.env.BLOCKS_CONFIG_BUCKET && process.env.BLOCKS_CONFIG_KEY && !isConfigResolved()) {
317
+ throw new TransientConfigError();
318
+ }
319
+
300
320
  // Merge hosting-provided CORS origins into the main env var so the lazy
301
321
  // getCorsPatterns() sees a combined value on first access.
302
322
  // loadConfigToProcessEnv() won't override CORS_ALLOWED_ORIGINS if it's
@@ -318,7 +338,18 @@ export function createLambdaHandler(backendFactory: () => Promise<any>) {
318
338
 
319
339
  return async (event: any, context?: LambdaContext) => {
320
340
  if (!handler) {
321
- if (!initPromise) initPromise = initialize();
341
+ // Retry init on failure instead of caching the rejection. If initialize() throws (most often
342
+ // because loadConfigToProcessEnv() couldn't read blocks-config.json during the brief
343
+ // post-deploy window before it's readable), a memoized rejected promise would poison this
344
+ // container for its whole lifetime — every later request re-awaits the same rejection and 500s.
345
+ // Resetting initPromise lets the next invocation re-run initialize() (config.ts re-fetches,
346
+ // since it doesn't cache failures), so the handler self-heals once config is available.
347
+ if (!initPromise) {
348
+ initPromise = initialize().catch((err) => {
349
+ initPromise = null;
350
+ throw err;
351
+ });
352
+ }
322
353
  await initPromise;
323
354
  }
324
355
 
@@ -390,6 +421,27 @@ class HandlerTimeoutError extends Error {
390
421
  }
391
422
  }
392
423
 
424
+ /**
425
+ * Thrown by `initialize()` when the app has config coordinates
426
+ * (BLOCKS_CONFIG_BUCKET/KEY) but the S3 config load resolved empty because
427
+ * blocks-config.json wasn't readable yet — the transient window right after a
428
+ * deploy, before the BucketDeployment settles.
429
+ *
430
+ * It flows through the `createLambdaHandler()` init catch, which resets
431
+ * `initPromise` so the NEXT request re-runs `initialize()` and picks up the
432
+ * now-present config (config.ts does not cache a not-found result, so the retry
433
+ * re-fetches). A distinct type keeps the recovery path greppable and testable
434
+ * and separates it from real init failures, which surface with their own error.
435
+ *
436
+ * @internal Exported for testing only.
437
+ */
438
+ export class TransientConfigError extends Error {
439
+ constructor() {
440
+ super('[Blocks] Config not readable yet (transient post-deploy S3 window); will retry on next request');
441
+ this.name = 'TransientConfigError';
442
+ }
443
+ }
444
+
393
445
  /**
394
446
  * Extract the request path from a Lambda event, normalizing between
395
447
  * API Gateway v1 (REST) and v2 (HTTP API) event shapes.
@@ -477,8 +529,17 @@ function createHandler(backend: any) {
477
529
  if (matched) {
478
530
  return handleRawRoute(event, matched.route, matched.params, corsHeaders, signal);
479
531
  }
480
- // 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.
481
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
+ );
482
543
  return {
483
544
  statusCode: 404,
484
545
  headers: {