@aws-blocks/core 0.1.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 (394) hide show
  1. package/LICENSE +174 -0
  2. package/dist/api.d.ts +147 -0
  3. package/dist/api.d.ts.map +1 -0
  4. package/dist/api.js +10 -0
  5. package/dist/api.test.d.ts +2 -0
  6. package/dist/api.test.d.ts.map +1 -0
  7. package/dist/api.test.js +19 -0
  8. package/dist/bb-utils.d.ts +11 -0
  9. package/dist/bb-utils.d.ts.map +1 -0
  10. package/dist/bb-utils.js +12 -0
  11. package/dist/builtin-routes.d.ts +6 -0
  12. package/dist/builtin-routes.d.ts.map +1 -0
  13. package/dist/builtin-routes.js +57 -0
  14. package/dist/cdk/__fixtures__/factory-backend.d.ts +3 -0
  15. package/dist/cdk/__fixtures__/factory-backend.d.ts.map +1 -0
  16. package/dist/cdk/__fixtures__/factory-backend.js +6 -0
  17. package/dist/cdk/__fixtures__/fullid-construct-backend.d.ts +2 -0
  18. package/dist/cdk/__fixtures__/fullid-construct-backend.d.ts.map +1 -0
  19. package/dist/cdk/__fixtures__/fullid-construct-backend.js +37 -0
  20. package/dist/cdk/__fixtures__/handler.d.ts +5 -0
  21. package/dist/cdk/__fixtures__/handler.d.ts.map +1 -0
  22. package/dist/cdk/__fixtures__/handler.js +3 -0
  23. package/dist/cdk/__fixtures__/side-effect-backend.d.ts +2 -0
  24. package/dist/cdk/__fixtures__/side-effect-backend.d.ts.map +1 -0
  25. package/dist/cdk/__fixtures__/side-effect-backend.js +5 -0
  26. package/dist/cdk/blocks-backend.d.ts +68 -0
  27. package/dist/cdk/blocks-backend.d.ts.map +1 -0
  28. package/dist/cdk/blocks-backend.js +214 -0
  29. package/dist/cdk/blocks-backend.test.d.ts +2 -0
  30. package/dist/cdk/blocks-backend.test.d.ts.map +1 -0
  31. package/dist/cdk/blocks-backend.test.js +151 -0
  32. package/dist/cdk/blocks-stack.test.d.ts +2 -0
  33. package/dist/cdk/blocks-stack.test.d.ts.map +1 -0
  34. package/dist/cdk/blocks-stack.test.js +78 -0
  35. package/dist/cdk/config-registry.d.ts +26 -0
  36. package/dist/cdk/config-registry.d.ts.map +1 -0
  37. package/dist/cdk/config-registry.js +80 -0
  38. package/dist/cdk/index.d.ts +35 -0
  39. package/dist/cdk/index.d.ts.map +1 -0
  40. package/dist/cdk/index.js +90 -0
  41. package/dist/cdk/mixins.d.ts +28 -0
  42. package/dist/cdk/mixins.d.ts.map +1 -0
  43. package/dist/cdk/mixins.js +37 -0
  44. package/dist/cdk/mixins.test.d.ts +2 -0
  45. package/dist/cdk/mixins.test.d.ts.map +1 -0
  46. package/dist/cdk/mixins.test.js +108 -0
  47. package/dist/cdk/node-version.d.ts +14 -0
  48. package/dist/cdk/node-version.d.ts.map +1 -0
  49. package/dist/cdk/node-version.js +15 -0
  50. package/dist/cdk/stack-metadata.d.ts +12 -0
  51. package/dist/cdk/stack-metadata.d.ts.map +1 -0
  52. package/dist/cdk/stack-metadata.js +22 -0
  53. package/dist/cdk/stack-metadata.test.d.ts +2 -0
  54. package/dist/cdk/stack-metadata.test.d.ts.map +1 -0
  55. package/dist/cdk/stack-metadata.test.js +56 -0
  56. package/dist/cdk/synth-guard.d.ts +14 -0
  57. package/dist/cdk/synth-guard.d.ts.map +1 -0
  58. package/dist/cdk/synth-guard.js +20 -0
  59. package/dist/cdk/synth-guard.test.d.ts +2 -0
  60. package/dist/cdk/synth-guard.test.d.ts.map +1 -0
  61. package/dist/cdk/synth-guard.test.js +15 -0
  62. package/dist/client/index.d.ts +97 -0
  63. package/dist/client/index.d.ts.map +1 -0
  64. package/dist/client/index.js +238 -0
  65. package/dist/client/index.test.d.ts +2 -0
  66. package/dist/client/index.test.d.ts.map +1 -0
  67. package/dist/client/index.test.js +113 -0
  68. package/dist/common/config.d.ts +45 -0
  69. package/dist/common/config.d.ts.map +1 -0
  70. package/dist/common/config.js +154 -0
  71. package/dist/common/config.test.d.ts +2 -0
  72. package/dist/common/config.test.d.ts.map +1 -0
  73. package/dist/common/config.test.js +173 -0
  74. package/dist/common/constants.d.ts +11 -0
  75. package/dist/common/constants.d.ts.map +1 -0
  76. package/dist/common/constants.js +12 -0
  77. package/dist/common/crypto.d.ts +6 -0
  78. package/dist/common/crypto.d.ts.map +1 -0
  79. package/dist/common/crypto.js +14 -0
  80. package/dist/common/index.d.ts +220 -0
  81. package/dist/common/index.d.ts.map +1 -0
  82. package/dist/common/index.js +180 -0
  83. package/dist/common/index.test.d.ts +2 -0
  84. package/dist/common/index.test.d.ts.map +1 -0
  85. package/dist/common/index.test.js +195 -0
  86. package/dist/common/mock-data.d.ts +40 -0
  87. package/dist/common/mock-data.d.ts.map +1 -0
  88. package/dist/common/mock-data.js +39 -0
  89. package/dist/common/official-bb-names.generated.d.ts +7 -0
  90. package/dist/common/official-bb-names.generated.d.ts.map +1 -0
  91. package/dist/common/official-bb-names.generated.js +28 -0
  92. package/dist/common/sdk-registry.d.ts +136 -0
  93. package/dist/common/sdk-registry.d.ts.map +1 -0
  94. package/dist/common/sdk-registry.js +125 -0
  95. package/dist/constants.d.ts +27 -0
  96. package/dist/constants.d.ts.map +1 -0
  97. package/dist/constants.js +28 -0
  98. package/dist/cors.d.ts +39 -0
  99. package/dist/cors.d.ts.map +1 -0
  100. package/dist/cors.js +85 -0
  101. package/dist/cors.test.d.ts +2 -0
  102. package/dist/cors.test.d.ts.map +1 -0
  103. package/dist/cors.test.js +241 -0
  104. package/dist/db-naming.d.ts +21 -0
  105. package/dist/db-naming.d.ts.map +1 -0
  106. package/dist/db-naming.js +38 -0
  107. package/dist/db-naming.test.d.ts +2 -0
  108. package/dist/db-naming.test.d.ts.map +1 -0
  109. package/dist/db-naming.test.js +29 -0
  110. package/dist/errors.d.ts +67 -0
  111. package/dist/errors.d.ts.map +1 -0
  112. package/dist/errors.js +69 -0
  113. package/dist/hosting.d.ts +258 -0
  114. package/dist/hosting.d.ts.map +1 -0
  115. package/dist/hosting.js +340 -0
  116. package/dist/hosting.test.d.ts +2 -0
  117. package/dist/hosting.test.d.ts.map +1 -0
  118. package/dist/hosting.test.js +1077 -0
  119. package/dist/index.cdk.d.ts +13 -0
  120. package/dist/index.cdk.d.ts.map +1 -0
  121. package/dist/index.cdk.js +13 -0
  122. package/dist/index.d.ts +9 -0
  123. package/dist/index.d.ts.map +1 -0
  124. package/dist/index.js +10 -0
  125. package/dist/lambda-handler.d.ts +131 -0
  126. package/dist/lambda-handler.d.ts.map +1 -0
  127. package/dist/lambda-handler.js +610 -0
  128. package/dist/lambda-handler.test.d.ts +2 -0
  129. package/dist/lambda-handler.test.d.ts.map +1 -0
  130. package/dist/lambda-handler.test.js +835 -0
  131. package/dist/pipeline/index.d.ts +4 -0
  132. package/dist/pipeline/index.d.ts.map +1 -0
  133. package/dist/pipeline/index.js +3 -0
  134. package/dist/pipeline/pipeline-construct.d.ts +130 -0
  135. package/dist/pipeline/pipeline-construct.d.ts.map +1 -0
  136. package/dist/pipeline/pipeline-construct.js +484 -0
  137. package/dist/pipeline/pipeline.test.d.ts +2 -0
  138. package/dist/pipeline/pipeline.test.d.ts.map +1 -0
  139. package/dist/pipeline/pipeline.test.js +1231 -0
  140. package/dist/pipeline/types.d.ts +351 -0
  141. package/dist/pipeline/types.d.ts.map +1 -0
  142. package/dist/pipeline/types.js +3 -0
  143. package/dist/raw-route.cdk.d.ts +47 -0
  144. package/dist/raw-route.cdk.d.ts.map +1 -0
  145. package/dist/raw-route.cdk.js +51 -0
  146. package/dist/raw-route.d.ts +119 -0
  147. package/dist/raw-route.d.ts.map +1 -0
  148. package/dist/raw-route.js +239 -0
  149. package/dist/raw-route.mock.d.ts +47 -0
  150. package/dist/raw-route.mock.d.ts.map +1 -0
  151. package/dist/raw-route.mock.js +51 -0
  152. package/dist/raw-route.test.d.ts +2 -0
  153. package/dist/raw-route.test.d.ts.map +1 -0
  154. package/dist/raw-route.test.js +597 -0
  155. package/dist/redact.d.ts +51 -0
  156. package/dist/redact.d.ts.map +1 -0
  157. package/dist/redact.js +134 -0
  158. package/dist/redact.test.d.ts +2 -0
  159. package/dist/redact.test.d.ts.map +1 -0
  160. package/dist/redact.test.js +199 -0
  161. package/dist/rpc.d.ts +54 -0
  162. package/dist/rpc.d.ts.map +1 -0
  163. package/dist/rpc.js +127 -0
  164. package/dist/rpc.test.d.ts +2 -0
  165. package/dist/rpc.test.d.ts.map +1 -0
  166. package/dist/rpc.test.js +60 -0
  167. package/dist/scripts/cdk-telemetry-env.d.ts +13 -0
  168. package/dist/scripts/cdk-telemetry-env.d.ts.map +1 -0
  169. package/dist/scripts/cdk-telemetry-env.js +21 -0
  170. package/dist/scripts/cdk-telemetry-env.test.d.ts +2 -0
  171. package/dist/scripts/cdk-telemetry-env.test.d.ts.map +1 -0
  172. package/dist/scripts/cdk-telemetry-env.test.js +56 -0
  173. package/dist/scripts/cleanup.d.ts +3 -0
  174. package/dist/scripts/cleanup.d.ts.map +1 -0
  175. package/dist/scripts/cleanup.js +35 -0
  176. package/dist/scripts/console.d.ts +6 -0
  177. package/dist/scripts/console.d.ts.map +1 -0
  178. package/dist/scripts/console.js +25 -0
  179. package/dist/scripts/deploy.d.ts +6 -0
  180. package/dist/scripts/deploy.d.ts.map +1 -0
  181. package/dist/scripts/deploy.js +82 -0
  182. package/dist/scripts/destroy.d.ts +6 -0
  183. package/dist/scripts/destroy.d.ts.map +1 -0
  184. package/dist/scripts/destroy.js +30 -0
  185. package/dist/scripts/dev-server-cors.test.d.ts +2 -0
  186. package/dist/scripts/dev-server-cors.test.d.ts.map +1 -0
  187. package/dist/scripts/dev-server-cors.test.js +23 -0
  188. package/dist/scripts/dev-server-telemetry.test.d.ts +2 -0
  189. package/dist/scripts/dev-server-telemetry.test.d.ts.map +1 -0
  190. package/dist/scripts/dev-server-telemetry.test.js +113 -0
  191. package/dist/scripts/dev-server.d.ts +21 -0
  192. package/dist/scripts/dev-server.d.ts.map +1 -0
  193. package/dist/scripts/dev-server.js +456 -0
  194. package/dist/scripts/ensure-secrets.d.ts +31 -0
  195. package/dist/scripts/ensure-secrets.d.ts.map +1 -0
  196. package/dist/scripts/ensure-secrets.js +100 -0
  197. package/dist/scripts/ensure-secrets.test.d.ts +2 -0
  198. package/dist/scripts/ensure-secrets.test.d.ts.map +1 -0
  199. package/dist/scripts/ensure-secrets.test.js +44 -0
  200. package/dist/scripts/external-migrations-step.d.ts +62 -0
  201. package/dist/scripts/external-migrations-step.d.ts.map +1 -0
  202. package/dist/scripts/external-migrations-step.js +243 -0
  203. package/dist/scripts/external-migrations-step.test.d.ts +2 -0
  204. package/dist/scripts/external-migrations-step.test.d.ts.map +1 -0
  205. package/dist/scripts/external-migrations-step.test.js +47 -0
  206. package/dist/scripts/extract-ts-types.d.ts +65 -0
  207. package/dist/scripts/extract-ts-types.d.ts.map +1 -0
  208. package/dist/scripts/extract-ts-types.js +664 -0
  209. package/dist/scripts/extract-ts-types.test.d.ts +2 -0
  210. package/dist/scripts/extract-ts-types.test.d.ts.map +1 -0
  211. package/dist/scripts/extract-ts-types.test.js +497 -0
  212. package/dist/scripts/generate-client-worker.d.ts +2 -0
  213. package/dist/scripts/generate-client-worker.d.ts.map +1 -0
  214. package/dist/scripts/generate-client-worker.js +13 -0
  215. package/dist/scripts/generate-client.d.ts +14 -0
  216. package/dist/scripts/generate-client.d.ts.map +1 -0
  217. package/dist/scripts/generate-client.js +73 -0
  218. package/dist/scripts/generate-client.test.d.ts +2 -0
  219. package/dist/scripts/generate-client.test.d.ts.map +1 -0
  220. package/dist/scripts/generate-client.test.js +48 -0
  221. package/dist/scripts/generate-spec-cli.d.ts +3 -0
  222. package/dist/scripts/generate-spec-cli.d.ts.map +1 -0
  223. package/dist/scripts/generate-spec-cli.js +77 -0
  224. package/dist/scripts/generate-spec-cli.test.d.ts +14 -0
  225. package/dist/scripts/generate-spec-cli.test.d.ts.map +1 -0
  226. package/dist/scripts/generate-spec-cli.test.js +90 -0
  227. package/dist/scripts/generate-spec.d.ts +61 -0
  228. package/dist/scripts/generate-spec.d.ts.map +1 -0
  229. package/dist/scripts/generate-spec.js +746 -0
  230. package/dist/scripts/generate-spec.test.d.ts +12 -0
  231. package/dist/scripts/generate-spec.test.d.ts.map +1 -0
  232. package/dist/scripts/generate-spec.test.js +88 -0
  233. package/dist/scripts/index.d.ts +12 -0
  234. package/dist/scripts/index.d.ts.map +1 -0
  235. package/dist/scripts/index.js +13 -0
  236. package/dist/scripts/sandbox.d.ts +11 -0
  237. package/dist/scripts/sandbox.d.ts.map +1 -0
  238. package/dist/scripts/sandbox.js +200 -0
  239. package/dist/scripts/telemetry-cli.d.ts +3 -0
  240. package/dist/scripts/telemetry-cli.d.ts.map +1 -0
  241. package/dist/scripts/telemetry-cli.js +8 -0
  242. package/dist/scripts/telemetry.d.ts +17 -0
  243. package/dist/scripts/telemetry.d.ts.map +1 -0
  244. package/dist/scripts/telemetry.js +150 -0
  245. package/dist/scripts/telemetry.test.d.ts +2 -0
  246. package/dist/scripts/telemetry.test.d.ts.map +1 -0
  247. package/dist/scripts/telemetry.test.js +203 -0
  248. package/dist/scripts/validate-spec.d.ts +22 -0
  249. package/dist/scripts/validate-spec.d.ts.map +1 -0
  250. package/dist/scripts/validate-spec.js +159 -0
  251. package/dist/server/index.d.ts +3 -0
  252. package/dist/server/index.d.ts.map +1 -0
  253. package/dist/server/index.js +3 -0
  254. package/dist/server/withAuth.d.ts +142 -0
  255. package/dist/server/withAuth.d.ts.map +1 -0
  256. package/dist/server/withAuth.js +261 -0
  257. package/dist/server/withAuth.test.d.ts +2 -0
  258. package/dist/server/withAuth.test.d.ts.map +1 -0
  259. package/dist/server/withAuth.test.js +278 -0
  260. package/dist/telemetry/client.d.ts +59 -0
  261. package/dist/telemetry/client.d.ts.map +1 -0
  262. package/dist/telemetry/client.js +215 -0
  263. package/dist/telemetry/config-writer.d.ts +16 -0
  264. package/dist/telemetry/config-writer.d.ts.map +1 -0
  265. package/dist/telemetry/config-writer.js +36 -0
  266. package/dist/telemetry/consent-commands.test.d.ts +2 -0
  267. package/dist/telemetry/consent-commands.test.d.ts.map +1 -0
  268. package/dist/telemetry/consent-commands.test.js +161 -0
  269. package/dist/telemetry/consent.d.ts +60 -0
  270. package/dist/telemetry/consent.d.ts.map +1 -0
  271. package/dist/telemetry/consent.js +122 -0
  272. package/dist/telemetry/environment.d.ts +33 -0
  273. package/dist/telemetry/environment.d.ts.map +1 -0
  274. package/dist/telemetry/environment.js +87 -0
  275. package/dist/telemetry/identifiers.d.ts +20 -0
  276. package/dist/telemetry/identifiers.d.ts.map +1 -0
  277. package/dist/telemetry/identifiers.js +86 -0
  278. package/dist/telemetry/index.d.ts +6 -0
  279. package/dist/telemetry/index.d.ts.map +1 -0
  280. package/dist/telemetry/index.js +4 -0
  281. package/dist/telemetry/telemetry.test.d.ts +2 -0
  282. package/dist/telemetry/telemetry.test.d.ts.map +1 -0
  283. package/dist/telemetry/telemetry.test.js +1131 -0
  284. package/dist/telemetry/trackCommand.d.ts +32 -0
  285. package/dist/telemetry/trackCommand.d.ts.map +1 -0
  286. package/dist/telemetry/trackCommand.js +102 -0
  287. package/dist/telemetry/types.d.ts +92 -0
  288. package/dist/telemetry/types.d.ts.map +1 -0
  289. package/dist/telemetry/types.js +1 -0
  290. package/dist/types.d.ts +24 -0
  291. package/dist/types.d.ts.map +1 -0
  292. package/dist/types.js +3 -0
  293. package/dist/version.d.ts +2 -0
  294. package/dist/version.d.ts.map +1 -0
  295. package/dist/version.js +2 -0
  296. package/package.json +83 -0
  297. package/src/api.test.ts +25 -0
  298. package/src/api.ts +163 -0
  299. package/src/bb-utils.ts +13 -0
  300. package/src/builtin-routes.ts +62 -0
  301. package/src/cdk/__fixtures__/factory-backend.ts +8 -0
  302. package/src/cdk/__fixtures__/fullid-construct-backend.ts +42 -0
  303. package/src/cdk/__fixtures__/handler.ts +4 -0
  304. package/src/cdk/__fixtures__/side-effect-backend.ts +7 -0
  305. package/src/cdk/blocks-backend.test.ts +207 -0
  306. package/src/cdk/blocks-backend.ts +249 -0
  307. package/src/cdk/blocks-stack.test.ts +106 -0
  308. package/src/cdk/config-registry.ts +106 -0
  309. package/src/cdk/index.ts +115 -0
  310. package/src/cdk/mixins.test.ts +129 -0
  311. package/src/cdk/mixins.ts +41 -0
  312. package/src/cdk/node-version.ts +17 -0
  313. package/src/cdk/stack-metadata.test.ts +71 -0
  314. package/src/cdk/stack-metadata.ts +29 -0
  315. package/src/cdk/synth-guard.test.ts +21 -0
  316. package/src/cdk/synth-guard.ts +23 -0
  317. package/src/client/index.test.ts +118 -0
  318. package/src/client/index.ts +320 -0
  319. package/src/common/config.test.ts +236 -0
  320. package/src/common/config.ts +169 -0
  321. package/src/common/constants.ts +15 -0
  322. package/src/common/crypto.ts +15 -0
  323. package/src/common/index.test.ts +242 -0
  324. package/src/common/index.ts +335 -0
  325. package/src/common/mock-data.ts +52 -0
  326. package/src/common/official-bb-names.generated.ts +29 -0
  327. package/src/common/sdk-registry.ts +150 -0
  328. package/src/constants.ts +31 -0
  329. package/src/cors.test.ts +279 -0
  330. package/src/cors.ts +91 -0
  331. package/src/db-naming.test.ts +48 -0
  332. package/src/db-naming.ts +41 -0
  333. package/src/errors.ts +72 -0
  334. package/src/hosting.test.ts +1431 -0
  335. package/src/hosting.ts +626 -0
  336. package/src/index.cdk.ts +45 -0
  337. package/src/index.ts +22 -0
  338. package/src/lambda-handler.test.ts +1005 -0
  339. package/src/lambda-handler.ts +706 -0
  340. package/src/pipeline/index.ts +12 -0
  341. package/src/pipeline/pipeline-construct.ts +662 -0
  342. package/src/pipeline/pipeline.test.ts +1565 -0
  343. package/src/pipeline/types.ts +384 -0
  344. package/src/raw-route.cdk.ts +56 -0
  345. package/src/raw-route.mock.ts +56 -0
  346. package/src/raw-route.test.ts +819 -0
  347. package/src/raw-route.ts +316 -0
  348. package/src/redact.test.ts +222 -0
  349. package/src/redact.ts +143 -0
  350. package/src/rpc.test.ts +73 -0
  351. package/src/rpc.ts +174 -0
  352. package/src/scripts/cdk-telemetry-env.test.ts +75 -0
  353. package/src/scripts/cdk-telemetry-env.ts +23 -0
  354. package/src/scripts/cleanup.ts +39 -0
  355. package/src/scripts/console.ts +34 -0
  356. package/src/scripts/deploy.ts +106 -0
  357. package/src/scripts/destroy.ts +42 -0
  358. package/src/scripts/dev-server-cors.test.ts +28 -0
  359. package/src/scripts/dev-server-telemetry.test.ts +127 -0
  360. package/src/scripts/dev-server.ts +515 -0
  361. package/src/scripts/ensure-secrets.test.ts +50 -0
  362. package/src/scripts/ensure-secrets.ts +113 -0
  363. package/src/scripts/external-migrations-step.test.ts +64 -0
  364. package/src/scripts/external-migrations-step.ts +269 -0
  365. package/src/scripts/extract-ts-types.test.ts +516 -0
  366. package/src/scripts/extract-ts-types.ts +773 -0
  367. package/src/scripts/generate-client-worker.ts +15 -0
  368. package/src/scripts/generate-client.test.ts +69 -0
  369. package/src/scripts/generate-client.ts +86 -0
  370. package/src/scripts/generate-spec-cli.test.ts +106 -0
  371. package/src/scripts/generate-spec-cli.ts +92 -0
  372. package/src/scripts/generate-spec.test.ts +99 -0
  373. package/src/scripts/generate-spec.ts +833 -0
  374. package/src/scripts/index.ts +21 -0
  375. package/src/scripts/sandbox.ts +230 -0
  376. package/src/scripts/telemetry-cli.ts +10 -0
  377. package/src/scripts/telemetry.test.ts +239 -0
  378. package/src/scripts/telemetry.ts +173 -0
  379. package/src/scripts/validate-spec.ts +199 -0
  380. package/src/server/index.ts +5 -0
  381. package/src/server/withAuth.test.ts +348 -0
  382. package/src/server/withAuth.ts +323 -0
  383. package/src/telemetry/client.ts +226 -0
  384. package/src/telemetry/config-writer.ts +52 -0
  385. package/src/telemetry/consent-commands.test.ts +196 -0
  386. package/src/telemetry/consent.ts +154 -0
  387. package/src/telemetry/environment.ts +100 -0
  388. package/src/telemetry/identifiers.ts +94 -0
  389. package/src/telemetry/index.ts +11 -0
  390. package/src/telemetry/telemetry.test.ts +1343 -0
  391. package/src/telemetry/trackCommand.ts +110 -0
  392. package/src/telemetry/types.ts +92 -0
  393. package/src/types.ts +33 -0
  394. package/src/version.ts +2 -0
