cdk-nextjs 0.6.2 → 0.7.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 (263) hide show
  1. package/.jsii +2404 -2723
  2. package/.jsii.tabl.json +1 -1
  3. package/API.md +1681 -1930
  4. package/CLAUDE.md +2 -1
  5. package/README.md +429 -130
  6. package/assets/lambdas/post-deploy/post-deploy.lambda/index.js +148 -169
  7. package/assets/lambdas/redeploy-stage/redeploy-stage.lambda/index.js +875 -0
  8. package/docs/adapter-runtime.md +185 -0
  9. package/docs/breaking-changes.md +667 -4
  10. package/docs/caching-guide.md +156 -112
  11. package/docs/cdk-nextjs-cfn-exec-policy.json +2 -1
  12. package/docs/development-guide.md +1 -1
  13. package/docs/harness-coverage.md +598 -0
  14. package/docs/init-cache-deployment.md +32 -1
  15. package/docs/next-build-output-guide.md +30 -20
  16. package/examples/README.md +5 -1
  17. package/lib/adapter/__fixtures__/app-playground-base-path.json +4926 -0
  18. package/lib/adapter/__fixtures__/app-playground.json +4917 -0
  19. package/lib/adapter/__fixtures__/pages-i18n.json +1728 -0
  20. package/lib/adapter/adapter.mjs +1548 -185
  21. package/lib/adapter/adapter.test.js +106 -0
  22. package/lib/adapter/aws-cache-store.d.ts +489 -0
  23. package/lib/adapter/aws-cache-store.js +1004 -0
  24. package/lib/adapter/aws-cache-store.test.d.ts +1 -0
  25. package/lib/adapter/aws-cache-store.test.js +361 -0
  26. package/lib/adapter/build-outputs.d.ts +80 -0
  27. package/lib/adapter/build-outputs.js +852 -0
  28. package/lib/adapter/build-outputs.test.d.ts +1 -0
  29. package/lib/adapter/build-outputs.test.js +861 -0
  30. package/lib/adapter/cache-handler.d.ts +9 -2
  31. package/lib/adapter/cache-handler.js +72 -96
  32. package/lib/adapter/cache-handler.mjs +18956 -17774
  33. package/lib/adapter/cache-handler.test.js +263 -31
  34. package/lib/adapter/cache-kinds.d.ts +32 -0
  35. package/lib/adapter/cache-kinds.js +35 -0
  36. package/lib/adapter/cache-kinds.test.d.ts +1 -0
  37. package/lib/adapter/cache-kinds.test.js +69 -0
  38. package/lib/adapter/cache-utils.d.ts +90 -0
  39. package/lib/adapter/cache-utils.js +199 -4
  40. package/lib/adapter/cache-utils.test.d.ts +1 -0
  41. package/lib/adapter/cache-utils.test.js +179 -0
  42. package/lib/adapter/function-groups.d.ts +215 -0
  43. package/lib/adapter/function-groups.js +1099 -0
  44. package/lib/adapter/function-groups.test.d.ts +1 -0
  45. package/lib/adapter/function-groups.test.js +743 -0
  46. package/lib/adapter/init-cache.d.ts +6 -0
  47. package/lib/adapter/init-cache.js +245 -0
  48. package/lib/adapter/init-cache.test.d.ts +1 -0
  49. package/lib/adapter/init-cache.test.js +137 -0
  50. package/lib/adapter/local-file-cache-handler.d.ts +23 -0
  51. package/lib/adapter/local-file-cache-handler.js +52 -1
  52. package/lib/adapter/memory-cache-handler.d.ts +26 -17
  53. package/lib/adapter/memory-cache-handler.js +32 -50
  54. package/lib/adapter/memory-cache-handler.test.js +105 -85
  55. package/lib/adapter/s3-cache-handler.d.ts +179 -40
  56. package/lib/adapter/s3-cache-handler.js +807 -277
  57. package/lib/adapter/s3-cache-handler.test.js +1186 -31
  58. package/lib/adapter/use-cache-common.d.ts +90 -0
  59. package/lib/adapter/use-cache-common.js +250 -0
  60. package/lib/adapter/use-cache-common.test.d.ts +1 -0
  61. package/lib/adapter/use-cache-common.test.js +646 -0
  62. package/lib/adapter/use-cache-default-handler.d.ts +24 -0
  63. package/lib/adapter/use-cache-default-handler.js +90 -0
  64. package/lib/adapter/use-cache-default-handler.mjs +38645 -0
  65. package/lib/adapter/use-cache-handlers.test.d.ts +1 -0
  66. package/lib/adapter/use-cache-handlers.test.js +349 -0
  67. package/lib/adapter/use-cache-remote-handler.d.ts +26 -0
  68. package/lib/adapter/use-cache-remote-handler.js +186 -0
  69. package/lib/{image-optimization/handler.mjs → adapter/use-cache-remote-handler.mjs} +33572 -38798
  70. package/lib/generated-structs/OptionalNextjsApiProps.d.ts +59 -0
  71. package/lib/generated-structs/OptionalNextjsApiProps.js +3 -0
  72. package/lib/generated-structs/OptionalNextjsCacheProps.d.ts +15 -6
  73. package/lib/generated-structs/OptionalNextjsCacheProps.js +1 -1
  74. package/lib/generated-structs/OptionalNextjsContainersProps.d.ts +25 -11
  75. package/lib/generated-structs/OptionalNextjsContainersProps.js +1 -1
  76. package/lib/generated-structs/OptionalNextjsDistributionProps.d.ts +31 -9
  77. package/lib/generated-structs/OptionalNextjsDistributionProps.js +1 -1
  78. package/lib/generated-structs/OptionalNextjsPostDeployProps.d.ts +8 -0
  79. package/lib/generated-structs/OptionalNextjsPostDeployProps.js +1 -1
  80. package/lib/generated-structs/OptionalPostDeployCustomResourceProperties.d.ts +6 -4
  81. package/lib/generated-structs/OptionalPostDeployCustomResourceProperties.js +1 -1
  82. package/lib/index.d.ts +6 -7
  83. package/lib/index.js +2 -4
  84. package/lib/lambdas/post-deploy/prune-cache-bucket.d.ts +7 -3
  85. package/lib/lambdas/post-deploy/prune-cache-bucket.js +57 -71
  86. package/lib/lambdas/post-deploy/prune-cache-bucket.test.d.ts +1 -0
  87. package/lib/lambdas/post-deploy/prune-cache-bucket.test.js +114 -0
  88. package/lib/lambdas/post-deploy/prune-revalidation-table.d.ts +1 -0
  89. package/lib/lambdas/post-deploy/prune-revalidation-table.js +23 -31
  90. package/lib/lambdas/post-deploy/prune-revalidation-table.test.d.ts +1 -0
  91. package/lib/lambdas/post-deploy/prune-revalidation-table.test.js +71 -0
  92. package/lib/lambdas/post-deploy/prune-s3.d.ts +14 -5
  93. package/lib/lambdas/post-deploy/prune-s3.js +41 -12
  94. package/lib/lambdas/post-deploy/prune-s3.test.js +97 -18
  95. package/lib/lambdas/redeploy-stage/redeploy-stage-function.d.ts +13 -0
  96. package/lib/lambdas/redeploy-stage/redeploy-stage-function.js +55 -0
  97. package/lib/lambdas/redeploy-stage/redeploy-stage.d.ts +24 -0
  98. package/lib/lambdas/redeploy-stage/redeploy-stage.js +68 -0
  99. package/lib/lambdas/redeploy-stage/redeploy-stage.lambda.d.ts +20 -0
  100. package/lib/lambdas/redeploy-stage/redeploy-stage.lambda.js +24 -0
  101. package/lib/lambdas/redeploy-stage/redeploy-stage.test.d.ts +1 -0
  102. package/lib/lambdas/redeploy-stage/redeploy-stage.test.js +93 -0
  103. package/lib/nextjs-api.d.ts +102 -18
  104. package/lib/nextjs-api.js +338 -38
  105. package/lib/nextjs-api.test.js +537 -1
  106. package/lib/nextjs-build/global-containers.Dockerfile +15 -10
  107. package/lib/nextjs-build/nextjs-build.d.ts +339 -63
  108. package/lib/nextjs-build/nextjs-build.js +684 -327
  109. package/lib/nextjs-build/nextjs-build.test.d.ts +1 -0
  110. package/lib/nextjs-build/nextjs-build.test.js +412 -0
  111. package/lib/nextjs-build/patch-fetch.js +1 -1
  112. package/lib/nextjs-build/patch-fetch.test.js +166 -11
  113. package/lib/nextjs-build/regional-containers.Dockerfile +17 -10
  114. package/lib/nextjs-cache.d.ts +82 -4
  115. package/lib/nextjs-cache.js +186 -10
  116. package/lib/nextjs-cache.test.js +176 -1
  117. package/lib/nextjs-compute/nextjs-compute-base-props.d.ts +21 -8
  118. package/lib/nextjs-compute/nextjs-compute-base-props.js +41 -1
  119. package/lib/nextjs-compute/nextjs-compute-base-props.test.d.ts +1 -0
  120. package/lib/nextjs-compute/nextjs-compute-base-props.test.js +44 -0
  121. package/lib/nextjs-compute/nextjs-containers.d.ts +19 -0
  122. package/lib/nextjs-compute/nextjs-containers.js +57 -20
  123. package/lib/nextjs-compute/nextjs-functions.d.ts +122 -7
  124. package/lib/nextjs-compute/nextjs-functions.js +113 -67
  125. package/lib/nextjs-compute/nextjs-functions.test.d.ts +1 -0
  126. package/lib/nextjs-compute/nextjs-functions.test.js +99 -0
  127. package/lib/nextjs-distribution.d.ts +194 -23
  128. package/lib/nextjs-distribution.js +535 -62
  129. package/lib/nextjs-distribution.test.d.ts +1 -0
  130. package/lib/nextjs-distribution.test.js +697 -0
  131. package/lib/nextjs-post-deploy.d.ts +13 -5
  132. package/lib/nextjs-post-deploy.js +13 -10
  133. package/lib/nextjs-post-deploy.test.d.ts +1 -0
  134. package/lib/nextjs-post-deploy.test.js +34 -0
  135. package/lib/nextjs-static-assets.d.ts +17 -4
  136. package/lib/nextjs-static-assets.js +22 -5
  137. package/lib/root-constructs/nextjs-base-construct.d.ts +102 -58
  138. package/lib/root-constructs/nextjs-base-construct.js +139 -47
  139. package/lib/root-constructs/nextjs-global-containers.d.ts +18 -20
  140. package/lib/root-constructs/nextjs-global-containers.js +20 -71
  141. package/lib/root-constructs/nextjs-global-functions.d.ts +26 -28
  142. package/lib/root-constructs/nextjs-global-functions.js +52 -70
  143. package/lib/root-constructs/nextjs-regional-containers.d.ts +18 -5
  144. package/lib/root-constructs/nextjs-regional-containers.js +8 -15
  145. package/lib/root-constructs/nextjs-regional-functions.d.ts +22 -18
  146. package/lib/root-constructs/nextjs-regional-functions.js +8 -26
  147. package/lib/runtime/api-gateway-path.d.ts +34 -0
  148. package/lib/runtime/api-gateway-path.js +71 -0
  149. package/lib/runtime/api-gateway-path.test.d.ts +1 -0
  150. package/lib/runtime/api-gateway-path.test.js +65 -0
  151. package/lib/runtime/case-redirect.d.ts +22 -0
  152. package/lib/runtime/case-redirect.js +63 -0
  153. package/lib/runtime/case-redirect.test.d.ts +1 -0
  154. package/lib/runtime/case-redirect.test.js +32 -0
  155. package/lib/runtime/core.d.ts +234 -0
  156. package/lib/runtime/core.js +1213 -0
  157. package/lib/runtime/core.test.d.ts +1 -0
  158. package/lib/runtime/core.test.js +1678 -0
  159. package/lib/runtime/deployment-root.d.ts +13 -0
  160. package/lib/runtime/deployment-root.js +29 -0
  161. package/lib/runtime/dispatch.d.ts +331 -0
  162. package/lib/runtime/dispatch.js +751 -0
  163. package/lib/runtime/dispatch.test.d.ts +1 -0
  164. package/lib/runtime/dispatch.test.js +853 -0
  165. package/lib/runtime/entrypoints.d.ts +57 -0
  166. package/lib/runtime/entrypoints.js +115 -0
  167. package/lib/runtime/http/abort-signal.d.ts +6 -0
  168. package/lib/runtime/http/abort-signal.js +19 -0
  169. package/lib/runtime/http/abort-signal.test.d.ts +1 -0
  170. package/lib/runtime/http/abort-signal.test.js +49 -0
  171. package/lib/runtime/http/node-server.d.ts +38 -0
  172. package/lib/runtime/http/node-server.js +99 -0
  173. package/lib/runtime/http/node-server.test.d.ts +1 -0
  174. package/lib/runtime/http/node-server.test.js +96 -0
  175. package/lib/runtime/http/node-sink.d.ts +16 -0
  176. package/lib/runtime/http/node-sink.js +48 -0
  177. package/lib/runtime/http/node-sink.test.d.ts +1 -0
  178. package/lib/runtime/http/node-sink.test.js +66 -0
  179. package/lib/runtime/http/request.d.ts +62 -0
  180. package/lib/runtime/http/request.js +134 -0
  181. package/lib/runtime/http/request.test.d.ts +1 -0
  182. package/lib/runtime/http/request.test.js +103 -0
  183. package/lib/runtime/http/response.d.ts +77 -0
  184. package/lib/runtime/http/response.js +229 -0
  185. package/lib/runtime/http/response.test.d.ts +1 -0
  186. package/lib/runtime/http/response.test.js +178 -0
  187. package/lib/runtime/http/sink.d.ts +73 -0
  188. package/lib/runtime/http/sink.js +188 -0
  189. package/lib/runtime/http/sink.test.d.ts +1 -0
  190. package/lib/runtime/http/sink.test.js +257 -0
  191. package/lib/runtime/image-utils.d.ts +78 -0
  192. package/lib/runtime/image-utils.js +129 -0
  193. package/lib/runtime/image-utils.test.d.ts +1 -0
  194. package/lib/runtime/image-utils.test.js +237 -0
  195. package/lib/runtime/image.d.ts +76 -0
  196. package/lib/runtime/image.js +296 -0
  197. package/lib/runtime/image.test.d.ts +1 -0
  198. package/lib/runtime/image.test.js +405 -0
  199. package/lib/runtime/lambda-event.d.ts +4 -0
  200. package/lib/runtime/lambda-event.js +113 -0
  201. package/lib/runtime/lambda-event.test.d.ts +1 -0
  202. package/lib/runtime/lambda-event.test.js +119 -0
  203. package/lib/runtime/lambda.mjs +41595 -0
  204. package/lib/runtime/load-module.d.ts +8 -0
  205. package/lib/runtime/load-module.js +35 -0
  206. package/lib/runtime/manifest.d.ts +215 -0
  207. package/lib/runtime/manifest.js +118 -0
  208. package/lib/runtime/middleware.d.ts +43 -0
  209. package/lib/runtime/middleware.js +113 -0
  210. package/lib/runtime/middleware.test.d.ts +1 -0
  211. package/lib/runtime/middleware.test.js +259 -0
  212. package/lib/runtime/next-modules.d.ts +55 -0
  213. package/lib/runtime/next-modules.js +124 -0
  214. package/lib/runtime/next-modules.test.d.ts +1 -0
  215. package/lib/runtime/next-modules.test.js +34 -0
  216. package/lib/runtime/public-files.d.ts +32 -0
  217. package/lib/runtime/public-files.js +117 -0
  218. package/lib/runtime/public-files.test.d.ts +1 -0
  219. package/lib/runtime/public-files.test.js +97 -0
  220. package/lib/runtime/server.mjs +41588 -0
  221. package/lib/runtime/static-files.d.ts +50 -0
  222. package/lib/runtime/static-files.js +266 -0
  223. package/lib/runtime/static-files.test.d.ts +1 -0
  224. package/lib/runtime/static-files.test.js +118 -0
  225. package/lib/runtime/util.d.ts +28 -0
  226. package/lib/runtime/util.js +79 -0
  227. package/lib/utils/base-path.d.ts +106 -13
  228. package/lib/utils/base-path.js +254 -41
  229. package/lib/utils/base-path.test.js +219 -51
  230. package/lib/utils/get-architecture.d.ts +7 -8
  231. package/lib/utils/get-architecture.js +12 -13
  232. package/lib/utils/zip-directory.d.ts +14 -0
  233. package/lib/utils/zip-directory.js +121 -0
  234. package/lib/utils/zip-directory.test.d.ts +1 -0
  235. package/lib/utils/zip-directory.test.js +99 -0
  236. package/package.json +22 -3
  237. package/scripts/capture-adapter-fixture.mjs +325 -0
  238. package/scripts/e2e-deploy.sh +398 -0
  239. package/scripts/e2e-harness/README.md +707 -0
  240. package/scripts/e2e-harness/app.js +370 -0
  241. package/scripts/e2e-harness/common.sh +249 -0
  242. package/scripts/e2e-harness/screen.mjs +302 -0
  243. package/scripts/e2e-harness/stage-proxy.mjs +145 -0
  244. package/scripts/e2e-harness/type-filters.mjs +47 -0
  245. package/scripts/e2e-logs.sh +75 -0
  246. package/scripts/e2e-offline.sh +91 -0
  247. package/scripts/e2e-sweep.sh +370 -0
  248. package/scripts/e2e-warm.sh +114 -0
  249. package/scripts/zero-config-build.mjs +418 -0
  250. package/docs/1k-concurrent-users-executions.png +0 -0
  251. package/docs/plans/adapter-runtime-release.md +0 -362
  252. package/docs/plans/phase-0-image-function-opt-in.md +0 -295
  253. package/lib/generated-structs/OptionalDockerImageFunctionProps.d.ts +0 -425
  254. package/lib/generated-structs/OptionalDockerImageFunctionProps.js +0 -3
  255. package/lib/image-optimization/handler-utils.d.ts +0 -34
  256. package/lib/image-optimization/handler-utils.js +0 -83
  257. package/lib/image-optimization/handler-utils.test.js +0 -178
  258. package/lib/nextjs-build/functions.Dockerfile +0 -31
  259. package/lib/nextjs-compute/nextjs-image-function.d.ts +0 -53
  260. package/lib/nextjs-compute/nextjs-image-function.js +0 -59
  261. package/lib/utils/experimental-flags.d.ts +0 -24
  262. package/lib/utils/experimental-flags.js +0 -30
  263. /package/lib/{image-optimization/handler-utils.test.d.ts → adapter/adapter.test.d.ts} +0 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * The CDK app `scripts/e2e-deploy.sh` deploys: one long-lived stack wrapping
