@openhi/constructs 0.0.133 → 0.0.134

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,146 @@ 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
+ comment: "Copies Host into x-viewer-host and rewrites /config.json to the configured runtime-config path so the SPA can bootstrap same-origin against its own per-PR REST API.",
1729
+ code: FunctionCode.fromInline(
1730
+ [
1731
+ "function handler(event) {",
1732
+ " var request = event.request;",
1733
+ " if (request.headers.host && request.headers.host.value) {",
1734
+ " request.headers['x-viewer-host'] = { value: request.headers.host.value };",
1735
+ " }",
1736
+ ` request.uri = ${runtimeConfigPathLiteral};`,
1737
+ " return request;",
1738
+ "}"
1739
+ ].join("\n")
1740
+ )
1741
+ }
1742
+ );
1743
+ const hostCopyFunction = new CloudFrontFunction(
1744
+ this,
1745
+ "host-copy-function",
1746
+ {
1747
+ functionName: `static-hosting-host-copy-${branchHash}`,
1748
+ comment: "Copies Host into x-viewer-host so the origin-request Lambda@Edge can pick the matching per-PR API origin (Host is stripped by ALL_VIEWER_EXCEPT_HOST_HEADER before reaching the origin-request stage).",
1749
+ code: FunctionCode.fromInline(
1750
+ [
1751
+ "function handler(event) {",
1752
+ " var request = event.request;",
1753
+ " if (request.headers.host && request.headers.host.value) {",
1754
+ " request.headers['x-viewer-host'] = { value: request.headers.host.value };",
1755
+ " }",
1756
+ " return request;",
1757
+ "}"
1758
+ ].join("\n")
1759
+ )
1760
+ }
1761
+ );
1762
+ const originHandlerJs = path6.join(
1763
+ __dirname,
1764
+ "static-hosting.origin-request-handler.js"
1765
+ );
1766
+ const originHandlerTs = path6.join(
1767
+ __dirname,
1768
+ "static-hosting.origin-request-handler.ts"
1769
+ );
1770
+ const originHandlerEntry = fs6.existsSync(originHandlerJs) ? originHandlerJs : originHandlerTs;
1771
+ const originRequestHandler = new NodejsFunction6(
1772
+ this,
1773
+ "origin-request-handler",
1774
+ {
1775
+ entry: originHandlerEntry,
1776
+ handler: "originRequestHandler",
1777
+ memorySize: 128,
1778
+ runtime: Runtime6.NODEJS_LATEST,
1779
+ logGroup: new LogGroup(this, "origin-request-handler-log-group", {
1780
+ retention: RetentionDays.ONE_MONTH
1781
+ }),
1782
+ // Lambda@Edge forbids runtime env vars, so the host-prefix
1783
+ // mapping is inlined into the bundle at synth time via esbuild
1784
+ // `define`. The handler reads `process.env.VIEWER_HOST_PREFIX`
1785
+ // and `process.env.API_HOST_PREFIX` at module load; esbuild
1786
+ // replaces those identifiers with literal strings during
1787
+ // bundling so the shipped code carries no env-var lookups.
1788
+ bundling: {
1789
+ define: {
1790
+ "process.env.VIEWER_HOST_PREFIX": JSON.stringify(viewerHostPrefix),
1791
+ "process.env.API_HOST_PREFIX": JSON.stringify(apiHostPrefix)
1792
+ }
1793
+ }
1794
+ }
1795
+ );
1796
+ const originRequestEdgeLambda = {
1797
+ functionVersion: originRequestHandler.currentVersion,
1798
+ eventType: LambdaEdgeEventType.ORIGIN_REQUEST,
1799
+ includeBody: false
1800
+ };
1702
1801
  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
