@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
@@ -0,0 +1,171 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, it } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { emptyBucket, listStackBucketNames, runDestroyWithRetries, toDeleteObjects } from './sandbox.js';
6
+ // The teardown bucket-emptying must delete BOTH live versions and delete
7
+ // markers — a versioned bucket left with either still blocks `cdk destroy`.
8
+ // This is the exact combination that, done wrong, silently no-ops.
9
+ describe('toDeleteObjects', () => {
10
+ it('combines versions and delete markers', () => {
11
+ const out = toDeleteObjects({
12
+ Versions: [
13
+ { Key: 'a', VersionId: 'v1' },
14
+ { Key: 'b', VersionId: 'v2' },
15
+ ],
16
+ DeleteMarkers: [{ Key: 'a', VersionId: 'dm1' }],
17
+ });
18
+ assert.deepStrictEqual(out, [
19
+ { Key: 'a', VersionId: 'v1' },
20
+ { Key: 'b', VersionId: 'v2' },
21
+ { Key: 'a', VersionId: 'dm1' },
22
+ ]);
23
+ });
24
+ it('returns [] for an empty bucket (no Versions/DeleteMarkers keys)', () => {
25
+ assert.deepStrictEqual(toDeleteObjects({}), []);
26
+ });
27
+ it('tolerates only versions or only delete markers', () => {
28
+ assert.deepStrictEqual(toDeleteObjects({ Versions: [{ Key: 'x', VersionId: 'v' }] }), [
29
+ { Key: 'x', VersionId: 'v' },
30
+ ]);
31
+ assert.deepStrictEqual(toDeleteObjects({ DeleteMarkers: [{ Key: 'y', VersionId: 'd' }] }), [
32
+ { Key: 'y', VersionId: 'd' },
33
+ ]);
34
+ });
35
+ it('drops entries without a Key rather than emitting a bad DeleteObjects payload', () => {
36
+ assert.deepStrictEqual(toDeleteObjects({ Versions: [{ VersionId: 'orphan' }] }), []);
37
+ });
38
+ });
39
+ describe('listStackBucketNames', () => {
40
+ it('follows NextToken across pages and returns only S3 buckets', async () => {
41
+ const seen = [];
42
+ const send = async (cmd) => {
43
+ seen.push(cmd.input.NextToken);
44
+ if (cmd.input.NextToken === undefined) {
45
+ return {
46
+ StackResourceSummaries: [
47
+ { ResourceType: 'AWS::S3::Bucket', PhysicalResourceId: 'b1' },
48
+ { ResourceType: 'AWS::Lambda::Function', PhysicalResourceId: 'fn' },
49
+ ],
50
+ NextToken: 't2',
51
+ };
52
+ }
53
+ return { StackResourceSummaries: [{ ResourceType: 'AWS::S3::Bucket', PhysicalResourceId: 'b2' }] };
54
+ };
55
+ const cfn = { send };
56
+ const buckets = await listStackBucketNames(cfn, 'stack');
57
+ assert.deepStrictEqual(buckets, ['b1', 'b2']);
58
+ assert.deepStrictEqual(seen, [undefined, 't2']); // paged exactly twice
59
+ });
60
+ });
61
+ describe('emptyBucket', () => {
62
+ it('deletes versions + markers, re-lists, and stops when empty', async () => {
63
+ let lists = 0;
64
+ const deletePayloads = [];
65
+ const send = async (cmd) => {
66
+ if (cmd.constructor.name === 'ListObjectVersionsCommand') {
67
+ lists++;
68
+ return lists === 1
69
+ ? { Versions: [{ Key: 'a', VersionId: 'v1' }], DeleteMarkers: [{ Key: 'a', VersionId: 'd1' }] }
70
+ : {};
71
+ }
72
+ if (cmd.constructor.name === 'DeleteObjectsCommand') {
73
+ deletePayloads.push(cmd.input.Delete.Objects);
74
+ return {};
75
+ }
76
+ throw new Error(`unexpected ${cmd.constructor.name}`);
77
+ };
78
+ await emptyBucket({ send }, 'bucket');
79
+ assert.strictEqual(lists, 2); // listed, deleted, re-listed → empty
80
+ assert.deepStrictEqual(deletePayloads, [
81
+ [
82
+ { Key: 'a', VersionId: 'v1' },
83
+ { Key: 'a', VersionId: 'd1' },
84
+ ],
85
+ ]);
86
+ });
87
+ it('stops (no infinite loop) when DeleteObjects reports Errors', async () => {
88
+ let lists = 0;
89
+ let deletes = 0;
90
+ const send = async (cmd) => {
91
+ if (cmd.constructor.name === 'ListObjectVersionsCommand') {
92
+ lists++;
93
+ return { Versions: [{ Key: 'locked', VersionId: 'v1' }] };
94
+ }
95
+ if (cmd.constructor.name === 'DeleteObjectsCommand') {
96
+ deletes++;
97
+ return { Errors: [{ Key: 'locked', Code: 'AccessDenied' }] };
98
+ }
99
+ throw new Error(`unexpected ${cmd.constructor.name}`);
100
+ };
101
+ await emptyBucket({ send }, 'bucket');
102
+ assert.strictEqual(lists, 1); // did NOT re-list after Errors
103
+ assert.strictEqual(deletes, 1);
104
+ });
105
+ });
106
+ // The sev2 fix is the wiring: on a destroy failure, buckets get emptied BEFORE
107
+ // the retry. runDestroyWithRetries takes its side effects as deps so we can
108
+ // exercise that ordering without spawning cdk.
109
+ describe('runDestroyWithRetries', () => {
110
+ it('empties buckets before retrying a failed destroy', async () => {
111
+ const events = [];
112
+ let attempts = 0;
113
+ await runDestroyWithRetries({
114
+ runDestroy: () => {
115
+ attempts++;
116
+ events.push(`destroy#${attempts}`);
117
+ if (attempts === 1)
118
+ throw new Error('bucket not empty');
119
+ },
120
+ listStackNames: () => {
121
+ events.push('list');
122
+ return ['stackA', 'stackB'];
123
+ },
124
+ emptyBuckets: async (names) => {
125
+ events.push(`empty:${names.join(',')}`);
126
+ },
127
+ sleep: async () => {
128
+ events.push('sleep');
129
+ },
130
+ retryDelays: [1],
131
+ });
132
+ assert.strictEqual(attempts, 2);
133
+ // buckets emptied (with the resolved stack names) before the retry destroy
134
+ assert.deepStrictEqual(events, ['destroy#1', 'list', 'empty:stackA,stackB', 'sleep', 'destroy#2']);
135
+ });
136
+ it('does not empty or sleep when the first destroy succeeds', async () => {
137
+ let emptied = false;
138
+ let slept = false;
139
+ let listed = false;
140
+ await runDestroyWithRetries({
141
+ runDestroy: () => { },
142
+ listStackNames: () => {
143
+ listed = true;
144
+ return [];
145
+ },
146
+ emptyBuckets: async () => {
147
+ emptied = true;
148
+ },
149
+ sleep: async () => {
150
+ slept = true;
151
+ },
152
+ });
153
+ assert.strictEqual(emptied, false);
154
+ assert.strictEqual(slept, false);
155
+ assert.strictEqual(listed, false);
156
+ });
157
+ it('throws after exhausting retries', async () => {
158
+ let attempts = 0;
159
+ await assert.rejects(runDestroyWithRetries({
160
+ runDestroy: () => {
161
+ attempts++;
162
+ throw new Error('still failing');
163
+ },
164
+ listStackNames: () => [],
165
+ emptyBuckets: async () => { },
166
+ sleep: async () => { },
167
+ retryDelays: [1, 1],
168
+ }), /still failing/);
169
+ assert.strictEqual(attempts, 3); // initial attempt + 2 retries
170
+ });
171
+ });
@@ -1,3 +1,5 @@
1
+ import type { CloudFormationClient } from '@aws-sdk/client-cloudformation';
2
+ import type { S3Client } from '@aws-sdk/client-s3';
1
3
  export interface SandboxOptions {
2
4
  backendPath: string;
3
5
  outDir?: string;
@@ -59,5 +61,57 @@ export declare function buildSandboxDeployArgs({ outDir, projectRoot, backendPat
59
61
  */
60
62
  export declare function sandboxFailureRecoveryHint(): string;
61
63
  export declare function startSandbox(options: SandboxOptions): Promise<string | undefined>;
64
+ /**
65
+ * Flatten a ListObjectVersions response into the `{ Key, VersionId }[]` shape
66
+ * DeleteObjects expects, combining live versions AND delete markers. Exported
67
+ * for unit testing — getting this combination wrong (e.g. missing the delete
68
+ * markers) leaves a "versioned" bucket that still can't be deleted.
69
+ */
70
+ export declare function toDeleteObjects(listed: {
71
+ Versions?: Array<{
72
+ Key?: string;
73
+ VersionId?: string;
74
+ }>;
75
+ DeleteMarkers?: Array<{
76
+ Key?: string;
77
+ VersionId?: string;
78
+ }>;
79
+ }): Array<{
80
+ Key: string;
81
+ VersionId?: string;
82
+ }>;
83
+ /** Collect a stack's S3 bucket physical names (paginated — a large stack has
84
+ * >100 resources, so a single page can miss the hosting bucket that blocks the
85
+ * delete). Returns [] if the stack is already gone / not accessible. */
86
+ export declare function listStackBucketNames(cfn: CloudFormationClient, stackName: string): Promise<string[]>;
87
+ /** Empty a versioned bucket: delete every object version + delete marker in
88
+ * pages of 1000 (the DeleteObjects limit). Stops if a page reports per-key
89
+ * errors (object-lock / retention) so it can't loop forever. */
90
+ export declare function emptyBucket(s3: S3Client, bucket: string): Promise<void>;
91
+ /** Injectable dependencies for {@link runDestroyWithRetries} — real ones in
92
+ * `destroySandbox`, fakes in tests so the retry/empty-before-retry wiring (the
93
+ * sev2 fix) is exercised without spawning cdk. */
94
+ export interface DestroyRetryDeps {
95
+ /** Run one `cdk destroy` attempt; throws on failure. */
96
+ runDestroy: () => void;
97
+ /** Resolve the app's stack names (for bucket enumeration). */
98
+ listStackNames: () => string[];
99
+ /** Empty the given stacks' versioned S3 buckets. */
100
+ emptyBuckets: (stackNames: string[]) => Promise<void>;
101
+ /** Sleep (VPC-ENI detach window). */
102
+ sleep: (ms: number) => Promise<void>;
103
+ /** Backoff between retries. Default: 1min, then 2min. */
104
+ retryDelays?: number[];
105
+ }
106
+ /**
107
+ * Retry `cdk destroy`, clearing the two known teardown blockers before each
108
+ * retry: (1) non-empty versioned S3 buckets — `cdk destroy` relies on each
109
+ * bucket's autoDeleteObjects Lambda, which never provisioned if the CREATE
110
+ * failed, so the bucket blocks the delete and the stack (+ its IAM roles) leaks;
111
+ * we empty them out-of-band — and (2) VPC ENIs that take 60-120s to detach (the
112
+ * backoff). Stack names are resolved once and reused (topology is stable across
113
+ * attempts). Factored out with injectable deps so this behavior is unit-testable.
114
+ */
115
+ export declare function runDestroyWithRetries(deps: DestroyRetryDeps): Promise<void>;
62
116
  export declare function destroySandbox(backendPath: string): Promise<void>;
63
117
  //# sourceMappingURL=sandbox.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/scripts/sandbox.ts"],"names":[],"mappings":"AAiCA,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,wBAAwB;IACvC,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,2FAA2F;IAC3F,WAAW,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,EAAE,wBAAwB,GAAG,MAAM,EAAE,CAY/G;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAYnD;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,+BAwMzD;AAED,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,iBAwCvD"}
1
+ {"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/scripts/sandbox.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAkBnD,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,wBAAwB;IACvC,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,2FAA2F;IAC3F,WAAW,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,EAAE,wBAAwB,GAAG,MAAM,EAAE,CAY/G;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAYnD;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,+BAwMzD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE;IACvC,QAAQ,CAAC,EAAE,KAAK,CAAC;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD,aAAa,CAAC,EAAE,KAAK,CAAC;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5D,GAAG,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAI7C;AAED;;wEAEwE;AACxE,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAY1G;AAED;;gEAEgE;AAChE,wBAAsB,WAAW,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB7E;AAmED;;kDAEkD;AAClD,MAAM,WAAW,gBAAgB;IAChC,wDAAwD;IACxD,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,8DAA8D;IAC9D,cAAc,EAAE,MAAM,MAAM,EAAE,CAAC;IAC/B,oDAAoD;IACpD,YAAY,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,qCAAqC;IACrC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwBjF;AAED,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,iBAyBvD"}
@@ -279,6 +279,160 @@ export async function startSandbox(options) {
279
279
  process.on("SIGTERM", cleanup);
280
280
  await new Promise(() => { });
281
281
  }
282
+ /**
283
+ * Flatten a ListObjectVersions response into the `{ Key, VersionId }[]` shape
284
+ * DeleteObjects expects, combining live versions AND delete markers. Exported
285
+ * for unit testing — getting this combination wrong (e.g. missing the delete
286
+ * markers) leaves a "versioned" bucket that still can't be deleted.
287
+ */
288
+ export function toDeleteObjects(listed) {
289
+ return [...(listed.Versions ?? []), ...(listed.DeleteMarkers ?? [])]
290
+ .filter((v) => v.Key !== undefined)
291
+ .map((v) => ({ Key: v.Key, VersionId: v.VersionId }));
292
+ }
293
+ /** Collect a stack's S3 bucket physical names (paginated — a large stack has
294
+ * >100 resources, so a single page can miss the hosting bucket that blocks the
295
+ * delete). Returns [] if the stack is already gone / not accessible. */
296
+ export async function listStackBucketNames(cfn, stackName) {
297
+ const { ListStackResourcesCommand } = await import('@aws-sdk/client-cloudformation');
298
+ const buckets = [];
299
+ let token;
300
+ do {
301
+ const res = await cfn.send(new ListStackResourcesCommand({ StackName: stackName, NextToken: token }));
302
+ for (const r of res.StackResourceSummaries ?? []) {
303
+ if (r.ResourceType === 'AWS::S3::Bucket' && r.PhysicalResourceId)
304
+ buckets.push(r.PhysicalResourceId);
305
+ }
306
+ token = res.NextToken;
307
+ } while (token);
308
+ return buckets;
309
+ }
310
+ /** Empty a versioned bucket: delete every object version + delete marker in
311
+ * pages of 1000 (the DeleteObjects limit). Stops if a page reports per-key
312
+ * errors (object-lock / retention) so it can't loop forever. */
313
+ export async function emptyBucket(s3, bucket) {
314
+ const { ListObjectVersionsCommand, DeleteObjectsCommand } = await import('@aws-sdk/client-s3');
315
+ while (true) {
316
+ const listed = await s3.send(new ListObjectVersionsCommand({ Bucket: bucket, MaxKeys: 1000 }));
317
+ const objects = toDeleteObjects(listed);
318
+ if (objects.length === 0)
319
+ return;
320
+ const res = await s3.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: objects, Quiet: true } }));
321
+ if (res.Errors && res.Errors.length > 0) {
322
+ // A bucket we can't fully empty (object-lock / retention / AccessDenied)
323
+ // will keep blocking `cdk destroy`, so the stack won't tear down until it's
324
+ // resolved by hand. Log loudly with the first error code — this is the
325
+ // kind of silent skip that leaks a stack, so make it visible in CI output.
326
+ const first = res.Errors[0];
327
+ console.warn(` ⚠️ ${bucket}: ${res.Errors.length} object(s) could NOT be deleted ` +
328
+ `(e.g. ${first.Key}: ${first.Code}). This bucket will block stack teardown ` +
329
+ `until cleared manually.`);
330
+ return;
331
+ }
332
+ }
333
+ }
334
+ /**
335
+ * Empty every versioned S3 bucket owned by the given stacks. `cdk destroy`
336
+ * relies on each bucket's `autoDeleteObjects` custom-resource Lambda to empty
337
+ * it on teardown — but that Lambda never provisions if the CREATE failed (e.g.
338
+ * a partial/aborted deploy), so the versioned bucket blocks the delete and the
339
+ * stack (and its IAM roles) leak. Emptying out-of-band before the retry lets
340
+ * the delete complete. Best-effort: any per-stack/per-bucket error is logged
341
+ * and skipped so teardown still proceeds.
342
+ */
343
+ async function emptySandboxBuckets(stackNames) {
344
+ try {
345
+ const { CloudFormationClient } = await import('@aws-sdk/client-cloudformation');
346
+ const { S3Client } = await import('@aws-sdk/client-s3');
347
+ const cfn = new CloudFormationClient({});
348
+ // `followRegionRedirects` makes the client transparently retry against a
349
+ // bucket's real region on a PermanentRedirect, so a bucket that lives
350
+ // outside AWS_REGION (e.g. a us-east-1 Lambda@Edge stack's bucket) is still
351
+ // emptied instead of failing silently — closing the exact no-op this fix
352
+ // targets. It needs no GetBucketLocation permission.
353
+ const s3 = new S3Client({ followRegionRedirects: true });
354
+ for (const stackName of stackNames) {
355
+ let buckets = [];
356
+ try {
357
+ buckets = await listStackBucketNames(cfn, stackName);
358
+ }
359
+ catch {
360
+ continue; // stack already gone / not accessible
361
+ }
362
+ for (const b of buckets) {
363
+ try {
364
+ await emptyBucket(s3, b);
365
+ }
366
+ catch (e) {
367
+ console.warn(` ⚠️ could not empty ${b}: ${e.message}`);
368
+ }
369
+ }
370
+ }
371
+ }
372
+ catch (e) {
373
+ console.warn(` ⚠️ bucket-emptying step skipped: ${e.message}`);
374
+ }
375
+ }
376
+ /** Resolve the sandbox app's stack names via `cdk ls` (best-effort; [] on error). */
377
+ function listSandboxStackNames(backendPath, cdkEnv) {
378
+ try {
379
+ const out = execFileSync('npm',
380
+ // Single-quote backendPath: CDK re-runs the --app string through a shell,
381
+ // so an unquoted path containing a space would split.
382
+ ['exec', 'cdk', '--', 'ls', '--context', 'sandboxMode=true', '--app', `npm exec tsx -- -C cdk '${backendPath}'`],
383
+ // Capture stderr so a genuine synth/`cdk ls` failure can be logged below
384
+ // rather than being indistinguishable from "app has zero stacks".
385
+ { env: cdkEnv, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
386
+ return out
387
+ .split('\n')
388
+ .map((s) => s.trim())
389
+ .filter(Boolean);
390
+ }
391
+ catch (e) {
392
+ // Warn (don't stay silent): if this returns [] on a real failure, the retry
393
+ // skips bucket-emptying — the exact silent-no-op this change exists to avoid.
394
+ const detail = e.stderr || e.message;
395
+ console.warn(` ⚠️ could not list sandbox stacks via 'cdk ls'; skipping bucket-emptying: ${detail}`);
396
+ return [];
397
+ }
398
+ }
399
+ /**
400
+ * Retry `cdk destroy`, clearing the two known teardown blockers before each
401
+ * retry: (1) non-empty versioned S3 buckets — `cdk destroy` relies on each
402
+ * bucket's autoDeleteObjects Lambda, which never provisioned if the CREATE
403
+ * failed, so the bucket blocks the delete and the stack (+ its IAM roles) leaks;
404
+ * we empty them out-of-band — and (2) VPC ENIs that take 60-120s to detach (the
405
+ * backoff). Stack names are resolved once and reused (topology is stable across
406
+ * attempts). Factored out with injectable deps so this behavior is unit-testable.
407
+ */
408
+ export async function runDestroyWithRetries(deps) {
409
+ const retryDelays = deps.retryDelays ?? [60_000, 120_000];
410
+ let stackNames;
411
+ for (let attempt = 0;; attempt++) {
412
+ try {
413
+ deps.runDestroy();
414
+ console.log(attempt === 0 ? '\n✅ Sandbox destroyed!' : '\n✅ Sandbox destroyed on retry!');
415
+ return;
416
+ }
417
+ catch (error) {
418
+ if (attempt < retryDelays.length) {
419
+ if (stackNames === undefined)
420
+ stackNames = deps.listStackNames();
421
+ if (stackNames.length > 0) {
422
+ console.log('\n🧹 Emptying versioned S3 buckets before retry...');
423
+ await deps.emptyBuckets(stackNames);
424
+ }
425
+ const delaySec = retryDelays[attempt] / 1000;
426
+ console.log(`\n⏳ Stack deletion failed. Retrying in ${delaySec}s (waiting for resource cleanup)...`);
427
+ await deps.sleep(retryDelays[attempt]);
428
+ }
429
+ else {
430
+ console.error('\n❌ Destroy failed after retries.');
431
+ throw error;
432
+ }
433
+ }
434
+ }
435
+ }
282
436
  export async function destroySandbox(backendPath) {
283
437
  return trackCommand('sandbox:destroy', async () => {
284
438
  console.log("🗑️ Destroying sandbox...");
@@ -294,32 +448,16 @@ export async function destroySandbox(backendPath) {
294
448
  "exec", "cdk", "--", "destroy",
295
449
  "--force",
296
450
  "--context", "sandboxMode=true",
297
- "--app", `npm exec tsx -- -C cdk ${backendPath}`,
451
+ // Single-quote backendPath: CDK re-runs the --app string through a shell,
452
+ // so an unquoted path containing a space would split.
453
+ "--app", `npm exec tsx -- -C cdk '${backendPath}'`,
298
454
  ];
299
455
  const cdkEnv = { ...process.env, NODE_OPTIONS: "--conditions=cdk", ...getCdkTelemetryEnv('sandbox') };
300
- // Retry with backoff for VPC-dependent resources (e.g. Aurora clusters).
301
- // CloudFormation deletes the cluster first, but its ENIs take 60-120s to
302
- // detach from the VPC subnets asynchronously. The initial destroy fails
303
- // because the subnets still have attached ENIs; retrying after the cleanup
304
- // window lets the VPC delete succeed.
305
- const retryDelays = [60_000, 120_000]; // 1min, then 2min
306
- for (let attempt = 0;; attempt++) {
307
- try {
308
- runSync("npm", cdkArgs, { stdio: "inherit", env: cdkEnv });
309
- console.log(attempt === 0 ? "\n✅ Sandbox destroyed!" : "\n✅ Sandbox destroyed on retry!");
310
- return;
311
- }
312
- catch (error) {
313
- if (attempt < retryDelays.length) {
314
- const delaySec = retryDelays[attempt] / 1000;
315
- console.log(`\n⏳ Stack deletion failed. Retrying in ${delaySec}s (waiting for resource cleanup)...`);
316
- await new Promise(r => setTimeout(r, retryDelays[attempt]));
317
- }
318
- else {
319
- console.error("\n❌ Destroy failed after retries.");
320
- throw error;
321
- }
322
- }
323
- }
456
+ await runDestroyWithRetries({
457
+ runDestroy: () => runSync('npm', cdkArgs, { stdio: 'inherit', env: cdkEnv }),
458
+ listStackNames: () => listSandboxStackNames(backendPath, cdkEnv),
459
+ emptyBuckets: emptySandboxBuckets,
460
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
461
+ });
324
462
  });
325
463
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CORE_VERSION = "0.3.0";
1
+ export declare const CORE_VERSION = "0.5.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.3.0';
2
+ export const CORE_VERSION = '0.5.0';
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "@aws-blocks/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
+ "keywords": [
5
+ "aws-blocks",
6
+ "framework",
7
+ "typescript",
8
+ "backend",
9
+ "primitives"
10
+ ],
4
11
  "repository": {
5
12
  "type": "git",
6
13
  "url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
@@ -52,6 +59,7 @@
52
59
  "require": "./dist/server/index.js"
53
60
  },
54
61
  "./scripts": "./dist/scripts/index.js",
62
+ "./scripts/generate-client-worker": "./dist/scripts/generate-client-worker.js",
55
63
  "./db-naming": {
56
64
  "types": "./dist/db-naming.d.ts",
57
65
  "default": "./dist/db-naming.js"
@@ -78,6 +86,7 @@
78
86
  "dependencies": {
79
87
  "@aws-blocks/hosting": "*",
80
88
  "@aws-blocks/pipeline": "*",
89
+ "@aws-sdk/client-cloudformation": "^3.700.0",
81
90
  "@aws-sdk/client-s3": "^3.700.0",
82
91
  "@aws-sdk/client-ssm": "^3.700.0",
83
92
  "@aws-sdk/client-sts": "^3.700.0",
package/src/api.test.ts CHANGED
@@ -23,3 +23,28 @@ test('Different ApiNamespace names should not collide', () => {
23
23
  assert.strictEqual((api1 as any)[API_NAMESPACE_MARKER], 'api1');
24
24
  assert.strictEqual((api2 as any)[API_NAMESPACE_MARKER], 'api2');
25
25
  });
26
+
27
+ // ApiNamespace records its name on the scope's resolved compute (CDK
28
+ // synth), while staying a silent no-op where no compute is resolvable.
29
+ test('ApiNamespace records its name on a resolvable compute', () => {
30
+ const compute = { namespaces: [] as string[] };
31
+ new ApiNamespace({ id: 'app', compute } as never, 'myapi', () => ({ ping: () => 'ok' }));
32
+ assert.deepStrictEqual(compute.namespaces, ['myapi']);
33
+ });
34
+
35
+ test('ApiNamespace is a no-op recorder when the scope has no compute', () => {
36
+ // The common Scope (mock/runtime) has no `compute`; recording must not throw
37
+ // and the handler is still tagged and returned unchanged.
38
+ const handler = new ApiNamespace(new Scope('no-compute'), 'plainapi', () => ({ x: () => 1 }));
39
+ assert.strictEqual((handler as any)[API_NAMESPACE_MARKER], 'plainapi');
40
+ });
41
+
42
+ test('ApiNamespace records a namespace at most once per compute', () => {
43
+ // `namespaces` is a set of names: recording the same name twice (e.g. a
44
+ // re-imported module during synth) must not append a duplicate.
45
+ const compute = { namespaces: [] as string[] };
46
+ const scopeLike = { id: 'app', compute } as never;
47
+ new ApiNamespace(scopeLike, 'dup', () => ({ ping: () => 'ok' }));
48
+ new ApiNamespace(scopeLike, 'dup', () => ({ ping: () => 'ok' }));
49
+ assert.deepStrictEqual(compute.namespaces, ['dup']);
50
+ });
package/src/api.ts CHANGED
@@ -59,6 +59,42 @@ export const API_NAMESPACE_MARKER = Symbol.for('blocks:ApiNamespace');
59
59
 
60
60
  import type { ScopeParent } from './common/index.js';
61
61
 
62
+ /**
63
+ * Structural view of a scope that resolves a compute carrying a recordable
64
+ * namespace list. Only the CDK `Scope` satisfies this at runtime (via its
65
+ * `compute` getter); in the mock/runtime bundles the property is absent.
66
+ */
67
+ type ComputeResolvingScope = { compute?: { namespaces?: string[] } };
68
+
69
+ /**
70
+ * Record this namespace on its resolved compute so request routing can later
71
+ * map a namespace to the compute that hosts it.
72
+ *
73
+ * CDK-synth concern only: in the CDK bundle `scope.compute` resolves to the
74
+ * namespace's compute (the stack default today), and we append the name to its
75
+ * `namespaces` list. In the mock/runtime bundles — or a scopeless test — the
76
+ * property is absent, so `scope.compute` is `undefined` and this is a silent
77
+ * no-op. The public `ApiNamespace` signature is unchanged; this is a purely
78
+ * internal side effect.
79
+ *
80
+ * No `try/catch`: `BlocksBackend.create()` initializes the default compute
81
+ * before it imports the backend module that constructs any `ApiNamespace`, so
82
+ * the `compute` getter always resolves at this call site. If it ever threw
83
+ * here it would signal a genuine lifecycle violation (an `ApiNamespace` built
84
+ * before `create()` resolved), which should surface rather than be swallowed.
85
+ */
86
+ function recordNamespaceOnCompute(scope: ScopeParent | null | undefined, name: string): void {
87
+ // An API created without a Scope stays unrecorded and routes to the
88
+ // default compute.
89
+ if (!scope || typeof scope !== 'object') return;
90
+ const compute = (scope as ComputeResolvingScope).compute;
91
+ // Guard against duplicates so `namespaces` stays a set of names: a namespace
92
+ // could otherwise be recorded twice (e.g. a re-imported module during synth).
93
+ if (compute && Array.isArray(compute.namespaces) && !compute.namespaces.includes(name)) {
94
+ compute.namespaces.push(name);
95
+ }
96
+ }
97
+
62
98
  /**
63
99
  * Define a type-safe API namespace that works seamlessly between frontend and backend.
64
100
  *
@@ -157,6 +193,9 @@ export interface ApiNamespaceConstructor {
157
193
  export const ApiNamespace: ApiNamespaceConstructor = class ApiNamespace {
158
194
  constructor(scope: ScopeParent, name: string, handler: any) {
159
195
  handler[API_NAMESPACE_MARKER] = name;
196
+ // Record the namespace → compute association for per-compute routing.
197
+ // No-op outside CDK synth. Signature and returned handler are unchanged.
198
+ recordNamespaceOnCompute(scope, name);
160
199
  return handler;
161
200
  }
162
201
  } as any;
package/src/bb-utils.ts CHANGED
@@ -11,3 +11,6 @@ export { getMockDataDir } from './common/mock-data.js';
11
11
  export { API_NAMESPACE_MARKER } from './api.js';
12
12
  export { EventSourceMapping } from './lambda-handler.js';
13
13
  export { constantTimeEquals } from './common/crypto.js';
14
+ // Defined in the config module (it owns the config-key contract), re-exported
15
+ // here so BB authors get it alongside the other BB utilities.
16
+ export { sanitizeConfigKey } from './common/config.js';
@@ -0,0 +1,66 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type * as cdk from 'aws-cdk-lib';
5
+ import { CfnAccount } from 'aws-cdk-lib/aws-apigateway';
6
+ import { ManagedPolicy, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
7
+
8
+ /**
9
+ * Global registry key for the per-stack API Gateway CloudWatch Logs account
10
+ * resource. Deliberately a `Symbol.for(...)` string key so the SAME symbol is
11
+ * shared across packages (core, hosting) — each package can carry its own copy
12
+ * of {@link ensureApiGatewayAccount} yet still find and reuse the one account a
13
+ * sibling package already created on the stack, instead of emitting a second
14
+ * `AWS::ApiGateway::Account` (which is account/region-level and would clash).
15
+ */
16
+ const ACCOUNT_KEY = Symbol.for('BLOCKS_APIGATEWAY_ACCOUNT');
17
+
18
+ /**
19
+ * Ensure the account-level API Gateway CloudWatch Logs role exists on `stack`,
20
+ * creating it at most once per stack.
21
+ *
22
+ * API Gateway access logging — for both REST (v1) and WebSocket (v2) stages —
23
+ * requires an `AWS::ApiGateway::Account` whose `cloudWatchRoleArn` points at a
24
+ * role trusted by `apigateway.amazonaws.com` and carrying
25
+ * `AmazonAPIGatewayPushToCloudWatchLogs`. Nothing else in a Blocks stack
26
+ * provisions it, so a clean-account first deploy of any access-logging stage
27
+ * otherwise fails at CreateStage with "CloudWatch Logs role ARN must be set in
28
+ * account settings to enable logging".
29
+ *
30
+ * Callers should add their stage's dependency on the returned account so the
31
+ * account setting is applied before the stage is created.
32
+ *
33
+ * ⚠️ Single-Blocks-stack-per-region assumption. `AWS::ApiGateway::Account` is an
34
+ * account/region-level singleton (one effective `cloudWatchRoleArn` per region),
35
+ * but this provisions one per Blocks stack. If two Blocks stacks in the same
36
+ * account+region both enable access logging:
37
+ * - on deploy, the later stack's role wins (overwrites the account setting); and
38
+ * - on teardown of that later stack, its role is deleted and the account
39
+ * setting is left pointing at a now-deleted role — silently breaking access
40
+ * logging (or blocking `CreateStage`) for the surviving stack until it
41
+ * redeploys.
42
+ * Enabling `accessLogging` is therefore safe for one Blocks stack per region.
43
+ * Multi-stack support (a shared/imported account) is deferred; if you run
44
+ * multiple Blocks stacks per region, manage the account role out-of-band and
45
+ * leave `accessLogging` to a single owner.
46
+ */
47
+ export function ensureApiGatewayAccount(stack: cdk.Stack): CfnAccount {
48
+ const existing = (stack as unknown as Record<symbol, CfnAccount | undefined>)[ACCOUNT_KEY];
49
+ if (existing) return existing;
50
+
51
+ const role = new Role(stack, 'BlocksApiGatewayCloudWatchRole', {
52
+ assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
53
+ managedPolicies: [
54
+ ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonAPIGatewayPushToCloudWatchLogs'),
55
+ ],
56
+ });
57
+
58
+ // `cloudWatchRoleArn: role.roleArn` already gives CDK an implicit dependency
59
+ // on the role, so no explicit node.addDependency is needed.
60
+ const account = new CfnAccount(stack, 'BlocksApiGatewayAccount', {
61
+ cloudWatchRoleArn: role.roleArn,
62
+ });
63
+
64
+ (stack as unknown as Record<symbol, CfnAccount | undefined>)[ACCOUNT_KEY] = account;
65
+ return account;
66
+ }