@@ -0,0 +1,1431 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, it, beforeEach, afterEach } from 'node:test';
5
+ import assert from 'node:assert';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+ import * as cdk from 'aws-cdk-lib';
10
+ import { App, Duration, Stack } from 'aws-cdk-lib';
11
+ import { Template, Match } from 'aws-cdk-lib/assertions';
12
+ import { BLOCKS_RPC_PREFIX } from './constants.js';
13
+ import { Hosting, type BlocksStackApi } from './hosting.js';
14
+ import { clearRouteRegistry, registerRoute } from './raw-route.js';
15
+
16
+ // ================================================================
17
+ // Hosting construct tests
18
+ //
19
+ // These validate that the Blocks wrapper around HostingConstruct
20
+ // produces the expected CloudFormation resources for SPA and SSR.
21
+ // ================================================================
22
+
23
+ const MOCK_API: BlocksStackApi = {
24
+ apiUrl: 'https://abc123.execute-api.us-east-1.amazonaws.com/prod/aws-blocks',
25
+ };
26
+
27
+ /** Helper: create a minimal SPA build output (dist/ with index.html). */
28
+ function createSpaBuildOutput(root: string): void {
29
+ const distDir = path.join(root, 'dist');
30
+ fs.mkdirSync(distDir, { recursive: true });
31
+ fs.writeFileSync(path.join(distDir, 'index.html'), '<!DOCTYPE html><html><body>Hello</body></html>');
32
+ fs.writeFileSync(path.join(distDir, 'main.js'), 'console.log("app")');
33
+ // package.json without next → detected as SPA
34
+ fs.writeFileSync(
35
+ path.join(root, 'package.json'),
36
+ JSON.stringify({ name: 'test-spa', dependencies: { react: '^18' } }),
37
+ );
38
+ }
39
+
40
+ /** Helper: create a minimal Next.js standalone build output. */
41
+ function createNextjsBuildOutput(root: string): void {
42
+ // package.json with next dep → detected as nextjs
43
+ fs.writeFileSync(
44
+ path.join(root, 'package.json'),
45
+ JSON.stringify({ name: 'test-nextjs', dependencies: { next: '15.0.0', react: '^18' } }),
46
+ );
47
+
48
+ // Create .next dir so build output validation passes
49
+ const nextDir = path.join(root, '.next');
50
+ fs.mkdirSync(nextDir, { recursive: true });
51
+ fs.writeFileSync(path.join(nextDir, 'BUILD_ID'), 'test-build-id');
52
+
53
+ // Create compute bundle directory with a handler
54
+ const computeDir = path.join(root, '.hosting', 'compute', 'default');
55
+ fs.mkdirSync(computeDir, { recursive: true });
56
+ fs.writeFileSync(path.join(computeDir, 'index.mjs'), 'export const handler = async () => ({})');
57
+ // run.sh for Lambda Web Adapter
58
+ fs.writeFileSync(path.join(computeDir, 'run.sh'), '#!/bin/bash\nnode index.mjs');
59
+
60
+ // Create static assets directory
61
+ const staticDir = path.join(root, '.hosting', 'static');
62
+ fs.mkdirSync(staticDir, { recursive: true });
63
+ fs.writeFileSync(path.join(staticDir, 'index.html'), '<!DOCTYPE html><html><body>SSR</body></html>');
64
+ }
65
+
66
+ /**
67
+ * Creates a fixture DeployManifest that mimics what the OpenNext adapter
68
+ * would produce for a Next.js app. Used with `customAdapter` in unit tests
69
+ * to avoid needing a real Next.js build + OpenNext binary.
70
+ */
71
+ function createNextjsFixtureAdapter(root: string) {
72
+ return (_projectDir: string) => ({
73
+ version: 1 as const,
74
+ compute: {
75
+ default: {
76
+ type: 'handler' as const,
77
+ bundle: path.join(root, '.hosting', 'compute', 'default'),
78
+ handler: 'index.handler',
79
+ placement: 'regional' as const,
80
+ streaming: true,
81
+ },
82
+ },
83
+ staticAssets: {
84
+ directory: path.join(root, '.hosting', 'static'),
85
+ },
86
+ routes: [
87
+ { pattern: '/_next/static/*', target: 'static' },
88
+ { pattern: '/*', target: 'default' },
89
+ ],
90
+ });
91
+ }
92
+
93
+ // ----------------------------------------------------------------
94
+
95
+ describe('Hosting', () => {
96
+ let tmpDir: string;
97
+
98
+ beforeEach(() => {
99
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bb-hosting-test-'));
100
+ });
101
+
102
+ afterEach(() => {
103
+ fs.rmSync(tmpDir, { recursive: true, force: true });
104
+ clearRouteRegistry();
105
+ });
106
+
107
+ // ── SPA tests ────────────────────────────────────────────────
108
+
109
+ describe('SPA mode', () => {
110
+ it('creates S3 bucket and CloudFront distribution', () => {
111
+ createSpaBuildOutput(tmpDir);
112
+
113
+ const app = new App();
114
+ const stack = new Stack(app, 'TestSpaStack');
115
+
116
+ new Hosting(stack, 'Hosting', {
117
+ root: tmpDir,
118
+ api: MOCK_API,
119
+ });
120
+
121
+ const template = Template.fromStack(stack);
122
+
123
+ // CloudFront distribution
124
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
125
+
126
+ // At least one S3 bucket
127
+ const buckets = template.findResources('AWS::S3::Bucket');
128
+ assert.ok(Object.keys(buckets).length >= 1, 'Should have at least one S3 bucket');
129
+
130
+ // No Lambda function (SPA = static only)
131
+ template.resourceCountIs('AWS::Lambda::Url', 0);
132
+ });
133
+
134
+ it('auto-detects SPA framework from package.json without "next"', () => {
135
+ createSpaBuildOutput(tmpDir);
136
+
137
+ const app = new App();
138
+ const stack = new Stack(app, 'AutoDetectSpaStack');
139
+
140
+ const hosting = new Hosting(stack, 'Hosting', {
141
+ root: tmpDir,
142
+ api: MOCK_API,
143
+ });
144
+
145
+ // Should produce a URL
146
+ assert.ok(hosting.url.startsWith('https://'), 'URL should start with https://');
147
+ // No SSR function
148
+ assert.strictEqual(hosting.ssrFunction, undefined, 'SPA should not have ssrFunction');
149
+ });
150
+
151
+ it('writes placeholder to static assets and deploys real config via BucketDeployment', () => {
152
+ createSpaBuildOutput(tmpDir);
153
+
154
+ const app = new App();
155
+ const stack = new Stack(app, 'ConfigStack');
156
+
157
+ new Hosting(stack, 'Hosting', {
158
+ root: tmpDir,
159
+ api: MOCK_API,
160
+ backendConfig: { pluginA: 'valueA' },
161
+ });
162
+
163
+ // The static file should be a placeholder (not the real config) since CDK tokens
164
+ // are only resolved at deploy time, not synth time.
165
+ const configPath = path.join(tmpDir, '.hosting', 'static', '.blocks-sandbox', 'config.json');
166
+ assert.ok(fs.existsSync(configPath), 'placeholder config.json should exist in static assets');
167
+
168
+ const placeholder = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
169
+ assert.strictEqual(placeholder._placeholder, true, 'static file should be a placeholder');
170
+ assert.strictEqual(placeholder.apiUrl, undefined, 'placeholder should NOT contain apiUrl (CDK token)');
171
+
172
+ // The real config is deployed via BucketDeployment (Custom Resource)
173
+ // which resolves CDK tokens at deploy time via CloudFormation.
174
+ const template = Template.fromStack(stack);
175
+
176
+ // BucketDeployment creates a Custom::CDKBucketDeployment resource.
177
+ // Verify the destination key prefix ends with .blocks-sandbox.
178
+ const customResources = template.findResources('Custom::CDKBucketDeployment');
179
+ const crKeys = Object.keys(customResources);
180
+ assert.ok(crKeys.length >= 1, 'Should have at least one BucketDeployment custom resource');
181
+
182
+ const hasBlocksSandboxPrefix = crKeys.some((key) => {
183
+ const props = (customResources[key] as Record<string, unknown>).Properties as Record<string, unknown> | undefined;
184
+ const prefix = props?.DestinationBucketKeyPrefix as string | undefined;
185
+ return typeof prefix === 'string' && prefix.endsWith('.blocks-sandbox');
186
+ });
187
+ assert.ok(hasBlocksSandboxPrefix, 'BucketDeployment should target .blocks-sandbox prefix');
188
+ });
189
+
190
+ it('throws when build output directory does not exist', () => {
191
+ // No build output, no buildCommand
192
+ fs.writeFileSync(
193
+ path.join(tmpDir, 'package.json'),
194
+ JSON.stringify({ name: 'test', dependencies: { react: '^18' } }),
195
+ );
196
+
197
+ const app = new App();
198
+ const stack = new Stack(app, 'MissingDistStack');
199
+
200
+ assert.throws(
201
+ () => {
202
+ new Hosting(stack, 'Hosting', {
203
+ root: tmpDir,
204
+ api: MOCK_API,
205
+ });
206
+ },
207
+ (err: Error) => {
208
+ assert.ok(err.message.includes('Build output directory not found'), `Expected build dir error, got: ${err.message}`);
209
+ return true;
210
+ },
211
+ );
212
+ });
213
+
214
+ it('exposes bucket and distribution resources for composition', () => {
215
+ createSpaBuildOutput(tmpDir);
216
+
217
+ const app = new App();
218
+ const stack = new Stack(app, 'ComposeStack');
219
+
220
+ const hosting = new Hosting(stack, 'Hosting', {
221
+ root: tmpDir,
222
+ api: MOCK_API,
223
+ });
224
+
225
+ assert.ok(hosting.bucket, 'Should expose bucket');
226
+ assert.ok(hosting.bucket.bucketName, 'Bucket should have a name');
227
+ assert.ok(hosting.distribution, 'Should expose distribution');
228
+ assert.ok(hosting.distribution.distributionId, 'Distribution should have an ID');
229
+ });
230
+
231
+ it('creates CfnOutput for the hosting URL', () => {
232
+ createSpaBuildOutput(tmpDir);
233
+
234
+ const app = new App();
235
+ const stack = new Stack(app, 'OutputStack');
236
+
237
+ new Hosting(stack, 'Hosting', {
238
+ root: tmpDir,
239
+ api: MOCK_API,
240
+ });
241
+
242
+ const template = Template.fromStack(stack);
243
+ const outputs = template.findOutputs('*');
244
+ const outputKeys = Object.keys(outputs);
245
+ const hostingOutput = outputKeys.find((k) => k.includes('HostingUrl'));
246
+ assert.ok(hostingOutput, 'Should create a HostingUrl CfnOutput');
247
+ });
248
+ });
249
+
250
+ // ── Next.js SSR tests ────────────────────────────────────────
251
+
252
+ describe('Next.js SSR mode', () => {
253
+ it('creates Lambda function fronted by API Gateway REST for SSR compute', () => {
254
+ createNextjsBuildOutput(tmpDir);
255
+
256
+ const app = new App();
257
+ const stack = new Stack(app, 'TestSsrStack');
258
+
259
+ new Hosting(stack, 'Hosting', {
260
+ root: tmpDir,
261
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
262
+ api: MOCK_API,
263
+ });
264
+
265
+ const template = Template.fromStack(stack);
266
+
267
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
268
+ template.hasResourceProperties('AWS::Lambda::Function', {
269
+ Handler: 'index.handler',
270
+ });
271
+ template.resourceCountIs('AWS::ApiGateway::RestApi', 1);
272
+ });
273
+
274
+ it('exposes ssrFunction', () => {
275
+ createNextjsBuildOutput(tmpDir);
276
+
277
+ const app = new App();
278
+ const stack = new Stack(app, 'SsrFnStack');
279
+
280
+ const hosting = new Hosting(stack, 'Hosting', {
281
+ root: tmpDir,
282
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
283
+ api: MOCK_API,
284
+ });
285
+
286
+ assert.ok(hosting.ssrFunction, 'Should expose ssrFunction for Next.js');
287
+ });
288
+
289
+ it('injects full API Gateway URL as BLOCKS_API_URL env var into SSR Lambda when api is provided', () => {
290
+ createNextjsBuildOutput(tmpDir);
291
+
292
+ const app = new App();
293
+ const stack = new Stack(app, 'EnvVarStack');
294
+
295
+ new Hosting(stack, 'Hosting', {
296
+ root: tmpDir,
297
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
298
+ api: MOCK_API,
299
+ });
300
+
301
+ const template = Template.fromStack(stack);
302
+
303
+ template.hasResourceProperties('AWS::Lambda::Function', {
304
+ Environment: Match.objectLike({
305
+ Variables: Match.objectLike({
306
+ BLOCKS_API_URL: MOCK_API.apiUrl,
307
+ }),
308
+ }),
309
+ });
310
+ });
311
+
312
+ it('injects BLOCKS_CONFIG env var when backendConfig is provided', () => {
313
+ createNextjsBuildOutput(tmpDir);
314
+
315
+ const app = new App();
316
+ const stack = new Stack(app, 'FullConfigStack');
317
+
318
+ const backendConfig = { pluginX: 'hello', pluginY: { nested: true } };
319
+
320
+ new Hosting(stack, 'Hosting', {
321
+ root: tmpDir,
322
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
323
+ api: MOCK_API,
324
+ backendConfig,
325
+ });
326
+
327
+ const template = Template.fromStack(stack);
328
+
329
+ template.hasResourceProperties('AWS::Lambda::Function', {
330
+ Environment: Match.objectLike({
331
+ Variables: Match.objectLike({
332
+ BLOCKS_CONFIG: JSON.stringify(backendConfig),
333
+ }),
334
+ }),
335
+ });
336
+ });
337
+
338
+ it('applies custom compute configuration', () => {
339
+ createNextjsBuildOutput(tmpDir);
340
+
341
+ const app = new App();
342
+ const stack = new Stack(app, 'ComputeConfigStack');
343
+
344
+ new Hosting(stack, 'Hosting', {
345
+ root: tmpDir,
346
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
347
+ api: MOCK_API,
348
+ compute: {
349
+ memorySize: 2048,
350
+ timeout: Duration.seconds(120),
351
+ },
352
+ });
353
+
354
+ const template = Template.fromStack(stack);
355
+
356
+ template.hasResourceProperties('AWS::Lambda::Function', {
357
+ MemorySize: 2048,
358
+ Timeout: 120,
359
+ });
360
+ });
361
+
362
+ it('auto-detects Next.js from package.json', () => {
363
+ createNextjsBuildOutput(tmpDir);
364
+
365
+ const app = new App();
366
+ const stack = new Stack(app, 'AutoDetectNextStack');
367
+
368
+ const hosting = new Hosting(stack, 'Hosting', {
369
+ root: tmpDir,
370
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
371
+ api: MOCK_API,
372
+ });
373
+
374
+ // Should detect Next.js and create SSR function
375
+ assert.ok(hosting.ssrFunction, 'Auto-detected Next.js should have ssrFunction');
376
+ });
377
+ });
378
+
379
+ // ── Infrastructure options tests ─────────────────────────────
380
+
381
+ describe('Infrastructure options', () => {
382
+ it('enables WAF when waf config is provided', () => {
383
+ createSpaBuildOutput(tmpDir);
384
+
385
+ const app = new App();
386
+ const stack = new Stack(app, 'WafStack', {
387
+ env: { account: '123456789012', region: 'us-east-1' },
388
+ });
389
+
390
+ new Hosting(stack, 'Hosting', {
391
+ root: tmpDir,
392
+ api: MOCK_API,
393
+ waf: { enabled: true, rateLimit: 500 },
394
+ });
395
+
396
+ const template = Template.fromStack(stack);
397
+ template.resourceCountIs('AWS::WAFv2::WebACL', 1);
398
+ });
399
+
400
+ it('sets custom CSP header', () => {
401
+ createSpaBuildOutput(tmpDir);
402
+
403
+ const app = new App();
404
+ const stack = new Stack(app, 'CspStack');
405
+ const customCsp = "default-src 'self'; script-src 'none'";
406
+
407
+ new Hosting(stack, 'Hosting', {
408
+ root: tmpDir,
409
+ api: MOCK_API,
410
+ contentSecurityPolicy: customCsp,
411
+ });
412
+
413
+ const template = Template.fromStack(stack);
414
+ template.hasResourceProperties(
415
+ 'AWS::CloudFront::ResponseHeadersPolicy',
416
+ Match.objectLike({
417
+ ResponseHeadersPolicyConfig: Match.objectLike({
418
+ SecurityHeadersConfig: Match.objectLike({
419
+ ContentSecurityPolicy: Match.objectLike({
420
+ ContentSecurityPolicy: customCsp,
421
+ }),
422
+ }),
423
+ }),
424
+ }),
425
+ );
426
+ });
427
+
428
+ it('retains bucket when retainOnDelete is true', () => {
429
+ createSpaBuildOutput(tmpDir);
430
+
431
+ const app = new App();
432
+ const stack = new Stack(app, 'RetainStack');
433
+
434
+ new Hosting(stack, 'Hosting', {
435
+ root: tmpDir,
436
+ api: MOCK_API,
437
+ retainOnDelete: true,
438
+ });
439
+
440
+ const template = Template.fromStack(stack);
441
+ const buckets = template.findResources('AWS::S3::Bucket');
442
+
443
+ let foundRetain = false;
444
+ for (const [, bucket] of Object.entries(buckets)) {
445
+ if ((bucket as Record<string, unknown>).DeletionPolicy === 'Retain') {
446
+ foundRetain = true;
447
+ }
448
+ }
449
+ assert.ok(foundRetain, 'At least one bucket should have Retain deletion policy');
450
+ });
451
+ });
452
+
453
+ // ── API integration tests ────────────────────────────────────
454
+
455
+ describe('API integration', () => {
456
+ it(`creates ${BLOCKS_RPC_PREFIX} and ${BLOCKS_RPC_PREFIX}/* CloudFront behaviors when api prop is provided`, () => {
457
+ createSpaBuildOutput(tmpDir);
458
+
459
+ const app = new App();
460
+ const stack = new Stack(app, 'ApiBehaviorStack');
461
+
462
+ new Hosting(stack, 'Hosting', {
463
+ root: tmpDir,
464
+ api: MOCK_API,
465
+ });
466
+
467
+ const template = Template.fromStack(stack);
468
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
469
+ const distKeys = Object.keys(distributions);
470
+ assert.ok(distKeys.length >= 1, 'Should have at least one distribution');
471
+
472
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
473
+ const cacheBehaviors = distConfig.CacheBehaviors ?? [];
474
+
475
+ const apiExactPattern = cacheBehaviors.find((b: any) => b.PathPattern === BLOCKS_RPC_PREFIX);
476
+ assert.ok(apiExactPattern, `Should have ${BLOCKS_RPC_PREFIX} exact cache behavior`);
477
+ assert.strictEqual(apiExactPattern.ViewerProtocolPolicy, 'redirect-to-https');
478
+
479
+ const apiWildcardPattern = cacheBehaviors.find((b: any) => b.PathPattern === `${BLOCKS_RPC_PREFIX}/*`);
480
+ assert.ok(apiWildcardPattern, `Should have ${BLOCKS_RPC_PREFIX}/* cache behavior`);
481
+ assert.strictEqual(apiWildcardPattern.ViewerProtocolPolicy, 'redirect-to-https');
482
+
483
+ // AllowedMethods should include all methods for API proxy
484
+ assert.deepStrictEqual(
485
+ apiExactPattern.AllowedMethods.sort(),
486
+ ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'],
487
+ 'API exact behavior should allow all HTTP methods',
488
+ );
489
+ assert.deepStrictEqual(
490
+ apiWildcardPattern.AllowedMethods.sort(),
491
+ ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'],
492
+ 'API wildcard behavior should allow all HTTP methods',
493
+ );
494
+ });
495
+
496
+ it(`does not create ${BLOCKS_RPC_PREFIX}/* behavior when api prop is omitted`, () => {
497
+ createSpaBuildOutput(tmpDir);
498
+
499
+ const app = new App();
500
+ const stack = new Stack(app, 'NoApiBehaviorStack');
501
+
502
+ new Hosting(stack, 'Hosting', {
503
+ root: tmpDir,
504
+ });
505
+
506
+ const template = Template.fromStack(stack);
507
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
508
+ const distKeys = Object.keys(distributions);
509
+ assert.ok(distKeys.length >= 1, 'Should have at least one distribution');
510
+
511
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
512
+ const cacheBehaviors = distConfig.CacheBehaviors ?? [];
513
+
514
+ const apiPattern = cacheBehaviors.find((b: any) => b.PathPattern === `${BLOCKS_RPC_PREFIX}/*`);
515
+ assert.strictEqual(apiPattern, undefined, `Should NOT have ${BLOCKS_RPC_PREFIX}/* behavior when api is omitted`);
516
+ });
517
+
518
+ // Regression: /api/* is the framework SSR namespace; Blocks must
519
+ // not synthesize a CloudFront behavior there.
520
+ it('does NOT claim the framework-reserved /api or /api/* prefix', () => {
521
+ createSpaBuildOutput(tmpDir);
522
+
523
+ const app = new App();
524
+ const stack = new Stack(app, 'NoApiPrefixStack');
525
+
526
+ new Hosting(stack, 'Hosting', {
527
+ root: tmpDir,
528
+ api: MOCK_API,
529
+ });
530
+
531
+ const template = Template.fromStack(stack);
532
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
533
+ const distConfig = (distributions[Object.keys(distributions)[0]] as any).Properties.DistributionConfig;
534
+ const cacheBehaviors = distConfig.CacheBehaviors ?? [];
535
+
536
+ const apiExact = cacheBehaviors.find((b: any) => b.PathPattern === '/api');
537
+ const apiWildcard = cacheBehaviors.find((b: any) => b.PathPattern === '/api/*');
538
+
539
+ assert.strictEqual(
540
+ apiExact,
541
+ undefined,
542
+ '/api must not be claimed by Blocks (framework SSR API namespace)',
543
+ );
544
+ assert.strictEqual(
545
+ apiWildcard,
546
+ undefined,
547
+ '/api/* must not be claimed by Blocks (framework SSR API namespace)',
548
+ );
549
+ });
550
+
551
+ it('works without api prop (static-only site)', () => {
552
+ createSpaBuildOutput(tmpDir);
553
+
554
+ const app = new App();
555
+ const stack = new Stack(app, 'StaticOnlyStack');
556
+
557
+ const hosting = new Hosting(stack, 'Hosting', {
558
+ root: tmpDir,
559
+ });
560
+
561
+ assert.ok(hosting.url.startsWith('https://'), 'URL should start with https://');
562
+ assert.ok(hosting.bucket, 'Should still expose bucket');
563
+ assert.ok(hosting.distribution, 'Should still expose distribution');
564
+
565
+ const template = Template.fromStack(stack);
566
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
567
+ });
568
+
569
+ it(`config JSON contains relative ${BLOCKS_RPC_PREFIX} URL when api is provided`, () => {
570
+ createSpaBuildOutput(tmpDir);
571
+
572
+ const app = new App();
573
+ const stack = new Stack(app, 'ConfigRelativeUrlStack');
574
+
575
+ new Hosting(stack, 'Hosting', {
576
+ root: tmpDir,
577
+ api: MOCK_API,
578
+ backendConfig: { region: 'us-east-1' },
579
+ });
580
+
581
+ const template = Template.fromStack(stack);
582
+ const customResources = template.findResources('Custom::CDKBucketDeployment');
583
+ const crKeys = Object.keys(customResources);
584
+ assert.ok(crKeys.length >= 1, 'Should have at least one BucketDeployment');
585
+
586
+ // The BucketDeployment source includes an S3 object with the JSON.
587
+ // We can't easily inspect the content directly, but we can verify the
588
+ // resource exists. The buildConfigJson() method is tested implicitly
589
+ // through the SSR Lambda env var test.
590
+ });
591
+
592
+ it('does not set apiUrl in config JSON when api is omitted', () => {
593
+ createSpaBuildOutput(tmpDir);
594
+
595
+ const app = new App();
596
+ const stack = new Stack(app, 'ConfigNoApiStack');
597
+
598
+ new Hosting(stack, 'Hosting', {
599
+ root: tmpDir,
600
+ backendConfig: { region: 'us-east-1' },
601
+ });
602
+
603
+ // Should still deploy config (with backendConfig), just without apiUrl
604
+ const template = Template.fromStack(stack);
605
+ const customResources = template.findResources('Custom::CDKBucketDeployment');
606
+ assert.ok(
607
+ Object.keys(customResources).length >= 1,
608
+ 'Should have BucketDeployment even without api',
609
+ );
610
+ });
611
+
612
+ it('does not inject BLOCKS_API_URL into SSR Lambda when api is omitted', () => {
613
+ createNextjsBuildOutput(tmpDir);
614
+
615
+ const app = new App();
616
+ const stack = new Stack(app, 'SsrNoApiStack');
617
+
618
+ new Hosting(stack, 'Hosting', {
619
+ root: tmpDir,
620
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
621
+ });
622
+
623
+ const template = Template.fromStack(stack);
624
+
625
+ // Lambda should exist (SSR) but should NOT have BLOCKS_API_URL
626
+ template.hasResourceProperties('AWS::Lambda::Function', {
627
+ Handler: 'index.handler',
628
+ });
629
+
630
+ // Verify BLOCKS_API_URL is absent
631
+ const lambdaFns = template.findResources('AWS::Lambda::Function');
632
+ for (const [, fn] of Object.entries(lambdaFns)) {
633
+ const env = (fn as any).Properties?.Environment?.Variables;
634
+ if (env) {
635
+ assert.strictEqual(
636
+ env.BLOCKS_API_URL,
637
+ undefined,
638
+ 'BLOCKS_API_URL should not be set when api is omitted',
639
+ );
640
+ }
641
+ }
642
+ });
643
+
644
+ it(`adds CloudFront behaviors for RawRoute paths not under ${BLOCKS_RPC_PREFIX}/`, () => {
645
+ createSpaBuildOutput(tmpDir);
646
+
647
+ registerRoute({
648
+ method: 'GET',
649
+ path: '/health',
650
+ handler: async () => {},
651
+ });
652
+ registerRoute({
653
+ method: 'GET',
654
+ path: '/users/{id}',
655
+ handler: async () => {},
656
+ });
657
+
658
+ const app = new App();
659
+ const stack = new Stack(app, 'RawRouteBehaviorStack');
660
+
661
+ new Hosting(stack, 'Hosting', {
662
+ root: tmpDir,
663
+ api: MOCK_API,
664
+ });
665
+
666
+ const template = Template.fromStack(stack);
667
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
668
+ const distKeys = Object.keys(distributions);
669
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
670
+ const cacheBehaviors = distConfig.CacheBehaviors ?? [];
671
+ const patterns = cacheBehaviors.map((b: any) => b.PathPattern);
672
+
673
+ assert.ok(patterns.includes('/aws-blocks/api/*'), 'Should have /aws-blocks/api/* behavior');
674
+ assert.ok(patterns.includes('/health'), 'Should have /health behavior for RawRoute');
675
+ assert.ok(patterns.includes('/users/*'), 'Should have /users/* behavior for parameterized RawRoute');
676
+ });
677
+
678
+ it('proxies the reserved /aws-blocks/auth subtree with a single behavior', () => {
679
+ createSpaBuildOutput(tmpDir);
680
+
681
+ // A route under the auth subtree (as the auth runtime would mount) must
682
+ // NOT get its own behavior — the subtree wildcard covers it.
683
+ registerRoute({
684
+ method: 'GET',
685
+ path: '/aws-blocks/auth/callback',
686
+ handler: async () => {},
687
+ });
688
+
689
+ const app = new App();
690
+ const stack = new Stack(app, 'AuthBehaviorStack');
691
+
692
+ new Hosting(stack, 'Hosting', {
693
+ root: tmpDir,
694
+ api: MOCK_API,
695
+ });
696
+
697
+ const template = Template.fromStack(stack);
698
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
699
+ const distConfig = (distributions[Object.keys(distributions)[0]] as any).Properties.DistributionConfig;
700
+ const cacheBehaviors = distConfig.CacheBehaviors ?? [];
701
+ const patterns = cacheBehaviors.map((b: any) => b.PathPattern);
702
+
703
+ assert.ok(patterns.includes('/aws-blocks/auth/*'), 'Should have /aws-blocks/auth/* behavior');
704
+ assert.ok(
705
+ !patterns.includes('/aws-blocks/auth/callback'),
706
+ 'A route under the auth subtree should be covered by the wildcard, not get its own behavior',
707
+ );
708
+ });
709
+
710
+ it('deduplicates CloudFront behaviors for same path prefix', () => {
711
+ createSpaBuildOutput(tmpDir);
712
+
713
+ registerRoute({
714
+ method: 'GET',
715
+ path: '/webhooks/{id}',
716
+ handler: async () => {},
717
+ });
718
+ registerRoute({
719
+ method: 'POST',
720
+ path: '/webhooks/{id}',
721
+ handler: async () => {},
722
+ });
723
+
724
+ const app = new App();
725
+ const stack = new Stack(app, 'DedupBehaviorStack');
726
+
727
+ new Hosting(stack, 'Hosting', {
728
+ root: tmpDir,
729
+ api: MOCK_API,
730
+ });
731
+
732
+ const template = Template.fromStack(stack);
733
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
734
+ const distKeys = Object.keys(distributions);
735
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
736
+ const cacheBehaviors = distConfig.CacheBehaviors ?? [];
737
+ const webhookPatterns = cacheBehaviors.filter((b: any) => b.PathPattern === '/webhooks/*');
738
+
739
+ assert.strictEqual(webhookPatterns.length, 1, 'Should have exactly one /webhooks/* behavior despite two routes');
740
+ });
741
+
742
+ it('strips {param} segments from mixed param+wildcard routes for CloudFront patterns', () => {
743
+ createSpaBuildOutput(tmpDir);
744
+
745
+ registerRoute({
746
+ method: 'GET',
747
+ path: '/proxy/{version}/*',
748
+ handler: async () => {},
749
+ });
750
+
751
+ const app = new App();
752
+ const stack = new Stack(app, 'MixedParamWildcardStack');
753
+
754
+ new Hosting(stack, 'Hosting', {
755
+ root: tmpDir,
756
+ api: MOCK_API,
757
+ });
758
+
759
+ const template = Template.fromStack(stack);
760
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
761
+ const distKeys = Object.keys(distributions);
762
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
763
+ const cacheBehaviors = distConfig.CacheBehaviors ?? [];
764
+ const patterns = cacheBehaviors.map((b: any) => b.PathPattern);
765
+
766
+ assert.ok(patterns.includes('/proxy/*'), 'Should strip {version} and produce /proxy/*');
767
+ assert.ok(!patterns.includes('/proxy/{version}/*'), 'Should NOT have literal {version} in CloudFront pattern');
768
+ });
769
+
770
+ it('converts param-only routes to wildcard patterns for CloudFront', () => {
771
+ createSpaBuildOutput(tmpDir);
772
+
773
+ registerRoute({
774
+ method: 'GET',
775
+ path: '/items/{id}/details/{detailId}',
776
+ handler: async () => {},
777
+ });
778
+
779
+ const app = new App();
780
+ const stack = new Stack(app, 'ParamOnlyPatternStack');
781
+
782
+ new Hosting(stack, 'Hosting', {
783
+ root: tmpDir,
784
+ api: MOCK_API,
785
+ });
786
+
787
+ const template = Template.fromStack(stack);
788
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
789
+ const distKeys = Object.keys(distributions);
790
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
791
+ const cacheBehaviors = distConfig.CacheBehaviors ?? [];
792
+ const patterns = cacheBehaviors.map((b: any) => b.PathPattern);
793
+
794
+ assert.ok(patterns.includes('/items/*'), 'Should produce /items/* from /items/{id}/details/{detailId}');
795
+ });
796
+ });
797
+
798
+ // ── Issue #728: SSR cache policy honors origin Cache-Control ──
799
+
800
+ describe('SSR cache policy (issue #728)', () => {
801
+ it('uses a cache policy with minTTL=0, defaultTTL=0, maxTTL=31536000 for compute behaviors', () => {
802
+ createNextjsBuildOutput(tmpDir);
803
+
804
+ const app = new App();
805
+ const stack = new Stack(app, 'SsrCachePolicyStack');
806
+
807
+ new Hosting(stack, 'Hosting', {
808
+ root: tmpDir,
809
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
810
+ api: MOCK_API,
811
+ });
812
+
813
+ const template = Template.fromStack(stack);
814
+
815
+ // The L3 construct should create an SsrCachePolicy with these values
816
+ template.hasResourceProperties('AWS::CloudFront::CachePolicy', {
817
+ CachePolicyConfig: Match.objectLike({
818
+ MinTTL: 0,
819
+ DefaultTTL: 0,
820
+ MaxTTL: 31536000,
821
+ }),
822
+ });
823
+ });
824
+
825
+ it('default behavior for SSR routes does NOT use CACHING_DISABLED', () => {
826
+ createNextjsBuildOutput(tmpDir);
827
+
828
+ const app = new App();
829
+ const stack = new Stack(app, 'SsrNotDisabledStack');
830
+
831
+ new Hosting(stack, 'Hosting', {
832
+ root: tmpDir,
833
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
834
+ api: MOCK_API,
835
+ });
836
+
837
+ const template = Template.fromStack(stack);
838
+
839
+ // The default behavior (catch-all SSR) should reference a custom cache policy,
840
+ // NOT the managed CACHING_DISABLED policy (4135ea2d-6df8-44a3-9df3-4b5a84be39ad).
841
+ // Verify a custom CachePolicy resource exists with SSR-appropriate TTL values.
842
+ template.hasResourceProperties('AWS::CloudFront::CachePolicy', {
843
+ CachePolicyConfig: Match.objectLike({
844
+ MinTTL: 0,
845
+ DefaultTTL: 0,
846
+ }),
847
+ });
848
+
849
+ // Verify the distribution does NOT reference the managed CACHING_DISABLED policy ID directly
850
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
851
+ const distKeys = Object.keys(distributions);
852
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
853
+ const defaultBehavior = distConfig.DefaultCacheBehavior;
854
+ assert.ok(defaultBehavior, 'Should have a DefaultCacheBehavior');
855
+
856
+ // In CDK templates, CachePolicyId is a { Ref } object pointing to the custom policy.
857
+ // If it were a string matching the managed CACHING_DISABLED ID, that would be a bug.
858
+ const cachePolicyId = defaultBehavior.CachePolicyId;
859
+ assert.notStrictEqual(
860
+ cachePolicyId,
861
+ '4135ea2d-6df8-44a3-9df3-4b5a84be39ad',
862
+ 'SSR default behavior should NOT use managed CACHING_DISABLED policy',
863
+ );
864
+ // Confirm it's a Ref (pointing to a custom CachePolicy resource)
865
+ assert.ok(
866
+ typeof cachePolicyId === 'object' && cachePolicyId !== null,
867
+ 'CachePolicyId should be a CDK reference (object), not a literal string',
868
+ );
869
+ });
870
+ });
871
+
872
+ // ── Issue #729: timeout accepts number or Duration ──────────
873
+
874
+ describe('Compute timeout normalization (issue #729)', () => {
875
+ it('accepts timeout as a plain number (seconds)', () => {
876
+ createNextjsBuildOutput(tmpDir);
877
+
878
+ const app = new App();
879
+ const stack = new Stack(app, 'TimeoutNumberStack');
880
+
881
+ new Hosting(stack, 'Hosting', {
882
+ root: tmpDir,
883
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
884
+ api: MOCK_API,
885
+ compute: {
886
+ memorySize: 512,
887
+ timeout: 45,
888
+ },
889
+ });
890
+
891
+ const template = Template.fromStack(stack);
892
+
893
+ template.hasResourceProperties('AWS::Lambda::Function', {
894
+ Timeout: 45,
895
+ });
896
+ });
897
+
898
+ it('accepts timeout as a cdk.Duration', () => {
899
+ createNextjsBuildOutput(tmpDir);
900
+
901
+ const app = new App();
902
+ const stack = new Stack(app, 'TimeoutDurationStack');
903
+
904
+ new Hosting(stack, 'Hosting', {
905
+ root: tmpDir,
906
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
907
+ api: MOCK_API,
908
+ compute: {
909
+ memorySize: 512,
910
+ timeout: Duration.seconds(60),
911
+ },
912
+ });
913
+
914
+ const template = Template.fromStack(stack);
915
+
916
+ template.hasResourceProperties('AWS::Lambda::Function', {
917
+ Timeout: 60,
918
+ });
919
+ });
920
+
921
+ it('works without timeout specified (uses default)', () => {
922
+ createNextjsBuildOutput(tmpDir);
923
+
924
+ const app = new App();
925
+ const stack = new Stack(app, 'TimeoutDefaultStack');
926
+
927
+ new Hosting(stack, 'Hosting', {
928
+ root: tmpDir,
929
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
930
+ api: MOCK_API,
931
+ compute: {
932
+ memorySize: 512,
933
+ },
934
+ });
935
+
936
+ // Should not throw — synth completes successfully
937
+ const template = Template.fromStack(stack);
938
+ template.hasResourceProperties('AWS::Lambda::Function', {
939
+ MemorySize: 512,
940
+ });
941
+ });
942
+ });
943
+
944
+ // ── CORS hosting origin registration tests ─────────────────────
945
+
946
+ describe('CORS hosting origin registration', () => {
947
+ it('registers CORS_HOSTING_ORIGINS via registerConfig when api prop is provided', () => {
948
+ createSpaBuildOutput(tmpDir);
949
+
950
+ const app = new App();
951
+ const stack = new Stack(app, 'CorsRegisterStack');
952
+
953
+ new Hosting(stack, 'Hosting', {
954
+ root: tmpDir,
955
+ api: MOCK_API,
956
+ });
957
+
958
+ // registerConfig with a CloudFront domain token means the
959
+ // BucketDeployment (created by finalizeConfigRegistry) will
960
+ // include CORS_HOSTING_ORIGINS in blocks-config.json.
961
+ // The Hosting construct should NOT create any Custom Resource
962
+ // for CORS — it simply calls registerConfig().
963
+ const template = Template.fromStack(stack);
964
+
965
+ // No Lambda function with the old CORS merge description
966
+ const lambdas = template.findResources('AWS::Lambda::Function', {
967
+ Properties: {
968
+ Description: 'Merges CORS_HOSTING_ORIGINS into blocks-config.json after deployment',
969
+ },
970
+ });
971
+ assert.strictEqual(Object.keys(lambdas).length, 0, 'Should NOT create a CORS merge Lambda');
972
+ });
973
+
974
+ it('does not register CORS_HOSTING_ORIGINS when api prop is missing', () => {
975
+ createSpaBuildOutput(tmpDir);
976
+
977
+ const app = new App();
978
+ const stack = new Stack(app, 'NoCorsStack');
979
+
980
+ new Hosting(stack, 'Hosting', {
981
+ root: tmpDir,
982
+ });
983
+
984
+ // Without api prop, no CORS hosting origin should be registered
985
+ const template = Template.fromStack(stack);
986
+ const lambdas = template.findResources('AWS::Lambda::Function', {
987
+ Properties: {
988
+ Description: 'Merges CORS_HOSTING_ORIGINS into blocks-config.json after deployment',
989
+ },
990
+ });
991
+ assert.strictEqual(Object.keys(lambdas).length, 0, 'No CORS merge Lambda when api is missing');
992
+ });
993
+ });
994
+
995
+ // ── Multi-domain support ────────────────────────────────────────
996
+
997
+ describe('Multi-domain support', () => {
998
+ it('accepts an array of domain names', () => {
999
+ createSpaBuildOutput(tmpDir);
1000
+
1001
+ const app = new App();
1002
+ const stack = new Stack(app, 'MultiDomainStack', {
1003
+ env: { account: '123456789012', region: 'us-east-1' },
1004
+ });
1005
+
1006
+ new Hosting(stack, 'Hosting', {
1007
+ root: tmpDir,
1008
+ api: MOCK_API,
1009
+ domain: {
1010
+ domainName: ['example.com', 'www.example.com'],
1011
+ hostedZone: 'example.com',
1012
+ },
1013
+ });
1014
+
1015
+ const template = Template.fromStack(stack);
1016
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
1017
+ const distKeys = Object.keys(distributions);
1018
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
1019
+
1020
+ // Both domains should appear as CloudFront aliases
1021
+ assert.ok(distConfig.Aliases, 'Distribution should have Aliases');
1022
+ assert.ok(
1023
+ Array.isArray(distConfig.Aliases) && distConfig.Aliases.length === 2,
1024
+ `Should have 2 aliases, got ${JSON.stringify(distConfig.Aliases)}`,
1025
+ );
1026
+ });
1027
+
1028
+ it('still accepts a single domain name string (backward compat)', () => {
1029
+ createSpaBuildOutput(tmpDir);
1030
+
1031
+ const app = new App();
1032
+ const stack = new Stack(app, 'SingleDomainStack', {
1033
+ env: { account: '123456789012', region: 'us-east-1' },
1034
+ });
1035
+
1036
+ new Hosting(stack, 'Hosting', {
1037
+ root: tmpDir,
1038
+ api: MOCK_API,
1039
+ domain: {
1040
+ domainName: 'example.com',
1041
+ hostedZone: 'example.com',
1042
+ },
1043
+ });
1044
+
1045
+ const template = Template.fromStack(stack);
1046
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
1047
+ const distKeys = Object.keys(distributions);
1048
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
1049
+
1050
+ assert.ok(distConfig.Aliases, 'Distribution should have Aliases');
1051
+ });
1052
+ });
1053
+
1054
+ // ── www redirect ────────────────────────────────────────────────
1055
+
1056
+ describe('www redirect', () => {
1057
+ it('passes wwwRedirect config to the underlying construct', () => {
1058
+ createSpaBuildOutput(tmpDir);
1059
+
1060
+ const app = new App();
1061
+ const stack = new Stack(app, 'WwwRedirectStack', {
1062
+ env: { account: '123456789012', region: 'us-east-1' },
1063
+ });
1064
+
1065
+ // Should not throw; the L3 handles redirect logic
1066
+ new Hosting(stack, 'Hosting', {
1067
+ root: tmpDir,
1068
+ api: MOCK_API,
1069
+ domain: {
1070
+ domainName: ['example.com', 'www.example.com'],
1071
+ hostedZone: 'example.com',
1072
+ wwwRedirect: 'toApex',
1073
+ },
1074
+ });
1075
+
1076
+ // If the L3 creates a CloudFront Function for redirect, it will show up
1077
+ const template = Template.fromStack(stack);
1078
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1079
+ });
1080
+ });
1081
+
1082
+ // ── hostedZoneId ────────────────────────────────────────────────
1083
+
1084
+ describe('hostedZoneId support', () => {
1085
+ it('accepts hostedZoneId to skip hosted zone lookup', () => {
1086
+ createSpaBuildOutput(tmpDir);
1087
+
1088
+ const app = new App();
1089
+ const stack = new Stack(app, 'HostedZoneIdStack', {
1090
+ env: { account: '123456789012', region: 'us-east-1' },
1091
+ });
1092
+
1093
+ // Using hostedZoneId instead of hostedZone name avoids the
1094
+ // HostedZone.fromLookup() call (which requires context)
1095
+ new Hosting(stack, 'Hosting', {
1096
+ root: tmpDir,
1097
+ api: MOCK_API,
1098
+ domain: {
1099
+ domainName: 'example.com',
1100
+ hostedZoneId: 'Z1234567890ABC',
1101
+ },
1102
+ });
1103
+
1104
+ const template = Template.fromStack(stack);
1105
+ // DNS records should still be created
1106
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1107
+ });
1108
+ });
1109
+
1110
+ // ── BYO domain (no hostedZone) ─────────────────────────────────
1111
+
1112
+ describe('BYO domain (external DNS)', () => {
1113
+ it('works without hostedZone when user manages DNS externally', () => {
1114
+ createSpaBuildOutput(tmpDir);
1115
+
1116
+ const app = new App();
1117
+ const stack = new Stack(app, 'ExternalDnsStack', {
1118
+ env: { account: '123456789012', region: 'us-east-1' },
1119
+ });
1120
+
1121
+ // When hostedZone is omitted, a BYO certificate is required (the construct
1122
+ // cannot create one via DNS validation without a zone).
1123
+ const cert = cdk.aws_certificatemanager.Certificate.fromCertificateArn(
1124
+ stack,
1125
+ 'ImportedCert',
1126
+ 'arn:aws:acm:us-east-1:123456789012:certificate/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
1127
+ );
1128
+
1129
+ new Hosting(stack, 'Hosting', {
1130
+ root: tmpDir,
1131
+ api: MOCK_API,
1132
+ domain: {
1133
+ domainName: 'custom.example.com',
1134
+ certificate: cert,
1135
+ },
1136
+ });
1137
+
1138
+ const template = Template.fromStack(stack);
1139
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1140
+
1141
+ // No Route53 records should be created
1142
+ const route53Records = template.findResources('AWS::Route53::RecordSet');
1143
+ assert.strictEqual(
1144
+ Object.keys(route53Records).length,
1145
+ 0,
1146
+ 'Should not create Route53 records when hostedZone is omitted',
1147
+ );
1148
+ });
1149
+ });
1150
+
1151
+ // ── BYO WAF (webAclArn) ────────────────────────────────────────
1152
+
1153
+ describe('BYO WAF', () => {
1154
+ it('uses existing WebACL ARN instead of creating a new one', () => {
1155
+ createSpaBuildOutput(tmpDir);
1156
+
1157
+ const app = new App();
1158
+ const stack = new Stack(app, 'ByoWafStack', {
1159
+ env: { account: '123456789012', region: 'us-east-1' },
1160
+ });
1161
+
1162
+ const existingAclArn = 'arn:aws:wafv2:us-east-1:123456789012:global/webacl/my-acl/abc123';
1163
+
1164
+ new Hosting(stack, 'Hosting', {
1165
+ root: tmpDir,
1166
+ api: MOCK_API,
1167
+ waf: {
1168
+ enabled: true,
1169
+ webAclArn: existingAclArn,
1170
+ },
1171
+ });
1172
+
1173
+ const template = Template.fromStack(stack);
1174
+
1175
+ // When webAclArn is provided, the L3 should NOT create a new WebACL
1176
+ template.resourceCountIs('AWS::WAFv2::WebACL', 0);
1177
+
1178
+ // The distribution should reference the existing ACL
1179
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
1180
+ const distKeys = Object.keys(distributions);
1181
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
1182
+ assert.strictEqual(
1183
+ distConfig.WebACLId,
1184
+ existingAclArn,
1185
+ 'Distribution should reference the provided WebACL ARN',
1186
+ );
1187
+ });
1188
+ });
1189
+
1190
+ // ── Build cache ────────────────────────────────────────────────
1191
+
1192
+ describe('Build cache', () => {
1193
+ it('provisions build cache bucket when enabled', () => {
1194
+ createSpaBuildOutput(tmpDir);
1195
+
1196
+ const app = new App();
1197
+ const stack = new Stack(app, 'BuildCacheStack');
1198
+
1199
+ const hosting = new Hosting(stack, 'Hosting', {
1200
+ root: tmpDir,
1201
+ api: MOCK_API,
1202
+ buildCache: { enabled: true },
1203
+ });
1204
+
1205
+ assert.ok(hosting.buildCacheBucket, 'Should expose buildCacheBucket when enabled');
1206
+
1207
+ const template = Template.fromStack(stack);
1208
+ // Should have the build cache bucket output
1209
+ const outputs = template.findOutputs('*');
1210
+ const outputKeys = Object.keys(outputs);
1211
+ const buildCacheOutput = outputKeys.find((k) => k.includes('BuildCacheBucketName'));
1212
+ assert.ok(buildCacheOutput, 'Should create a BuildCacheBucketName CfnOutput');
1213
+ });
1214
+
1215
+ it('does not provision build cache bucket when not configured', () => {
1216
+ createSpaBuildOutput(tmpDir);
1217
+
1218
+ const app = new App();
1219
+ const stack = new Stack(app, 'NoBuildCacheStack');
1220
+
1221
+ const hosting = new Hosting(stack, 'Hosting', {
1222
+ root: tmpDir,
1223
+ api: MOCK_API,
1224
+ });
1225
+
1226
+ assert.strictEqual(hosting.buildCacheBucket, undefined, 'Should not have buildCacheBucket by default');
1227
+ });
1228
+ });
1229
+
1230
+ // ── Error pages ────────────────────────────────────────────────
1231
+
1232
+ describe('Custom error pages', () => {
1233
+ it('passes errorPages config to the construct', () => {
1234
+ createSpaBuildOutput(tmpDir);
1235
+
1236
+ // The construct validates error page files via fs.existsSync on the
1237
+ // provided path. Write the files to a known location and pass absolute paths.
1238
+ const notFoundPath = path.join(tmpDir, '404.html');
1239
+ const serverErrorPath = path.join(tmpDir, '500.html');
1240
+ fs.writeFileSync(notFoundPath, '<!DOCTYPE html><html><body>Not Found</body></html>');
1241
+ fs.writeFileSync(serverErrorPath, '<!DOCTYPE html><html><body>Server Error</body></html>');
1242
+
1243
+ const app = new App();
1244
+ const stack = new Stack(app, 'ErrorPagesStack');
1245
+
1246
+ new Hosting(stack, 'Hosting', {
1247
+ root: tmpDir,
1248
+ api: MOCK_API,
1249
+ errorPages: {
1250
+ notFound: notFoundPath,
1251
+ serverError: serverErrorPath,
1252
+ },
1253
+ });
1254
+
1255
+ const template = Template.fromStack(stack);
1256
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
1257
+ const distKeys = Object.keys(distributions);
1258
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
1259
+
1260
+ // When errorPages is set, CloudFront CustomErrorResponses should be configured
1261
+ const errorResponses = distConfig.CustomErrorResponses;
1262
+ assert.ok(errorResponses, 'Distribution should have CustomErrorResponses');
1263
+ const has404 = errorResponses.some(
1264
+ (r: any) => r.ErrorCode === 404,
1265
+ );
1266
+ assert.ok(has404, 'Should configure 404 error response');
1267
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1268
+ });
1269
+
1270
+ it('does not produce duplicate 404 responses when 404.html is in build output', () => {
1271
+ createSpaBuildOutput(tmpDir);
1272
+
1273
+ // Simulate Vite copying public/404.html into dist/404.html —
1274
+ // this triggers the SPA adapter's auto-detection.
1275
+ const distDir = path.join(tmpDir, 'dist');
1276
+ fs.writeFileSync(path.join(distDir, '404.html'), '<!DOCTYPE html><html><body>Not Found</body></html>');
1277
+ fs.writeFileSync(path.join(distDir, '500.html'), '<!DOCTYPE html><html><body>Server Error</body></html>');
1278
+
1279
+ // Also provide error pages via props (real paths for the construct's fs.existsSync check)
1280
+ const notFoundPath = path.join(tmpDir, '404.html');
1281
+ const serverErrorPath = path.join(tmpDir, '500.html');
1282
+ fs.writeFileSync(notFoundPath, '<!DOCTYPE html><html><body>Not Found</body></html>');
1283
+ fs.writeFileSync(serverErrorPath, '<!DOCTYPE html><html><body>Server Error</body></html>');
1284
+
1285
+ const app = new App();
1286
+ const stack = new Stack(app, 'DuplicateErrorPagesStack');
1287
+
1288
+ new Hosting(stack, 'Hosting', {
1289
+ root: tmpDir,
1290
+ api: MOCK_API,
1291
+ errorPages: {
1292
+ notFound: notFoundPath,
1293
+ serverError: serverErrorPath,
1294
+ },
1295
+ });
1296
+
1297
+ const template = Template.fromStack(stack);
1298
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
1299
+ const distKeys = Object.keys(distributions);
1300
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
1301
+
1302
+ // Verify no duplicate 404 error responses
1303
+ const errorResponses = distConfig.CustomErrorResponses;
1304
+ assert.ok(errorResponses, 'Distribution should have CustomErrorResponses');
1305
+ const error404Responses = errorResponses.filter(
1306
+ (r: any) => r.ErrorCode === 404,
1307
+ );
1308
+ assert.strictEqual(
1309
+ error404Responses.length, 1,
1310
+ `Expected exactly one 404 error response but got ${error404Responses.length}`,
1311
+ );
1312
+ });
1313
+ });
1314
+
1315
+ // ── Geo-restriction ────────────────────────────────────────────
1316
+
1317
+ describe('Geo-restriction', () => {
1318
+ it('passes geo-restriction whitelist to CloudFront', () => {
1319
+ createSpaBuildOutput(tmpDir);
1320
+
1321
+ const app = new App();
1322
+ const stack = new Stack(app, 'GeoRestrictionStack');
1323
+
1324
+ new Hosting(stack, 'Hosting', {
1325
+ root: tmpDir,
1326
+ api: MOCK_API,
1327
+ geoRestriction: { type: 'whitelist', countries: ['US', 'CA'] },
1328
+ });
1329
+
1330
+ const template = Template.fromStack(stack);
1331
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
1332
+ const distKeys = Object.keys(distributions);
1333
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
1334
+
1335
+ assert.ok(distConfig.Restrictions, 'Distribution should have Restrictions');
1336
+ const geoRestriction = distConfig.Restrictions?.GeoRestriction;
1337
+ assert.ok(geoRestriction, 'Should have GeoRestriction config');
1338
+ assert.strictEqual(geoRestriction.RestrictionType, 'whitelist');
1339
+ assert.deepStrictEqual(geoRestriction.Locations, ['US', 'CA']);
1340
+ });
1341
+ });
1342
+
1343
+ // ── Logging ────────────────────────────────────────────────────
1344
+
1345
+ describe('Access logging', () => {
1346
+ it('enables CloudFront access logging when configured', () => {
1347
+ createSpaBuildOutput(tmpDir);
1348
+
1349
+ const app = new App();
1350
+ const stack = new Stack(app, 'LoggingStack');
1351
+
1352
+ new Hosting(stack, 'Hosting', {
1353
+ root: tmpDir,
1354
+ api: MOCK_API,
1355
+ logging: { enabled: true, retentionDays: 30 },
1356
+ });
1357
+
1358
+ const template = Template.fromStack(stack);
1359
+ const distributions = template.findResources('AWS::CloudFront::Distribution');
1360
+ const distKeys = Object.keys(distributions);
1361
+ const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
1362
+
1363
+ // When logging is enabled, the distribution should have a Logging config
1364
+ assert.ok(distConfig.Logging, 'Distribution should have Logging config');
1365
+ });
1366
+ });
1367
+
1368
+ // ── Monitoring ─────────────────────────────────────────────────
1369
+
1370
+ describe('Monitoring', () => {
1371
+ it('exposes monitoringTopic when monitoring is enabled', () => {
1372
+ createNextjsBuildOutput(tmpDir);
1373
+
1374
+ const app = new App();
1375
+ const stack = new Stack(app, 'MonitoringStack');
1376
+
1377
+ const hosting = new Hosting(stack, 'Hosting', {
1378
+ root: tmpDir,
1379
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
1380
+ api: MOCK_API,
1381
+ monitoring: { enabled: true },
1382
+ });
1383
+
1384
+ // The L3 should create an SNS topic for alarms
1385
+ assert.ok(hosting.monitoringTopic, 'Should expose monitoringTopic when enabled');
1386
+ });
1387
+ });
1388
+
1389
+ // ── Skew protection ─────────────────────────────────────────────
1390
+
1391
+ describe('Skew protection', () => {
1392
+ it('passes skewProtection config through to L3 construct', () => {
1393
+ createNextjsBuildOutput(tmpDir);
1394
+
1395
+ const app = new App();
1396
+ const stack = new Stack(app, 'SkewProtectionStack');
1397
+
1398
+ new Hosting(stack, 'Hosting', {
1399
+ root: tmpDir,
1400
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
1401
+ api: MOCK_API,
1402
+ skewProtection: { enabled: true },
1403
+ });
1404
+
1405
+ // Synth should succeed without throwing
1406
+ const template = Template.fromStack(stack);
1407
+
1408
+ // The L3 creates a CloudFront Function for skew protection cookie handling
1409
+ // Verify distribution was created (synth success proves passthrough works)
1410
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1411
+ });
1412
+
1413
+ it('allows disabling skew protection', () => {
1414
+ createNextjsBuildOutput(tmpDir);
1415
+
1416
+ const app = new App();
1417
+ const stack = new Stack(app, 'NoSkewStack');
1418
+
1419
+ new Hosting(stack, 'Hosting', {
1420
+ root: tmpDir,
1421
+ customAdapter: createNextjsFixtureAdapter(tmpDir),
1422
+ api: MOCK_API,
1423
+ skewProtection: { enabled: false },
1424
+ });
1425
+
1426
+ // Synth should succeed without throwing
1427
+ const template = Template.fromStack(stack);
1428
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1429
+ });
1430
+ });
1431
+ });