@openhi/constructs 0.0.133 → 0.0.135

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.
package/lib/index.mjs CHANGED
@@ -460,10 +460,14 @@ var OpenHiService = class extends Stack {
460
460
  );
461
461
  }
462
462
  /**
463
- * DNS prefix for this branche's child zone.
463
+ * DNS prefix for this branche's child zone. Capped at 56 chars so
464
+ * that a `<service>-<prefix>` hostname segment stays under the 63-byte
465
+ * DNS label limit even for the longest current prefix (`admin-`, 6
466
+ * bytes; the matching API uses `api-`, 4 bytes). 56 leaves 1 byte of
467
+ * headroom on the longer side.
464
468
  */
465
469
  get childZonePrefix() {
466
- return paramCase(this.branchName).slice(0, 200);
470
+ return paramCase(this.branchName).slice(0, 56);
467
471
  }
468
472
  };
469
473
 
@@ -1507,6 +1511,9 @@ import {
1507
1511
  CachePolicy,
1508
1512
  CacheQueryStringBehavior,
1509
1513
  Distribution,
1514
+ Function as CloudFrontFunction,
1515
+ FunctionCode,
1516
+ FunctionEventType,
1510
1517
  LambdaEdgeEventType,
1511
1518
  OriginProtocolPolicy,
1512
1519
  OriginRequestPolicy,
@@ -1526,7 +1533,7 @@ import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
1526
1533
  import { Bucket as Bucket2 } from "aws-cdk-lib/aws-s3";
1527
1534
  import { Construct as Construct8 } from "constructs";
1528
1535
  var STATIC_HOSTING_SERVICE_TYPE = "website";
1529
- var PER_BRANCH_PREVIEW_PREFIX = "admin-pr-";
1536
+ var PER_BRANCH_PREVIEW_PREFIX = "admin-";
1530
1537
  var DEFAULT_PREVIEW_EXPIRATION_DAYS = 14;
1531
1538
  var _StaticHosting = class _StaticHosting extends Construct8 {
1532
1539
  /**
@@ -1605,10 +1612,14 @@ var _StaticHosting = class _StaticHosting extends Construct8 {
1605
1612
  originAccessLevels: [AccessLevel.READ]
1606
1613
  });
1607
1614
  const hasCustomDomain = props.certificate !== void 0 && props.hostedZone !== void 0 && props.domainNames !== void 0 && props.domainNames.length > 0;
1608
- const additionalBehaviors = this.buildRestApiBehaviors(
1615
+ const restApiWiring = this.buildRestApiBehaviors(
1609
1616
  stack.branchHash,
1610
1617
  props.restApi
1611
1618
  );
1619
+ const additionalBehaviors = restApiWiring?.behaviors;
1620
+ this.configJsonRewriteFunction = restApiWiring?.configJsonRewriteFunction;
1621
+ this.hostCopyFunction = restApiWiring?.hostCopyFunction;
1622
+ this.originRequestHandler = restApiWiring?.originRequestHandler;
1612
1623
  this.distribution = new Distribution(this, "distribution", {
1613
1624
  comment: `Static hosting distribution for ${props.description ?? id}`,
1614
1625
  ...hasCustomDomain ? {
@@ -1672,14 +1683,20 @@ var _StaticHosting = class _StaticHosting extends Construct8 {
1672
1683
  });
1673
1684
  }
1674
1685
  /**
1675
- * Builds the `/api/*` and `/api/control/runtime-config` behaviors backed
1676
- * by the REST API custom-domain origin. Returns `undefined` when no
1677
- * `restApi` prop is supplied so the Distribution stays S3-only.
1686
+ * Builds the `/config.json`, `/api/*`, and `/api/control/runtime-config`
1687
+ * behaviors backed by the REST API custom-domain origin, plus the
1688
+ * viewer-request CloudFront Functions and the origin-request
1689
+ * Lambda@Edge that route each request to the matching per-PR API
1690
+ * origin. Returns `undefined` when no `restApi` prop is supplied so
1691
+ * the Distribution stays S3-only.
1678
1692
  */
1679
1693
  buildRestApiBehaviors(branchHash, restApi) {
1680
1694
  if (restApi === void 0) {
1681
1695
  return void 0;
1682
1696
  }
1697
+ const runtimeConfigPath = restApi.runtimeConfigPath ?? "/control/runtime-config";
1698
+ const viewerHostPrefix = restApi.hostMapping?.viewerPrefix ?? "admin";
1699
+ const apiHostPrefix = restApi.hostMapping?.apiPrefix ?? "api";
1683
1700
  const apiOrigin = new HttpOrigin(restApi.domainName, {
1684
1701
  protocolPolicy: OriginProtocolPolicy.HTTPS_ONLY
1685
1702
  });
@@ -1688,31 +1705,150 @@ var _StaticHosting = class _StaticHosting extends Construct8 {
1688
1705
  "runtime-config-cache-policy",
1689
1706
  {
1690
1707
  cachePolicyName: `static-hosting-runtime-config-${branchHash}`,
1691
- comment: "/api/control/runtime-config: cache key includes only `v` so the bundle's deploy-hash bust works automatically.",
1708
+ comment: "/config.json: cache key keyed on Host + `v` so per-PR responses cannot leak across hosts.",
1692
1709
  defaultTtl: restApi.runtimeConfigCacheTtl?.defaultTtl ?? Duration5.minutes(5),
1693
1710
  minTtl: Duration5.seconds(0),
1694
1711
  maxTtl: restApi.runtimeConfigCacheTtl?.maxTtl ?? Duration5.hours(1),
1695
- headerBehavior: CacheHeaderBehavior.none(),
1712
+ // `Host` keys the cache per-PR — the origin-request edge Lambda
1713
+ // forwards each PR's request to its own API origin, and two
1714
+ // PRs' /config.json payloads must not share a cache slot.
1715
+ headerBehavior: CacheHeaderBehavior.allowList("Host"),
1696
1716
  queryStringBehavior: CacheQueryStringBehavior.allowList("v"),
1697
1717
  cookieBehavior: CacheCookieBehavior.none(),
1698
1718
  enableAcceptEncodingGzip: true,
1699
1719
  enableAcceptEncodingBrotli: true
1700
1720
  }
1701
1721
  );
1722
+ const runtimeConfigPathLiteral = JSON.stringify(runtimeConfigPath);
1723
+ const configJsonRewriteFunction = new CloudFrontFunction(
1724
+ this,
1725
+ "config-json-rewrite-function",
1726
+ {
1727
+ functionName: `static-hosting-config-json-rewrite-${branchHash}`,
1728
+ // CloudFront caps `Comment` at 128 chars; the full rationale is in
1729
+ // the preceding code comment.
1730
+ comment: "Rewrites /config.json to the runtime-config path; copies Host into x-viewer-host.",
1731
+ code: FunctionCode.fromInline(
1732
+ [
1733
+ "function handler(event) {",
1734
+ " var request = event.request;",
1735
+ " if (request.headers.host && request.headers.host.value) {",
1736
+ " request.headers['x-viewer-host'] = { value: request.headers.host.value };",
1737
+ " }",
1738
+ ` request.uri = ${runtimeConfigPathLiteral};`,
1739
+ " return request;",
1740
+ "}"
1741
+ ].join("\n")
1742
+ )
1743
+ }
1744
+ );
1745
+ const hostCopyFunction = new CloudFrontFunction(
1746
+ this,
1747
+ "host-copy-function",
1748
+ {
1749
+ functionName: `static-hosting-host-copy-${branchHash}`,
1750
+ // CloudFront caps `Comment` at 128 chars; the full rationale is in
1751
+ // the preceding code comment.
1752
+ comment: "Copies viewer Host into x-viewer-host for the origin-request Lambda@Edge.",
1753
+ code: FunctionCode.fromInline(
1754
+ [
1755
+ "function handler(event) {",
1756
+ " var request = event.request;",
1757
+ " if (request.headers.host && request.headers.host.value) {",
1758
+ " request.headers['x-viewer-host'] = { value: request.headers.host.value };",
1759
+ " }",
1760
+ " return request;",
1761
+ "}"
1762
+ ].join("\n")
1763
+ )
1764
+ }
1765
+ );
1766
+ const originHandlerJs = path6.join(
1767
+ __dirname,
1768
+ "static-hosting.origin-request-handler.js"
1769
+ );
1770
+ const originHandlerTs = path6.join(
1771
+ __dirname,
1772
+ "static-hosting.origin-request-handler.ts"
1773
+ );
1774
+ const originHandlerEntry = fs6.existsSync(originHandlerJs) ? originHandlerJs : originHandlerTs;
1775
+ const originRequestHandler = new NodejsFunction6(
1776
+ this,
1777
+ "origin-request-handler",
1778
+ {
1779
+ entry: originHandlerEntry,
1780
+ handler: "originRequestHandler",
1781
+ memorySize: 128,
1782
+ runtime: Runtime6.NODEJS_LATEST,
1783
+ logGroup: new LogGroup(this, "origin-request-handler-log-group", {
1784
+ retention: RetentionDays.ONE_MONTH
1785
+ }),
1786
+ // Lambda@Edge forbids runtime env vars, so the host-prefix
1787
+ // mapping is inlined into the bundle at synth time via esbuild
1788
+ // `define`. The handler reads `process.env.VIEWER_HOST_PREFIX`
1789
+ // and `process.env.API_HOST_PREFIX` at module load; esbuild
1790
+ // replaces those identifiers with literal strings during
1791
+ // bundling so the shipped code carries no env-var lookups.
1792
+ bundling: {
1793
+ define: {
1794
+ "process.env.VIEWER_HOST_PREFIX": JSON.stringify(viewerHostPrefix),
1795
+ "process.env.API_HOST_PREFIX": JSON.stringify(apiHostPrefix)
1796
+ }
1797
+ }
1798
+ }
1799
+ );
1800
+ const originRequestEdgeLambda = {
1801
+ functionVersion: originRequestHandler.currentVersion,
1802
+ eventType: LambdaEdgeEventType.ORIGIN_REQUEST,
1803
+ includeBody: false
1804
+ };
1702
1805
  return {
1703
- "/api/control/runtime-config": {
1704
- origin: apiOrigin,
1705
- viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1706
- allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
1707
- cachePolicy: runtimeConfigCachePolicy,
1708
- originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER
1709
- },
1710
- "/api/*": {
1711
- origin: apiOrigin,
1712
- viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1713
- allowedMethods: AllowedMethods.ALLOW_ALL,
1714
- cachePolicy: CachePolicy.CACHING_DISABLED,
1715
- originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER
1806
+ configJsonRewriteFunction,
1807
+ hostCopyFunction,
1808
+ originRequestHandler,
1809
+ behaviors: {
1810
+ "/config.json": {
1811
+ origin: apiOrigin,
1812
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1813
+ allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
1814
+ cachePolicy: runtimeConfigCachePolicy,
1815
+ originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
1816
+ functionAssociations: [
1817
+ {
1818
+ function: configJsonRewriteFunction,
1819
+ eventType: FunctionEventType.VIEWER_REQUEST
1820
+ }
1821
+ ],
1822
+ edgeLambdas: [originRequestEdgeLambda]
1823
+ },
1824
+ "/api/control/runtime-config": {
1825
+ origin: apiOrigin,
1826
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1827
+ allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
1828
+ cachePolicy: runtimeConfigCachePolicy,
1829
+ originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
1830
+ functionAssociations: [
1831
+ {
1832
+ function: hostCopyFunction,
1833
+ eventType: FunctionEventType.VIEWER_REQUEST
1834
+ }
1835
+ ],
1836
+ edgeLambdas: [originRequestEdgeLambda]
1837
+ },
1838
+ "/api/*": {
1839
+ origin: apiOrigin,
1840
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1841
+ allowedMethods: AllowedMethods.ALLOW_ALL,
1842
+ cachePolicy: CachePolicy.CACHING_DISABLED,
1843
+ originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
1844
+ functionAssociations: [
1845
+ {
1846
+ function: hostCopyFunction,
1847
+ eventType: FunctionEventType.VIEWER_REQUEST
1848
+ }
1849
+ ],
1850
+ edgeLambdas: [originRequestEdgeLambda]
1851
+ }
1716
1852
  }
1717
1853
  };
1718
1854
  }
@@ -1766,7 +1902,7 @@ var PerBranchHostname = class extends Construct9 {
1766
1902
  distributionId
1767
1903
  }
1768
1904
  );
1769
- this.record = new ARecord2(this, "alias-record", {
1905
+ this.record = new ARecord2(this, `alias-record-${props.hostname}`, {
1770
1906
  zone: props.hostedZone,
1771
1907
  recordName: props.hostname,
1772
1908
  target: RecordTarget2.fromAlias(new CloudFrontTarget2(distribution))
@@ -3072,7 +3208,7 @@ var _OpenHiRestApiService = class _OpenHiRestApiService extends OpenHiService {
3072
3208
  integration
3073
3209
  });
3074
3210
  const apiPrefix = this.branchName === "main" ? `api` : `api-${this.childZonePrefix}`;
3075
- new ARecord3(this, "api-a-record", {
3211
+ new ARecord3(this, `api-a-record-${apiPrefix}`, {
3076
3212
  zone: hostedZone,
3077
3213
  recordName: apiPrefix,
3078
3214
  target: RecordTarget3.fromAlias(
@@ -3208,7 +3344,6 @@ var OpenHiGraphqlService = _OpenHiGraphqlService;
3208
3344
  // src/services/open-hi-website-service.ts
3209
3345
  var import_config5 = __toESM(require_lib2());
3210
3346
  import { Bucket as Bucket3 } from "aws-cdk-lib/aws-s3";
3211
- var OPENHI_PR_NUMBER_ENV_VAR = "OPENHI_PR_NUMBER";
3212
3347
  var SSM_PARAM_NAME_FULL_DOMAIN = "WEBSITE_FULL_DOMAIN";
3213
3348
  var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3214
3349
  /**
@@ -3269,12 +3404,6 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3269
3404
  this.props = props;
3270
3405
  this.validateConfig(props);
3271
3406
  const isReleaseBranch = this.branchName === this.defaultReleaseBranch;
3272
- this.prNumber = this.resolvePrNumber(props);
3273
- if (!isReleaseBranch && this.prNumber === void 0) {
3274
- throw new Error(
3275
- `OpenHiWebsiteService: prNumber is required on non-release-branch deploys (branchName="${this.branchName}", defaultReleaseBranch="${this.defaultReleaseBranch}"). Pass the \`prNumber\` prop or set the ${OPENHI_PR_NUMBER_ENV_VAR} env var.`
3276
- );
3277
- }
3278
3407
  const hostedZone = this.createHostedZone();
3279
3408
  this.fullDomain = this.computeFullDomain(hostedZone);
3280
3409
  const shouldCreateHostingInfra = props.createHostingInfrastructure ?? isReleaseBranch;
@@ -3329,32 +3458,12 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3329
3458
  return OpenHiGlobalService.rootWildcardCertificateFromConstruct(this);
3330
3459
  }
3331
3460
  /**
3332
- * Resolves the PR number from props or the `OPENHI_PR_NUMBER` env var.
3333
- * Returns `undefined` on release-branch deploys where no PR number is
3334
- * needed.
3335
- */
3336
- resolvePrNumber(props) {
3337
- if (props.prNumber !== void 0) {
3338
- return props.prNumber;
3339
- }
3340
- const raw = process.env[OPENHI_PR_NUMBER_ENV_VAR]?.trim();
3341
- if (!raw) {
3342
- return void 0;
3343
- }
3344
- const parsed = Number.parseInt(raw, 10);
3345
- if (!Number.isInteger(parsed) || parsed <= 0) {
3346
- throw new Error(
3347
- `${OPENHI_PR_NUMBER_ENV_VAR} must be a positive integer; got "${raw}".`
3348
- );
3349
- }
3350
- return parsed;
3351
- }
3352
- /**
3353
- * Computes the full website domain from `domainPrefix`, the PR number,
3354
- * and the child zone name. Release-branch deploys serve at
3355
- * `\<domainPrefix\>.\<zone\>` (e.g. `admin.dev.openhi.org`); every other
3356
- * deploy serves a per-PR preview at `\<domainPrefix\>-pr-\<N\>.\<zone\>`
3357
- * (e.g. `admin-pr-123.dev.openhi.org`).
3461
+ * Computes the full website domain from `domainPrefix`,
3462
+ * `childZonePrefix`, and the child zone name. Release-branch deploys
3463
+ * serve at `\<domainPrefix\>.\<zone\>` (e.g. `admin.dev.openhi.org`);
3464
+ * every other deploy serves a per-PR preview at
3465
+ * `\<domainPrefix\>-\<childZonePrefix\>.\<zone\>`
3466
+ * (e.g. `admin-feat-1093-patient-migration.dev.openhi.org`).
3358
3467
  */
3359
3468
  computeFullDomain(hostedZone) {
3360
3469
  const subDomain = this.computeSubDomain();
@@ -3366,16 +3475,20 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3366
3475
  * key prefix passed to {@link StaticContent} so the upload prefix
3367
3476
  * always matches the served hostname.
3368
3477
  *
3369
- * Non-release deploys compose the per-PR slug from
3370
- * {@link PER_BRANCH_PREVIEW_PREFIX} so the per-PR S3 key prefix
3478
+ * Non-release deploys compose the per-PR slug as
3479
+ * `\<domainPrefix\>-\<childZonePrefix\>`, mirroring the REST API's
3480
+ * `api-\<childZonePrefix\>` convention. When `domainPrefix` is `admin`
3481
+ * (the only consumer today), the resulting sub-domain starts with
3482
+ * {@link PER_BRANCH_PREVIEW_PREFIX}, so the per-PR S3 key prefix
3371
3483
  * matches what `StaticHosting`'s lifecycle rule expires.
3372
3484
  */
3373
3485
  computeSubDomain() {
3374
3486
  const isReleaseBranch = this.branchName === this.defaultReleaseBranch;
3487
+ const domainPrefix = this.props.domainPrefix ?? "www";
3375
3488
  if (isReleaseBranch) {
3376
- return this.props.domainPrefix ?? "www";
3489
+ return domainPrefix;
3377
3490
  }
3378
- return `${PER_BRANCH_PREVIEW_PREFIX}${this.prNumber}`;
3491
+ return `${domainPrefix}-${this.childZonePrefix}`;
3379
3492
  }
3380
3493
  /**
3381
3494
  * Creates the StaticHosting infrastructure (bucket + distribution +
@@ -3436,8 +3549,9 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3436
3549
  * The S3 key prefix is `\<sub-domain\>.\<zone\>/\<contentDest\>` so the
3437
3550
  * upload location matches the Host-header-derived folder the Lambda@Edge
3438
3551
  * viewer-request handler prepends. Passing the zone name (rather than
3439
- * `this.fullDomain`) for the `fullDomain` prop keeps the prefix flat
3440
- * `admin-pr-123.dev.openhi.org/`, not `admin-pr-123.admin.dev.openhi.org/`.
3552
+ * `this.fullDomain`) for the `fullDomain` prop keeps the prefix flat
3553
+ * `admin-feat-foo.dev.openhi.org/`, not
3554
+ * `admin-feat-foo.admin.dev.openhi.org/`.
3441
3555
  */
3442
3556
  createStaticContent(bucket) {
3443
3557
  const { contentSourceDirectory, contentDestinationDirectory } = this.props;
@@ -3451,9 +3565,10 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3451
3565
  }
3452
3566
  /**
3453
3567
  * Creates the per-PR `PerBranchHostname` alias record on non-release
3454
- * branch deploys. The record points `\<domainPrefix\>-pr-\<N\>.\<zone\>`
3455
- * at the release-branch CloudFront distribution (resolved from SSM
3456
- * against {@link OpenHiService.releaseBranchHash}).
3568
+ * branch deploys. The record points
3569
+ * `\<domainPrefix\>-\<childZonePrefix\>.\<zone\>` at the release-branch
3570
+ * CloudFront distribution (resolved from SSM against
3571
+ * {@link OpenHiService.releaseBranchHash}).
3457
3572
  */
3458
3573
  createPerBranchHostname(hostedZone) {
3459
3574
  return new PerBranchHostname(this, "per-branch-hostname", {
@@ -4029,7 +4144,6 @@ export {
4029
4144
  DataStorePostgresReplica,
4030
4145
  DiscoverableStringParameter,
4031
4146
  DynamoDbDataStore,
4032
- OPENHI_PR_NUMBER_ENV_VAR,
4033
4147
  OPENHI_REPO_TAG_KEY_ENV_VAR,
4034
4148
  OPENHI_RESOURCE_URN_SYSTEM,
4035
4149
  OPENHI_TAG_KEY_PREFIX_ENV_VAR,