3
+ * whichever temporary app the Next.js compatibility harness most recently built.
4
+ *
5
+ * Plain CommonJS on purpose. It runs straight from `lib/` after `pnpm compile`,
6
+ * with no tsx, no tsconfig and no place in the jsii assembly - it is test
7
+ * infrastructure, not part of the published library.
8
+ *
9
+ * @see scripts/e2e-harness/README.md
10
+ */
11
+ const { App, CfnOutput, Duration, Stack } = require("aws-cdk-lib");
12
+ const { Vpc } = require("aws-cdk-lib/aws-ec2");
13
+ const {
14
+ NextjsGlobalContainers,
15
+ NextjsGlobalFunctions,
16
+ NextjsRegionalContainers,
17
+ NextjsRegionalFunctions,
18
+ } = require("../../lib");
19
+
20
+ const appDir = required("HARNESS_APP_DIR");
21
+ const stackName = required("HARNESS_STACK_NAME");
22
+ // One of the four `NextjsType`s, `global-functions` by default; see common.sh's
23
+ // `harness_nextjs_type`, which validates it and keeps the stack names apart.
24
+ const nextjsType = process.env["HARNESS_NEXTJS_TYPE"] || "global-functions";
25
+
26
+ function required(name) {
27
+ const value = process.env[name];
28
+ if (!value) {
29
+ throw new Error(
30
+ `${name} is required. This app is deployed by scripts/e2e-deploy.sh, ` +
31
+ `which sets it; see scripts/e2e-harness/README.md to run it by hand.`,
32
+ );
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /**
38
+ * Pins every post-deploy custom resource property that can differ between two
39
+ * fixtures, so the resource never changes and never does anything. Shared by
40
+ * every type.
41
+ *
42
+ * - `buildId` is the real build ID and `createInvalidationCommandInput` carries
43
+ * a `new Date().toISOString()` caller reference, so either one alone would
44
+ * make every synth a non-hotswappable change, and every test file a
45
+ * CloudFormation deployment.
46
+ * - `staticAssetsKeyPrefix` follows the fixture's `basePath`, so switching
47
+ * between a basePath fixture and one without would still force an Update -
48
+ * and an Update with the bucket and table names set prunes every
49
+ * `<buildId>/` prefix but `"harness"`, i.e. the cache the fixture just
50
+ * seeded. Dropping the names turns off every prune branch in
51
+ * `post-deploy.lambda.ts`, so the Create and any Update are both no-ops.
52
+ *
53
+ * What that gives up is the post-deploy pass itself: no pruning and no
54
+ * invalidation. Neither is missed. Cache isolation between files comes from
55
+ * `CDK_NEXTJS_BUILD_ID` (src/adapter/s3-cache-handler.ts), which *is*
56
+ * hotswappable, and `scripts/e2e-deploy.sh` invalidates the distribution itself
57
+ * - it has to, since a hotswap never runs a custom resource at all. The stack
58
+ * is deleted after every run, so nothing accumulates.
59
+ *
60
+ * `undefined` rather than fixed values: CDK strips undefined properties, and the
61
+ * handler guards on each being absent.
62
+ */
63
+ const PINNED_POST_DEPLOY = {
64
+ customResourceProperties: {
65
+ buildId: "harness",
66
+ createInvalidationCommandInput: undefined,
67
+ cacheBucketName: undefined,
68
+ revalidationTableName: undefined,
69
+ staticAssetsBucketName: undefined,
70
+ staticAssetsKeyPrefix: undefined,
71
+ },
72
+ };
73
+
74
+ const COMMON_PROPS = {
75
+ buildDirectory: appDir,
76
+ // `scripts/e2e-deploy.sh` already ran `next build`: the harness's own `build`
77
+ // script chains a `post-build` that prints the BUILD_ID / DEPLOYMENT_ID /
78
+ // NEXT_SUPPORTS_IMMUTABLE_ASSETS markers the harness parses, so the build has
79
+ // to happen there rather than in this synth.
80
+ skipBuild: true,
81
+ };
82
+
83
+ /**
84
+ * `NextjsRegionalFunctions`: API Gateway REST + Lambda, no CloudFront.
85
+ *
86
+ * Its URL always carries the stage, which the harness would discard, so
87
+ * `e2e-deploy.sh` does not report `HarnessUrl` to the suite directly: it puts
88
+ * `stage-proxy.mjs` in front of it and reports that. See there. The fixture is
89
+ * deployed exactly as built - no `basePath` injected - because the stage never
90
+ * reaches the app: API Gateway strips it, and the proxy re-adds it on the way in.
91
+ */
92
+ /**
93
+ * The app's resolved `basePath` from the build's `required-server-files.json`,
94
+ * the same file `NextjsBuild` reads it from.
95
+ */
96
+ function fixtureBasePath(dir) {
97
+ const file = require("node:path").join(dir, ".next", "required-server-files.json");
98
+ const { config } = JSON.parse(require("node:fs").readFileSync(file, "utf8"));
99
+ return config.basePath || undefined;
100
+ }
101
+
102
+ class RegionalHarnessStack extends Stack {
103
+ constructor(scope, id, props) {
104
+ super(scope, id, props);
105
+ const nextjs = new NextjsRegionalFunctions(this, "Nextjs", {
106
+ ...COMMON_PROPS,
107
+ // The fixture's own `basePath`, as the prop. `resolveBasePath` would derive
108
+ // the same value - a fixture's `basePath` never starts with the stage, so
109
+ // all of it is a resource path - but it would also warn on every deploy
110
+ // that the app's links miss the stage. They do not here: the proxy plays
111
+ // the root-mapped custom domain synth cannot see. Setting the prop to the
112
+ // app's own value is the documented way to say so.
113
+ basePath: fixtureBasePath(appDir),
114
+ overrides: {
115
+ nextjsApi: {
116
+ restApiProps: {
117
+ // One `AWS::ApiGateway::Account` per region, and it is not ours to
118
+ // own from a test stack.
119
+ cloudWatchRole: false,
120
+ },
121
+ },
122
+ nextjsPostDeploy: PINNED_POST_DEPLOY,
123
+ },
124
+ });
125
+ new CfnOutput(this, "HarnessUrl", {
126
+ key: "HarnessUrl",
127
+ // The stage URL alone - not `nextjs.url`, which appends the `basePath`
128
+ // prop set above, while the fixture's requests already carry it.
129
+ // `stage-proxy.mjs` appends each request's own absolute path, and trims
130
+ // the trailing slash `RestApi.url` ends with.
131
+ value: nextjs.nextjsApi.api.url,
132
+ });
133
+ new CfnOutput(this, "ServerFunctionName", {
134
+ key: "ServerFunctionName",
135
+ value: nextjs.nextjsFunctions.function.functionName,
136
+ });
137
+ }
138
+ }
139
+
140
+ class HarnessStack extends Stack {
141
+ constructor(scope, id, props) {
142
+ super(scope, id, props);
143
+
144
+ // `NextjsGlobalFunctions` is the default, because its front door is a
145
+ // CloudFront distribution served at the origin root. The harness builds
146
+ // every request URL as `new URL(path, deployUrl)` (`getFullUrl` in
147
+ // `test/lib/next-test-utils.ts` assigns `pathname`), so any prefix in the
148
+ // deployment URL is discarded - which is why `RegionalHarnessStack` above
149
+ // needs `stage-proxy.mjs` and this one needs nothing.
150
+ //
151
+ // It is also the front door the suite expects. `NEXT_TEST_MODE=deploy` is
152
+ // the mode Vercel validates edge-fronted deployments with, so its tests are
153
+ // written to tolerate a CDN, and the ones that cannot are already gated out
154
+ // of deploy mode upstream. Serving from CloudFront means `_next/static` and
155
+ // `public/` are answered by the `NextjsStaticAssets` bucket exactly as in
156
+ // production, rather than by some harness-only arrangement.
157
+ const nextjs = new NextjsGlobalFunctions(this, "Nextjs", {
158
+ ...COMMON_PROPS,
159
+ overrides: { nextjsPostDeploy: PINNED_POST_DEPLOY },
160
+ });
161
+
162
+ new CfnOutput(this, "HarnessUrl", {
163
+ key: "HarnessUrl",
164
+ // The distribution's own origin, deliberately not `nextjs.url`: that
165
+ // property appends the app's `basePath` (see NextjsGlobalFunctions#url),
166
+ // and next.js's tests expect a deployment URL without one. They build
167
+ // request URLs both ways - `new URL(path, next.url)`, which discards a
168
+ // prefix, and `` `${next.url}${path}` `` with `path` already carrying the
169
+ // basePath, which doubles it (`/base//base/refresh`). Vercel's deployment
170
+ // URL is the bare origin, so that is what a basePath fixture is written
171
+ // against.
172
+ //
173
+ // No trailing slash either, for the same reason: `vercel deploy` prints a
174
+ // bare origin, so `${next.url}${path}` is what the fixtures are written
175
+ // against. `new URL(path, next.url)` is unaffected - `path` is absolute.
176
+ value: `https://${nextjs.nextjsDistribution.distribution.domainName}`,
177
+ });
178
+ // Read by `scripts/e2e-deploy.sh` to invalidate between test files, and by
179
+ // `scripts/e2e-logs.sh`.
180
+ new CfnOutput(this, "DistributionId", {
181
+ key: "DistributionId",
182
+ value: nextjs.nextjsDistribution.distribution.distributionId,
183
+ });
184
+ new CfnOutput(this, "ServerFunctionName", {
185
+ key: "ServerFunctionName",
186
+ value: nextjs.nextjsFunctions.function.functionName,
187
+ });
188
+ }
189
+ }
190
+
191
+ /**
192
+ * A path no fixture routes, for the container health checks.
193
+ *
194
+ * Both container types require a `healthCheckPath` that answers 200, and the
195
+ * harness's fixtures - written for Vercel - have no such route. Adding one to
196
+ * every fixture would change the app under test (a pages-only fixture would grow
197
+ * an `app/` directory), so instead both checks are pointed at a path the app
198
+ * 404s and told that any answer means the server is up. They exist to tell a
199
+ * running task from one that is not listening, and a 404 tells them that.
200
+ *
201
+ * Under `/_next/static/`, which both this runtime and `next start` answer from
202
+ * the build's static files alone: a missing file there is a plain 404 before
203
+ * any app route is tried. Anywhere else a fixture's dynamic route can catch it.
204
+ * The top-level `/__cdk-nextjs-harness-health` was taken by
205
+ * `use-cache-metadata-route-handler`'s `/[slug]` page, which threw on it (a 500,
206
+ * past the ALB's 200-499 range, so the task under test kept being replaced);
207
+ * `/_next/__cdk-nextjs-harness-health` was taken by `sub-shell-generation`'s
208
+ * `/[lang]/[slug]` as `lang: "_next"`, and the health check's renders left
209
+ * `/es/*` served a runtime root layout instead of the build-time shell.
210
+ */
211
+ const HEALTH_CHECK_PATH = "/_next/static/__cdk-nextjs-harness-health";
212
+
213
+ /**
214
+ * The container types' own VPC, with one NAT gateway rather than the default of
215
+ * one per AZ. A sharded run creates a stack per shard, and every NAT gateway
216
+ * holds an Elastic IP out of a per-region quota the account's other stacks share.
217
+ */
218
+ function harnessVpc(scope) {
219
+ return new Vpc(scope, "Vpc", { maxAzs: 2, natGateways: 1 });
220
+ }
221
+
222
+ /**
223
+ * Loosens both container health checks to "the server answered" (see
224
+ * HEALTH_CHECK_PATH), keeping the construct's timings.
225
+ */
226
+ function acceptAnyAnswer(nextjsContainers, basePath) {
227
+ const path = `${basePath || ""}${HEALTH_CHECK_PATH}`;
228
+ const { albFargateService } = nextjsContainers;
229
+ // `configureHealthCheck` replaces the whole health check, so the construct's
230
+ // timings are restated (src/nextjs-compute/nextjs-containers.ts).
231
+ albFargateService.targetGroup.configureHealthCheck({
232
+ path,
233
+ healthyHttpCodes: "200-499",
234
+ healthyThresholdCount: 2,
235
+ interval: Duration.seconds(10),
236
+ timeout: Duration.seconds(5),
237
+ });
238
+ // The construct sets this the same way, through the container's props; there
239
+ // is no public setter. `wget --spider` fails on a 404, so ask node instead,
240
+ // which resolves `fetch` for any HTTP answer and rejects only when nothing is
241
+ // listening.
242
+ albFargateService.taskDefinition.defaultContainer.props.healthCheck = {
243
+ command: [
244
+ "CMD-SHELL",
245
+ `node -e "fetch('http://localhost:3000${path}').then(() => process.exit(0), () => process.exit(1))"`,
246
+ ],
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Keeps the harness's own files out of the image's build context, which is the
252
+ * fixture directory. `.adapter-cdk-out` is where this very synth writes its
253
+ * assembly - left in, the asset would try to stage a copy of itself - and
254
+ * `node_modules` and `.next/cache` are large and unused: the Dockerfile copies
255
+ * only the staged deployment root, `.next/static` and `public`.
256
+ */
257
+ const CONTAINER_OVERRIDES = {
258
+ dockerImageAssetProps: {
259
+ exclude: ["cdk.out", ".adapter-*", "node_modules", ".next/cache"],
260
+ },
261
+ };
262
+
263
+ function containerOutputs(scope, nextjsContainers) {
264
+ const { albFargateService } = nextjsContainers;
265
+ new CfnOutput(scope, "EcsClusterName", {
266
+ key: "EcsClusterName",
267
+ value: albFargateService.cluster.clusterName,
268
+ });
269
+ new CfnOutput(scope, "EcsServiceName", {
270
+ key: "EcsServiceName",
271
+ value: albFargateService.service.serviceName,
272
+ });
273
+ // Read by `e2e-deploy.sh`, which waits on it: a hotswap into ECS returns once
274
+ // the task is RUNNING, which is before the load balancer will route to it.
275
+ new CfnOutput(scope, "TargetGroupArn", {
276
+ key: "TargetGroupArn",
277
+ value: albFargateService.targetGroup.targetGroupArn,
278
+ });
279
+ const logDriver = albFargateService.taskDefinition.defaultContainer.logDriverConfig;
280
+ new CfnOutput(scope, "ServerLogGroupName", {
281
+ key: "ServerLogGroupName",
282
+ value: logDriver.options["awslogs-group"],
283
+ });
284
+ }
285
+
286
+ /**
287
+ * `NextjsGlobalContainers`: CloudFront + ALB + Fargate. Served at the
288
+ * distribution's origin root, like `HarnessStack`, and invalidated the same way
289
+ * by `e2e-deploy.sh`.
290
+ */
291
+ class GlobalContainersHarnessStack extends Stack {
292
+ constructor(scope, id, props) {
293
+ super(scope, id, props);
294
+ const nextjs = new NextjsGlobalContainers(this, "Nextjs", {
295
+ ...COMMON_PROPS,
296
+ healthCheckPath: HEALTH_CHECK_PATH,
297
+ vpc: harnessVpc(this),
298
+ overrides: {
299
+ nextjsContainers: CONTAINER_OVERRIDES,
300
+ nextjsPostDeploy: PINNED_POST_DEPLOY,
301
+ },
302
+ });
303
+ acceptAnyAnswer(nextjs.nextjsContainers, fixtureBasePath(appDir));
304
+ new CfnOutput(this, "HarnessUrl", {
305
+ key: "HarnessUrl",
306
+ // The bare origin, for the reasons given in `HarnessStack`.
307
+ value: `https://${nextjs.nextjsDistribution.distribution.domainName}`,
308
+ });
309
+ new CfnOutput(this, "DistributionId", {
310
+ key: "DistributionId",
311
+ value: nextjs.nextjsDistribution.distribution.distributionId,
312
+ });
313
+ containerOutputs(this, nextjs.nextjsContainers);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * `NextjsRegionalContainers`: a public ALB + Fargate, no CloudFront. The ALB
319
+ * forwards every path unchanged, so - unlike `NextjsRegionalFunctions` - its URL
320
+ * has no stage and needs no proxy. Plain HTTP, which is what the construct
321
+ * deploys without a certificate.
322
+ */
323
+ class RegionalContainersHarnessStack extends Stack {
324
+ constructor(scope, id, props) {
325
+ super(scope, id, props);
326
+ const nextjs = new NextjsRegionalContainers(this, "Nextjs", {
327
+ ...COMMON_PROPS,
328
+ healthCheckPath: HEALTH_CHECK_PATH,
329
+ vpc: harnessVpc(this),
330
+ overrides: {
331
+ nextjsContainers: CONTAINER_OVERRIDES,
332
+ nextjsPostDeploy: PINNED_POST_DEPLOY,
333
+ },
334
+ });
335
+ acceptAnyAnswer(nextjs.nextjsContainers, fixtureBasePath(appDir));
336
+ new CfnOutput(this, "HarnessUrl", {
337
+ key: "HarnessUrl",
338
+ // The ALB's bare origin - not `nextjs.url`, which appends the fixture's
339
+ // `basePath`, for the reasons given in `HarnessStack`.
340
+ value: nextjs.nextjsContainers.url,
341
+ });
342
+ containerOutputs(this, nextjs.nextjsContainers);
343
+ }
344
+ }
345
+
346
+ const STACK_CLASSES = {
347
+ "global-functions": HarnessStack,
348
+ "regional-functions": RegionalHarnessStack,
349
+ "global-containers": GlobalContainersHarnessStack,
350
+ "regional-containers": RegionalContainersHarnessStack,
351
+ };
352
+
353
+ const app = new App();
354
+ new STACK_CLASSES[nextjsType](app, stackName, {
355
+ stackName,
356
+ env: {
357
+ account: process.env["CDK_DEFAULT_ACCOUNT"],
358
+ region: process.env["CDK_DEFAULT_REGION"],
359
+ },
360
+ // A stack tag, so an orphaned stack is identifiable from CloudFormation alone
361
+ // long after the temporary app directory is gone. `e2e-sweep.sh` refuses to
362
+ // delete a stack without it. The key and value come from common.sh, which
363
+ // exports them, so the tag written here and the tag checked there cannot
364
+ // drift. Deliberately a constant: a tag whose value changed per deploy (a
365
+ // timestamp, say) would be a stack-level diff on every run and so a
366
+ // CloudFormation update, which is the one thing the hotswap path is trying to
367
+ // avoid. The sweeper ages stacks off their server function's (or ECS
368
+ // service's) last hotswap instead.
369
+ tags: { [required("HARNESS_TAG_KEY")]: required("HARNESS_TAG_VALUE") },
370
+ });
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env bash
2
+ # Shared by scripts/e2e-deploy.sh, e2e-logs.sh, e2e-warm.sh and e2e-sweep.sh.
3
+ # Sourced, not executed.
4
+
5
+ # Files the two lifecycle scripts hand to each other. They run as separate
6
+ # processes with `cwd` set to the harness's temporary app, so everything one
7
+ # needs from another has to be on disk in that directory.
8
+ # See https://nextjs.org/docs/app/api-reference/adapters/testing-adapters
9
+ readonly HARNESS_MARKERS_FILE=".adapter-markers.log"
10
+ readonly HARNESS_BUILD_LOG=".adapter-build.log"
11
+ readonly HARNESS_DEPLOY_LOG=".adapter-deploy.log"
12
+ readonly HARNESS_STACK_FILE=".adapter-stack.txt"
13
+ readonly HARNESS_CDK_OUT=".adapter-cdk-out"
14
+
15
+ # Every stack the harness creates carries these, and `e2e-sweep.sh` refuses to
16
+ # delete a stack that does not. A CloudFormation stack is the only record that
17
+ # survives the temporary app directory, so the tag is what makes an orphan
18
+ # identifiable later. Exported because `app.js` writes the tag from them.
19
+ readonly HARNESS_TAG_KEY="cdk-nextjs:harness"
20
+ readonly HARNESS_TAG_VALUE="1"
21
+ export HARNESS_TAG_KEY HARNESS_TAG_VALUE
22
+ readonly HARNESS_STACK_PREFIX="hrns-"
23
+
24
+ # A stable identifier for one harness app directory.
25
+ #
26
+ # Derived from the directory rather than random so that any script can recompute
27
+ # it without a handoff file. The harness's directory name is already unique per
28
+ # test (`next-test-<ms>-<rand>`), but tests with a `subDir` end in a shared
29
+ # basename like `app`, so the hash is of the full path.
30
+ harness_app_id() {
31
+ local dir="$1"
32
+ local base
33
+ # `printf` rather than a bare `basename` pipe: `tr` would turn the trailing
34
+ # newline into another separator, giving `<name>--<hash>`.
35
+ base="$(printf '%s' "$(basename "$dir")" | tr -cs '[:alnum:]' '-')"
36
+ local hash
37
+ hash="$(node -e 'process.stdout.write(require("node:crypto").createHash("sha1").update(process.argv[1]).digest("hex").slice(0, 8))' "$dir")"
38
+ printf '%s-%s' "${base:0:60}" "$hash"
39
+ }
40
+
41
+ # The stack name every harness app directory deploys into.
42
+ #
43
+ # One shared stack. The harness builds a *different* app per test file, so the
44
+ # deploy itself cannot be skipped - but the CloudFront distribution it goes behind
45
+ # can be created once instead of once per file, which is where most of the
46
+ # wall-clock went. That only works if every test file lands in the
47
+ # same stack, which in turn means:
48
+ #
49
+ # - Test files must be serialized (`run-tests.js -c 1`). Two concurrent
50
+ # deploys into one stack would race, and the second would see the first's
51
+ # app.
52
+ # - Nothing may delete the stack between files, which is why the harness gets
53
+ # no NEXT_TEST_CLEANUP_SCRIPT_PATH; `e2e-sweep.sh --apply --shared` deletes
54
+ # it once at the end of the run.
55
+ # - Nothing may leak between files. Server cache entries are keyed by
56
+ # `CDK_NEXTJS_BUILD_ID` (src/adapter/s3-cache-handler.ts), which differs per
57
+ # file and hotswaps with the function, and `e2e-deploy.sh` invalidates the
58
+ # distribution's edge cache before it reports the URL.
59
+ #
60
+ # HARNESS_SHARED_STACK_SUFFIX is how a run gets parallel anyway: N processes,
61
+ # each with a shared stack of its own and its own disjoint slice of the file
62
+ # list (`run-tests.js -g <n>/<N>`), still serial *within* a slice. That is what
63
+ # .github/workflows/e2e-harness.yml's matrix does, one stack per shard. Two
64
+ # concurrent runs must not reuse a suffix, for the same reason two files in one
65
+ # shard cannot overlap.
66
+ #
67
+ # A developer debugging one file gets a stack of their own the same way
68
+ # (`HARNESS_SHARED_STACK_SUFFIX=dev-$USER`), and deletes it with
69
+ # `e2e-sweep.sh --apply --shared`.
70
+ #
71
+ # Every type but `global-functions` (HARNESS_NEXTJS_TYPE) gets its own infix
72
+ # after the prefix - `rf-`, `gc-`, `rc-` - so a run can never deploy into another
73
+ # type's shared stack: a different root construct in the same stack would be a
74
+ # replacement of nearly everything in it. The prefix is unchanged, so
75
+ # `e2e-sweep.sh` finds them all.
76
+ harness_stack_name() {
77
+ local prefix="$HARNESS_STACK_PREFIX"
78
+ local type
79
+ # Assigned on its own line so a rejected type fails this function instead of
80
+ # comparing as "" and quietly naming a Global stack.
81
+ type="$(harness_nextjs_type)" || return 1
82
+ case "$type" in
83
+ regional-functions) prefix="${prefix}rf-" ;;
84
+ global-containers) prefix="${prefix}gc-" ;;
85
+ regional-containers) prefix="${prefix}rc-" ;;
86
+ esac
87
+ printf '%s%s' "$prefix" "${HARNESS_SHARED_STACK_SUFFIX:-shared}"
88
+ }
89
+
90
+ # Which root construct `app.js` deploys: `global-functions` (the default),
91
+ # `regional-functions`, `global-containers` or `regional-containers`. Exits on
92
+ # anything else, rather than quietly deploying the default under a name that says
93
+ # otherwise.
94
+ #
95
+ # When called as `$(harness_nextjs_type)`, that `exit` only ends the command
96
+ # substitution's subshell, which is why this file also runs it once at the top
97
+ # level (at the bottom), where it ends the script that sourced it.
98
+ harness_nextjs_type() {
99
+ local type="${HARNESS_NEXTJS_TYPE:-global-functions}"
100
+ case "$type" in
101
+ global-functions | regional-functions | global-containers | regional-containers)
102
+ printf '%s' "$type"
103
+ ;;
104
+ *)
105
+ echo "harness: HARNESS_NEXTJS_TYPE=$type is not global-functions, regional-functions, global-containers or regional-containers" >&2
106
+ exit 1
107
+ ;;
108
+ esac
109
+ }
110
+
111
+ # The localhost port `stage-proxy.mjs` listens on for a stack. Derived from the
112
+ # stack name, so every script - and every shard - computes the same port for the
113
+ # same stack with no handoff file, and two shards' proxies do not collide.
114
+ harness_proxy_port() {
115
+ local stack="$1"
116
+ node -e '
117
+ const hash = require("node:crypto").createHash("sha1").update(process.argv[1]).digest();
118
+ process.stdout.write(String(40000 + (hash.readUInt16BE(0) % 10000)));
119
+ ' "$stack"
120
+ }
121
+
122
+ # Where a stack's proxy records its pid and target, so the next test file can
123
+ # reuse it. In TMPDIR rather than the app directory: the proxy outlives the app.
124
+ harness_proxy_state() {
125
+ local stack="$1"
126
+ printf '%s/%s-stage-proxy' "${TMPDIR:-/tmp}" "$stack"
127
+ }
128
+
129
+ # Read one CloudFormation output off the stack, or nothing if it has none.
130
+ harness_stack_output() {
131
+ local stack="$1" key="$2"
132
+ local value
133
+ value="$(aws cloudformation describe-stacks --stack-name "$stack" \
134
+ --query "Stacks[0].Outputs[?OutputKey=='${key}'].OutputValue" \
135
+ --output text 2>/dev/null)" || return 0
136
+ # `--output text` prints `None` for an empty result, which is not a value.
137
+ [ "$value" = "None" ] && return 0
138
+ printf '%s' "$value"
139
+ }
140
+
141
+ # Print the named stack's StackStatus, or nothing if CloudFormation says it
142
+ # does not exist. Any other failure (AccessDenied, throttling, no credentials)
143
+ # returns non-zero with the CLI's error on stderr, so a caller can't mistake a
144
+ # stack it isn't allowed to read for a stack that is missing.
145
+ harness_stack_status() {
146
+ local stack="$1"
147
+ local out
148
+ if out="$(aws cloudformation describe-stacks --stack-name "$stack" \
149
+ --query "Stacks[0].StackStatus" --output text 2>&1)"; then
150
+ printf '%s' "$out"
151
+ return 0
152
+ fi
153
+ case "$out" in
154
+ *"does not exist"*) return 0 ;;
155
+ esac
156
+ printf '%s\n' "$out" >&2
157
+ return 1
158
+ }
159
+
160
+ # Wait for an orphaned `cdk deploy` of the named stack to exit, then stop it if
161
+ # it has not. A deploy that outran its caller's timeout was killed, but its
162
+ # `cdk deploy` can outlive it - and a hotswap runs in that process, invisible to
163
+ # CloudFormation, so no stack status says it is still going.
164
+ #
165
+ # Bounded by HARNESS_ORPHAN_WAIT (10 minutes), then killed: the file that started
166
+ # it has already failed, and waiting longer only fails the next one too. The
167
+ # bound used to be 30 minutes with no kill, and one `cdk deploy` that hung after
168
+ # synth (global-containers shard 11, 2026-09-29) failed every file behind it on
169
+ # its 15-minute budget until the shard hit its step cap, 23 files unrun. What a
170
+ # kill leaves server-side, the caller's stack-status wait covers.
171
+ harness_wait_for_cdk() {
172
+ local stack="$1"
173
+ local waited=0
174
+ local limit="${HARNESS_ORPHAN_WAIT:-600}"
175
+ while pgrep -f -- "deploy $stack --app" >/dev/null; do
176
+ if [ "$waited" -ge "$limit" ]; then
177
+ echo "harness: an earlier cdk deploy of $stack outlived ${limit}s; stopping it" >&2
178
+ pkill -TERM -f -- "deploy $stack --app" || true
179
+ sleep 10
180
+ pkill -KILL -f -- "deploy $stack --app" || true
181
+ return 0
182
+ fi
183
+ echo "harness: an earlier cdk deploy of $stack is still running; waiting" >&2
184
+ sleep 15
185
+ waited=$((waited + 15))
186
+ done
187
+ }
188
+
189
+ # Run a command, stopping it after the given number of seconds. Portable, since
190
+ # macOS has no `timeout(1)`. The watchdog writes nowhere, so a caller's pipe
191
+ # (`| tee`) closes when the command does, not when the watchdog's sleep ends.
192
+ harness_run_bounded() {
193
+ local seconds="$1"
194
+ shift
195
+ "$@" &
196
+ local pid=$!
197
+ (sleep "$seconds" && kill -TERM "$pid") </dev/null >/dev/null 2>&1 &
198
+ local watchdog=$!
199
+ local status=0
200
+ wait "$pid" || status=$?
201
+ pkill -P "$watchdog" 2>/dev/null || true
202
+ kill "$watchdog" 2>/dev/null || true
203
+ if [ "$status" -eq 143 ]; then
204
+ echo "harness: stopped after ${seconds}s: $1" >&2
205
+ fi
206
+ return "$status"
207
+ }
208
+
209
+ # True when the named stack exists and carries the harness tag. Read-only, and
210
+ # the gate on every delete: nothing else in the account can be removed by these
211
+ # scripts even if a stack name is passed in by hand.
212
+ harness_stack_is_ours() {
213
+ local stack="$1"
214
+ local tags
215
+ tags="$(aws cloudformation describe-stacks --stack-name "$stack" \
216
+ --query "Stacks[0].Tags[?Key=='${HARNESS_TAG_KEY}'].Value" \
217
+ --output text 2>/dev/null)" || return 1
218
+ [ "$tags" = "$HARNESS_TAG_VALUE" ]
219
+ }
220
+
221
+ # The pid in a stack's proxy state file, if that process is still running and is
222
+ # still `stage-proxy.mjs`. A pid file outlives its proxy (a crash, a reboot), and
223
+ # the pid can by then belong to anything else of the user's, so nothing may
224
+ # signal or reuse it on the file's word alone.
225
+ harness_proxy_pid() {
226
+ local state pid
227
+ state="$(harness_proxy_state "$1")"
228
+ pid="$(cat "$state.pid" 2>/dev/null)" || return 1
229
+ case "$(ps -p "$pid" -o command= 2>/dev/null)" in
230
+ *stage-proxy.mjs*) printf '%s' "$pid" ;;
231
+ *) return 1 ;;
232
+ esac
233
+ }
234
+
235
+ # Stop a stack's `stage-proxy.mjs`, if one is running here. Called next to every
236
+ # stack delete, so a proxy never outlives the API it forwards to. A no-op for a
237
+ # Global stack, which never has one.
238
+ harness_stop_proxy() {
239
+ local state pid
240
+ state="$(harness_proxy_state "$1")"
241
+ if pid="$(harness_proxy_pid "$1")"; then
242
+ kill "$pid" 2>/dev/null || true
243
+ fi
244
+ rm -f "$state.pid" "$state.target"
245
+ }
246
+
247
+ # Validate HARNESS_NEXTJS_TYPE as soon as any harness script sources this file.
248
+ # Not in a subshell, so `harness_nextjs_type`'s `exit 1` ends the sourcing script.
249
+ harness_nextjs_type >/dev/null