1802
+ configJsonRewriteFunction,
1803
+ hostCopyFunction,
1804
+ originRequestHandler,
1805
+ behaviors: {
1806
+ "/config.json": {
1807
+ origin: apiOrigin,
1808
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1809
+ allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
1810
+ cachePolicy: runtimeConfigCachePolicy,
1811
+ originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
1812
+ functionAssociations: [
1813
+ {
1814
+ function: configJsonRewriteFunction,
1815
+ eventType: FunctionEventType.VIEWER_REQUEST
1816
+ }
1817
+ ],
1818
+ edgeLambdas: [originRequestEdgeLambda]
1819
+ },
1820
+ "/api/control/runtime-config": {
1821
+ origin: apiOrigin,
1822
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1823
+ allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
1824
+ cachePolicy: runtimeConfigCachePolicy,
1825
+ originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
1826
+ functionAssociations: [
1827
+ {
1828
+ function: hostCopyFunction,
1829
+ eventType: FunctionEventType.VIEWER_REQUEST
1830
+ }
1831
+ ],
1832
+ edgeLambdas: [originRequestEdgeLambda]
1833
+ },
1834
+ "/api/*": {
1835
+ origin: apiOrigin,
1836
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1837
+ allowedMethods: AllowedMethods.ALLOW_ALL,
1838
+ cachePolicy: CachePolicy.CACHING_DISABLED,
1839
+ originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
1840
+ functionAssociations: [
1841
+ {
1842
+ function: hostCopyFunction,
1843
+ eventType: FunctionEventType.VIEWER_REQUEST
1844
+ }
1845
+ ],
1846
+ edgeLambdas: [originRequestEdgeLambda]
1847
+ }
1716
1848
  }
1717
1849
  };
1718
1850
  }
@@ -1766,7 +1898,7 @@ var PerBranchHostname = class extends Construct9 {
1766
1898
  distributionId
1767
1899
  }
1768
1900
  );
1769
- this.record = new ARecord2(this, "alias-record", {
1901
+ this.record = new ARecord2(this, `alias-record-${props.hostname}`, {
1770
1902
  zone: props.hostedZone,
1771
1903
  recordName: props.hostname,
1772
1904
  target: RecordTarget2.fromAlias(new CloudFrontTarget2(distribution))
@@ -3072,7 +3204,7 @@ var _OpenHiRestApiService = class _OpenHiRestApiService extends OpenHiService {
3072
3204
  integration
3073
3205
  });
3074
3206
  const apiPrefix = this.branchName === "main" ? `api` : `api-${this.childZonePrefix}`;
3075
- new ARecord3(this, "api-a-record", {
3207
+ new ARecord3(this, `api-a-record-${apiPrefix}`, {
3076
3208
  zone: hostedZone,
3077
3209
  recordName: apiPrefix,
3078
3210
  target: RecordTarget3.fromAlias(
@@ -3208,7 +3340,6 @@ var OpenHiGraphqlService = _OpenHiGraphqlService;
3208
3340
  // src/services/open-hi-website-service.ts
3209
3341
  var import_config5 = __toESM(require_lib2());
3210
3342
  import { Bucket as Bucket3 } from "aws-cdk-lib/aws-s3";
3211
- var OPENHI_PR_NUMBER_ENV_VAR = "OPENHI_PR_NUMBER";
3212
3343
  var SSM_PARAM_NAME_FULL_DOMAIN = "WEBSITE_FULL_DOMAIN";
3213
3344
  var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3214
3345
  /**
@@ -3269,12 +3400,6 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3269
3400
  this.props = props;
3270
3401
  this.validateConfig(props);
3271
3402
  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
3403
  const hostedZone = this.createHostedZone();
3279
3404
  this.fullDomain = this.computeFullDomain(hostedZone);
3280
3405
  const shouldCreateHostingInfra = props.createHostingInfrastructure ?? isReleaseBranch;
@@ -3329,32 +3454,12 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3329
3454
  return OpenHiGlobalService.rootWildcardCertificateFromConstruct(this);
3330
3455
  }
3331
3456
  /**
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`).
3457
+ * Computes the full website domain from `domainPrefix`,
3458
+ * `childZonePrefix`, and the child zone name. Release-branch deploys
3459
+ * serve at `\<domainPrefix\>.\<zone\>` (e.g. `admin.dev.openhi.org`);
3460
+ * every other deploy serves a per-PR preview at
3461
+ * `\<domainPrefix\>-\<childZonePrefix\>.\<zone\>`
3462
+ * (e.g. `admin-feat-1093-patient-migration.dev.openhi.org`).
3358
3463
  */
3359
3464
  computeFullDomain(hostedZone) {
3360
3465
  const subDomain = this.computeSubDomain();
@@ -3366,16 +3471,20 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3366
3471
  * key prefix passed to {@link StaticContent} so the upload prefix
3367
3472
  * always matches the served hostname.
3368
3473
  *
3369
- * Non-release deploys compose the per-PR slug from
3370
- * {@link PER_BRANCH_PREVIEW_PREFIX} so the per-PR S3 key prefix
3474
+ * Non-release deploys compose the per-PR slug as
3475
+ * `\<domainPrefix\>-\<childZonePrefix\>`, mirroring the REST API's
3476
+ * `api-\<childZonePrefix\>` convention. When `domainPrefix` is `admin`
3477
+ * (the only consumer today), the resulting sub-domain starts with
3478
+ * {@link PER_BRANCH_PREVIEW_PREFIX}, so the per-PR S3 key prefix
3371
3479
  * matches what `StaticHosting`'s lifecycle rule expires.
3372
3480
  */
3373
3481
  computeSubDomain() {
3374
3482
  const isReleaseBranch = this.branchName === this.defaultReleaseBranch;
3483
+ const domainPrefix = this.props.domainPrefix ?? "www";
3375
3484
  if (isReleaseBranch) {
3376
- return this.props.domainPrefix ?? "www";
3485
+ return domainPrefix;
3377
3486
  }
3378
- return `${PER_BRANCH_PREVIEW_PREFIX}${this.prNumber}`;
3487
+ return `${domainPrefix}-${this.childZonePrefix}`;
3379
3488
  }
3380
3489
  /**
3381
3490
  * Creates the StaticHosting infrastructure (bucket + distribution +
@@ -3436,8 +3545,9 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3436
3545
  * The S3 key prefix is `\<sub-domain\>.\<zone\>/\<contentDest\>` so the
3437
3546
  * upload location matches the Host-header-derived folder the Lambda@Edge
3438
3547
  * 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/`.
3548
+ * `this.fullDomain`) for the `fullDomain` prop keeps the prefix flat
3549
+ * `admin-feat-foo.dev.openhi.org/`, not
3550
+ * `admin-feat-foo.admin.dev.openhi.org/`.
3441
3551
  */
3442
3552
  createStaticContent(bucket) {
3443
3553
  const { contentSourceDirectory, contentDestinationDirectory } = this.props;
@@ -3451,9 +3561,10 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
3451
3561
  }
3452
3562
  /**
3453
3563
  * 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}).
3564
+ * branch deploys. The record points
3565
+ * `\<domainPrefix\>-\<childZonePrefix\>.\<zone\>` at the release-branch
3566
+ * CloudFront distribution (resolved from SSM against
3567
+ * {@link OpenHiService.releaseBranchHash}).
3457
3568
  */
3458
3569
  createPerBranchHostname(hostedZone) {
3459
3570
  return new PerBranchHostname(this, "per-branch-hostname", {
@@ -4029,7 +4140,6 @@ export {
4029
4140
  DataStorePostgresReplica,
4030
4141
  DiscoverableStringParameter,
4031
4142
  DynamoDbDataStore,
4032
- OPENHI_PR_NUMBER_ENV_VAR,
4033
4143
  OPENHI_REPO_TAG_KEY_ENV_VAR,
4034
4144
  OPENHI_RESOURCE_URN_SYSTEM,
4035
4145
  OPENHI_TAG_KEY_PREFIX_ENV_VAR,