@anthusai/papyrus 1.0.0-next.47 → 1.0.0-next.49

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 (36) hide show
  1. package/amplify/functions/content-actions/handler.py +23 -1
  2. package/amplify/functions/renditions/handler.py +35 -9
  3. package/amplify/publication/default-publication.js +18 -0
  4. package/amplify/publication/default-publication.ts +20 -0
  5. package/amplify/publication/invalidation-alarm.js +26 -0
  6. package/amplify/publication/invalidation-alarm.ts +30 -0
  7. package/amplify/publication/renditions-settings.js +11 -3
  8. package/amplify/publication/renditions-settings.ts +11 -3
  9. package/amplify/publication/reproject-on-deploy.js +31 -0
  10. package/amplify/publication/reproject-on-deploy.ts +36 -0
  11. package/amplify/publication/s3-projection-settings.js +5 -1
  12. package/amplify/publication/s3-projection-settings.ts +4 -1
  13. package/amplify/publication/s3-projection.js +8 -1
  14. package/amplify/publication/s3-projection.ts +8 -1
  15. package/amplify/revalidate-invalidation-settings.js +30 -0
  16. package/amplify/revalidate-invalidation-settings.ts +35 -0
  17. package/amplify/site-backend-config.js +14 -0
  18. package/amplify/site-backend-config.ts +41 -2
  19. package/amplify/site-backend.js +31 -6
  20. package/amplify/site-backend.ts +36 -5
  21. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +1 -1
  22. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +1 -1
  23. package/app/[year]/[month]/[day]/page.tsx +1 -1
  24. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +1 -1
  25. package/app/api/archive/editions/route.ts +1 -1
  26. package/app/archive/page.tsx +1 -1
  27. package/app/articles/[slug]/page.tsx +1 -1
  28. package/infra/build-specs.js +6 -3
  29. package/infra/build-specs.ts +8 -4
  30. package/infra/site-config.js +20 -3
  31. package/infra/site-config.ts +31 -3
  32. package/lib/reader-cache-lifetime.ts +5 -0
  33. package/package.json +2 -1
  34. package/routes.manifest.json +8 -8
  35. package/src/with-papyrus.d.mts +1 -0
  36. package/src/with-papyrus.mjs +8 -0
@@ -4,9 +4,10 @@ import json
4
4
  import os
5
5
  from typing import Any
6
6
 
7
+ from papyrus_content.edge_invalidation import distribution_ids_from_environment, invalidate_revalidated_paths
7
8
  from papyrus_content.graphql_authoring import create_authoring_client
8
9
  from papyrus_content.markus_renderer.derive import derive_body
9
- from papyrus_content.projection.engine import project, project_item_pages
10
+ from papyrus_content.projection.engine import project, project_all, project_item_pages
10
11
  from papyrus_content.projection.runtime import (
11
12
  DEAD_LETTER_QUEUE_ENV,
12
13
  context_from_environment,
@@ -133,11 +134,18 @@ def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
133
134
  message = "; ".join(report.errors)
134
135
  schedule_projection_task(task, message)
135
136
  response["error"] = message
137
+ if report.unpurged:
138
+ response["edgePurged"] = False
139
+ schedule_unpurged_invalidation(report.unpurged)
136
140
  if report.follow_up_item_pages:
137
141
  schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
138
142
  return response
139
143
 
140
144
 
145
+ def schedule_unpurged_invalidation(keys: list[str]) -> None:
146
+ schedule_projection_task({"task": "invalidate", "keys": keys}, "edge invalidation failed")
147
+
148
+
141
149
  def run_projection_task(task: dict) -> dict:
142
150
  """An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
143
151
  client, _claims = create_authoring_client()
@@ -147,8 +155,19 @@ def run_projection_task(task: dict) -> dict:
147
155
  report = project(projection_context(client), task["itemId"], unpublished=bool(task.get("unpublished")))
148
156
  if report.follow_up_item_pages:
149
157
  schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
158
+ elif task.get("task") == "all":
159
+ report = project_all(projection_context(client))
160
+ elif task.get("task") == "all":
161
+ report = project_all(projection_context(client))
162
+ elif task.get("task") == "invalidate":
163
+ invalidate = projection_context(client).invalidate
164
+ if invalidate is None:
165
+ return {"ok": True, "paths": []}
166
+ return {"ok": True, "paths": invalidate(list(task["keys"]))}
150
167
  else:
151
168
  raise ProjectionTaskFailed(f"Unknown projection task {task.get('task')!r}")
169
+ if report.unpurged:
170
+ schedule_unpurged_invalidation(report.unpurged)
152
171
  if not report.ok:
153
172
  raise ProjectionTaskFailed("; ".join(report.errors))
154
173
  return {"ok": True, "paths": [*report.written, *report.deleted]}
@@ -193,6 +212,9 @@ def site_triggers_for(client, item_id: str) -> dict:
193
212
  slugs = [item["slug"]] if item.get("slug") else []
194
213
  revalidated = trigger_pretext_revalidation(slugs, None, base_url=revalidate_base_url)
195
214
  triggers["revalidated"] = revalidated if revalidated is not None else {"ok": False, "reason": "not-configured"}
215
+ distribution_ids = distribution_ids_from_environment(os.environ)
216
+ if distribution_ids and revalidated is not None:
217
+ triggers["edgeInvalidated"] = invalidate_revalidated_paths(distribution_ids, revalidated)
196
218
  except Exception as error: # noqa: BLE001 - a trigger failure never fails the publish
197
219
  triggers["revalidated"] = {"ok": False, "error": f"{type(error).__name__}: {error}"}
198
220
  if reader_app_id:
@@ -11,7 +11,10 @@ from papyrus_content.renditions import (
11
11
  RenditionSettings,
12
12
  S3RenditionStore,
13
13
  make_renditions,
14
+ media_originals_enabled,
14
15
  meta_key,
16
+ original_site_key,
17
+ ORIGINAL_CACHE_CONTROL,
15
18
  settings_from_static_directory,
16
19
  source_from_media_key,
17
20
  )
@@ -26,6 +29,13 @@ def configured_settings() -> RenditionSettings | None:
26
29
  return _loaded["settings"]
27
30
 
28
31
 
32
+ def originals_enabled() -> bool:
33
+ if "originals" not in _loaded:
34
+ static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
35
+ _loaded["originals"] = media_originals_enabled(Path(static_directory) if static_directory else None)
36
+ return _loaded["originals"]
37
+
38
+
29
39
  def site_store() -> S3RenditionStore:
30
40
  if "store" not in _loaded:
31
41
  _loaded["store"] = S3RenditionStore(os.environ[SITE_BUCKET_ENV])
@@ -50,18 +60,32 @@ def media_objects(event: dict) -> list[tuple[str, str]]:
50
60
 
51
61
 
52
62
  def render_media_object(
53
- settings: RenditionSettings, bucket: str, key: str, *, skip_existing: bool = False, dry_run: bool = False
63
+ settings: RenditionSettings | None,
64
+ bucket: str,
65
+ key: str,
66
+ *,
67
+ originals: bool = False,
68
+ skip_existing: bool = False,
69
+ dry_run: bool = False,
54
70
  ) -> dict:
55
- source = source_from_media_key(key)
56
- if source is None:
71
+ source = source_from_media_key(key) if settings is not None else None
72
+ original_key = original_site_key(key) if originals else None
73
+ if source is None and original_key is None:
57
74
  return {"key": key, "action": "ignored", "reason": "not an image under media/"}
58
- if skip_existing and site_store().get(meta_key(settings.pipeline, source)) is not None:
59
- return {"key": key, "source": source, "action": "skipped-existing"}
75
+ if skip_existing:
76
+ renditions_exist = source is None or site_store().get(meta_key(settings.pipeline, source)) is not None
77
+ original_exists = original_key is None or site_store().get(original_key) is not None
78
+ if renditions_exist and original_exists:
79
+ return {"key": key, "source": source or original_key, "action": "skipped-existing"}
60
80
  if dry_run:
61
- return {"key": key, "source": source, "action": "would-render"}
81
+ return {"key": key, "source": source or original_key, "action": "would-render" if source else "would-copy"}
62
82
  started = time.perf_counter()
63
83
  data = s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
64
84
  downloaded = time.perf_counter()
85
+ if original_key is not None:
86
+ site_store().put(original_key, data, cache_control=ORIGINAL_CACHE_CONTROL)
87
+ if source is None:
88
+ return {"key": key, "source": original_key, "action": "copied", "written": 1, "sourceBytes": len(data)}
65
89
  result = make_renditions(settings, site_store(), source, data)
66
90
  finished = time.perf_counter()
67
91
  return {
@@ -69,7 +93,7 @@ def render_media_object(
69
93
  "source": source,
70
94
  "action": "rendered",
71
95
  "kind": result["kind"],
72
- "written": result["written"],
96
+ "written": result["written"] + (1 if original_key is not None else 0),
73
97
  "sourceBytes": len(data),
74
98
  "downloadMilliseconds": round((downloaded - started) * 1000),
75
99
  "renditionMilliseconds": round((finished - downloaded) * 1000),
@@ -78,13 +102,15 @@ def render_media_object(
78
102
 
79
103
  def handler(event, context):
80
104
  settings = configured_settings()
81
- if settings is None:
82
- return {"ok": True, "results": [], "skipped": "this site has no images settings in projection.json"}
105
+ originals = originals_enabled()
106
+ if settings is None and not originals:
107
+ return {"ok": True, "results": [], "skipped": "this site has no images or media settings in projection.json"}
83
108
  results = [
84
109
  render_media_object(
85
110
  settings,
86
111
  bucket,
87
112
  key,
113
+ originals=originals,
88
114
  skip_existing=event.get("skipExisting") is True,
89
115
  dry_run=event.get("dryRun") is True,
90
116
  )
@@ -0,0 +1,18 @@
1
+ export const DEFAULT_MARKUS_PUBLICATION = { kind: "s3-projection", reprojectOnDeploy: true };
2
+ /**
3
+ * The publication a site backend is built with. A Markus site that names a `reader` Amplify app
4
+ * keeps publishing through it (`amplify-static`); every other Markus site gets the s3-projection
5
+ * default unless it writes `publication: false`. Sites with another renderer get nothing unless
6
+ * they write a `publication`.
7
+ */
8
+ export function resolveSitePublication(backend, rendererKind) {
9
+ if (backend.publication === false)
10
+ return undefined;
11
+ if (backend.publication !== undefined)
12
+ return backend.publication;
13
+ if (rendererKind !== "markus")
14
+ return undefined;
15
+ if (backend.reader !== undefined)
16
+ return undefined;
17
+ return DEFAULT_MARKUS_PUBLICATION;
18
+ }
@@ -0,0 +1,20 @@
1
+ import type { PapyrusPublicationConfig, PapyrusSiteBackendConfig } from "../site-backend-config";
2
+
3
+ export const DEFAULT_MARKUS_PUBLICATION: PapyrusPublicationConfig = { kind: "s3-projection", reprojectOnDeploy: true };
4
+
5
+ /**
6
+ * The publication a site backend is built with. A Markus site that names a `reader` Amplify app
7
+ * keeps publishing through it (`amplify-static`); every other Markus site gets the s3-projection
8
+ * default unless it writes `publication: false`. Sites with another renderer get nothing unless
9
+ * they write a `publication`.
10
+ */
11
+ export function resolveSitePublication(
12
+ backend: Pick<PapyrusSiteBackendConfig, "publication" | "reader">,
13
+ rendererKind: string | undefined,
14
+ ): PapyrusPublicationConfig | undefined {
15
+ if (backend.publication === false) return undefined;
16
+ if (backend.publication !== undefined) return backend.publication;
17
+ if (rendererKind !== "markus") return undefined;
18
+ if (backend.reader !== undefined) return undefined;
19
+ return DEFAULT_MARKUS_PUBLICATION;
20
+ }
@@ -0,0 +1,26 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
3
+ export const FREE_INVALIDATION_PATHS_PER_MONTH = 1000;
4
+ export const DAYS_PER_MONTH = 30.4;
5
+ export const ALARM_WINDOW_DAYS = 7;
6
+ export const INVALIDATION_METRIC_NAMESPACE = "Papyrus/Edge";
7
+ export const INVALIDATION_METRIC_NAME = "InvalidatedPaths";
8
+ /** CloudWatch cannot alarm across more than seven days, so the monthly free allowance is checked as its weekly pace. */
9
+ export const WEEKLY_INVALIDATION_PATH_THRESHOLD = Math.round((FREE_INVALIDATION_PATHS_PER_MONTH * ALARM_WINDOW_DAYS) / DAYS_PER_MONTH);
10
+ export function addInvalidationPathAlarm(scope, id, distributionId) {
11
+ const metric = new cloudwatch.Metric({
12
+ namespace: INVALIDATION_METRIC_NAMESPACE,
13
+ metricName: INVALIDATION_METRIC_NAME,
14
+ dimensionsMap: { DistributionId: distributionId },
15
+ statistic: "Sum",
16
+ period: Duration.days(ALARM_WINDOW_DAYS),
17
+ });
18
+ return new cloudwatch.Alarm(scope, id, {
19
+ metric,
20
+ evaluationPeriods: 1,
21
+ threshold: WEEKLY_INVALIDATION_PATH_THRESHOLD,
22
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
23
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
24
+ alarmDescription: `CloudFront invalidation paths on ${distributionId} are on pace to pass the ${FREE_INVALIDATION_PATHS_PER_MONTH} free paths a month.`,
25
+ });
26
+ }
@@ -0,0 +1,30 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
3
+ import type { Construct } from "constructs";
4
+
5
+ export const FREE_INVALIDATION_PATHS_PER_MONTH = 1000;
6
+ export const DAYS_PER_MONTH = 30.4;
7
+ export const ALARM_WINDOW_DAYS = 7;
8
+ export const INVALIDATION_METRIC_NAMESPACE = "Papyrus/Edge";
9
+ export const INVALIDATION_METRIC_NAME = "InvalidatedPaths";
10
+
11
+ /** CloudWatch cannot alarm across more than seven days, so the monthly free allowance is checked as its weekly pace. */
12
+ export const WEEKLY_INVALIDATION_PATH_THRESHOLD = Math.round((FREE_INVALIDATION_PATHS_PER_MONTH * ALARM_WINDOW_DAYS) / DAYS_PER_MONTH);
13
+
14
+ export function addInvalidationPathAlarm(scope: Construct, id: string, distributionId: string): cloudwatch.Alarm {
15
+ const metric = new cloudwatch.Metric({
16
+ namespace: INVALIDATION_METRIC_NAMESPACE,
17
+ metricName: INVALIDATION_METRIC_NAME,
18
+ dimensionsMap: { DistributionId: distributionId },
19
+ statistic: "Sum",
20
+ period: Duration.days(ALARM_WINDOW_DAYS),
21
+ });
22
+ return new cloudwatch.Alarm(scope, id, {
23
+ metric,
24
+ evaluationPeriods: 1,
25
+ threshold: WEEKLY_INVALIDATION_PATH_THRESHOLD,
26
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
27
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
28
+ alarmDescription: `CloudFront invalidation paths on ${distributionId} are on pace to pass the ${FREE_INVALIDATION_PATHS_PER_MONTH} free paths a month.`,
29
+ });
30
+ }
@@ -3,8 +3,9 @@
3
3
  * site (no CDK imports, so it can be tested without synthesizing).
4
4
  *
5
5
  * The function exists only for a site whose `projection.json` carries an
6
- * `images` section: that section is the one place the image pipeline settings
7
- * live, shared by the renditions function and the projector.
6
+ * `images` section (the one place the image pipeline settings live, shared by
7
+ * the renditions function and the projector) or `media.originals: true`
8
+ * (every media object is also published as is, at its key without `media/`).
8
9
  */
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
@@ -20,11 +21,18 @@ export function projectionSettingsHaveImages(settingsPath) {
20
21
  const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
21
22
  return typeof parsed.images === "object" && parsed.images !== null;
22
23
  }
24
+ export function projectionSettingsPublishOriginals(settingsPath) {
25
+ if (!fs.existsSync(settingsPath))
26
+ return false;
27
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
28
+ return parsed.media?.originals === true;
29
+ }
23
30
  export function planRenditions(publication, workingDirectory) {
24
31
  if (publication?.staticDir === undefined)
25
32
  return { enabled: false, siteFiles: [] };
26
33
  const settingsFile = path.join(publication.staticDir, "projection.json");
27
- if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile)))
34
+ const resolved = path.resolve(workingDirectory, settingsFile);
35
+ if (!projectionSettingsHaveImages(resolved) && !projectionSettingsPublishOriginals(resolved))
28
36
  return { enabled: false, siteFiles: [] };
29
37
  return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
30
38
  }
@@ -3,8 +3,9 @@
3
3
  * site (no CDK imports, so it can be tested without synthesizing).
4
4
  *
5
5
  * The function exists only for a site whose `projection.json` carries an
6
- * `images` section: that section is the one place the image pipeline settings
7
- * live, shared by the renditions function and the projector.
6
+ * `images` section (the one place the image pipeline settings live, shared by
7
+ * the renditions function and the projector) or `media.originals: true`
8
+ * (every media object is also published as is, at its key without `media/`).
8
9
  */
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
@@ -27,10 +28,17 @@ export function projectionSettingsHaveImages(settingsPath: string): boolean {
27
28
  return typeof parsed.images === "object" && parsed.images !== null;
28
29
  }
29
30
 
31
+ export function projectionSettingsPublishOriginals(settingsPath: string): boolean {
32
+ if (!fs.existsSync(settingsPath)) return false;
33
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as { media?: { originals?: unknown } };
34
+ return parsed.media?.originals === true;
35
+ }
36
+
30
37
  export function planRenditions(publication: { staticDir?: string } | undefined, workingDirectory: string): RenditionsPlan {
31
38
  if (publication?.staticDir === undefined) return { enabled: false, siteFiles: [] };
32
39
  const settingsFile = path.join(publication.staticDir, "projection.json");
33
- if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile))) return { enabled: false, siteFiles: [] };
40
+ const resolved = path.resolve(workingDirectory, settingsFile);
41
+ if (!projectionSettingsHaveImages(resolved) && !projectionSettingsPublishOriginals(resolved)) return { enabled: false, siteFiles: [] };
34
42
  return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
35
43
  }
36
44
 
@@ -0,0 +1,31 @@
1
+ import { PolicyStatement } from "aws-cdk-lib/aws-iam";
2
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
3
+ export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 300;
4
+ export function reprojectDeployToken(environment, now = Date.now) {
5
+ const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
6
+ return buildJobId !== "" ? `job-${buildJobId}` : `time-${now()}`;
7
+ }
8
+ /**
9
+ * Starts the content-actions `all` projection task (asynchronously) every time the stack deploys.
10
+ * The token is in the request, so CloudFormation sees a changed property on each deploy.
11
+ */
12
+ export function addReprojectOnDeploy(stack, contentActions, deployToken) {
13
+ const invocation = {
14
+ service: "Lambda",
15
+ action: "invoke",
16
+ parameters: {
17
+ FunctionName: contentActions.functionName,
18
+ InvocationType: "Event",
19
+ Payload: JSON.stringify({ projectionTask: { task: "all", deployToken } }),
20
+ },
21
+ physicalResourceId: PhysicalResourceId.of(`reproject-${deployToken}`),
22
+ };
23
+ return new AwsCustomResource(stack, "ReprojectOnDeploy", {
24
+ onCreate: invocation,
25
+ onUpdate: invocation,
26
+ installLatestAwsSdk: false,
27
+ policy: AwsCustomResourcePolicy.fromStatements([
28
+ new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }),
29
+ ]),
30
+ });
31
+ }
@@ -0,0 +1,36 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import type { IFunction } from "aws-cdk-lib/aws-lambda";
4
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
5
+
6
+ export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 300;
7
+
8
+ export function reprojectDeployToken(environment: Record<string, string | undefined>, now: () => number = Date.now): string {
9
+ const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
10
+ return buildJobId !== "" ? `job-${buildJobId}` : `time-${now()}`;
11
+ }
12
+
13
+ /**
14
+ * Starts the content-actions `all` projection task (asynchronously) every time the stack deploys.
15
+ * The token is in the request, so CloudFormation sees a changed property on each deploy.
16
+ */
17
+ export function addReprojectOnDeploy(stack: Stack, contentActions: IFunction, deployToken: string): AwsCustomResource {
18
+ const invocation = {
19
+ service: "Lambda",
20
+ action: "invoke",
21
+ parameters: {
22
+ FunctionName: contentActions.functionName,
23
+ InvocationType: "Event",
24
+ Payload: JSON.stringify({ projectionTask: { task: "all", deployToken } }),
25
+ },
26
+ physicalResourceId: PhysicalResourceId.of(`reproject-${deployToken}`),
27
+ };
28
+ return new AwsCustomResource(stack, "ReprojectOnDeploy", {
29
+ onCreate: invocation,
30
+ onUpdate: invocation,
31
+ installLatestAwsSdk: false,
32
+ policy: AwsCustomResourcePolicy.fromStatements([
33
+ new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }),
34
+ ]),
35
+ });
36
+ }
@@ -14,6 +14,10 @@ export function s3ProjectionEnvironment(input) {
14
14
  environment.PAPYRUS_EDGE_DISTRIBUTION_ID = distributionId;
15
15
  environment.PAPYRUS_REDIRECTS_KVS_ARN = input.keyValueStoreArn;
16
16
  environment.PAPYRUS_PROJECTION_DLQ_URL = input.deadLetterQueueUrl;
17
+ if (input.cache?.htmlSMaxAgeSeconds !== undefined)
18
+ environment.PAPYRUS_HTML_S_MAXAGE_SECONDS = String(input.cache.htmlSMaxAgeSeconds);
19
+ if (input.cache?.invalidation === "off")
20
+ environment.PAPYRUS_EDGE_INVALIDATION = "off";
17
21
  if (input.publicHostName !== undefined)
18
22
  environment.PAPYRUS_SITE_PUBLIC_URL = `https://${input.publicHostName}`;
19
23
  if (input.projector !== undefined)
@@ -27,7 +31,7 @@ export function s3ProjectionPolicyStatements(input) {
27
31
  { actions: ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"], resources: [`${input.bucketArn}/*`] },
28
32
  { actions: ["s3:ListBucket"], resources: [input.bucketArn] },
29
33
  ];
30
- if (input.distributionArn !== undefined) {
34
+ if (input.distributionArn !== undefined && input.cache?.invalidation !== "off") {
31
35
  statements.push({ actions: ["cloudfront:CreateInvalidation"], resources: [input.distributionArn] });
32
36
  }
33
37
  statements.push({
@@ -11,6 +11,7 @@ export type S3ProjectionSettingsInput = {
11
11
  distributionArn?: string;
12
12
  distributionId?: string;
13
13
  publicHostName?: string;
14
+ cache?: { htmlSMaxAgeSeconds?: number; invalidation?: "targeted" | "off" };
14
15
  projector?: string;
15
16
  staticDir?: string;
16
17
  };
@@ -37,6 +38,8 @@ export function s3ProjectionEnvironment(input: S3ProjectionSettingsInput): Recor
37
38
  if (distributionId !== undefined) environment.PAPYRUS_EDGE_DISTRIBUTION_ID = distributionId;
38
39
  environment.PAPYRUS_REDIRECTS_KVS_ARN = input.keyValueStoreArn;
39
40
  environment.PAPYRUS_PROJECTION_DLQ_URL = input.deadLetterQueueUrl;
41
+ if (input.cache?.htmlSMaxAgeSeconds !== undefined) environment.PAPYRUS_HTML_S_MAXAGE_SECONDS = String(input.cache.htmlSMaxAgeSeconds);
42
+ if (input.cache?.invalidation === "off") environment.PAPYRUS_EDGE_INVALIDATION = "off";
40
43
  if (input.publicHostName !== undefined) environment.PAPYRUS_SITE_PUBLIC_URL = `https://${input.publicHostName}`;
41
44
  if (input.projector !== undefined) environment.PAPYRUS_SITE_PROJECTOR = S3_PROJECTION_PROJECTOR_MODULE;
42
45
  if (input.staticDir !== undefined) environment.PAPYRUS_SITE_STATIC_DIR = S3_PROJECTION_STATIC_DIRECTORY;
@@ -48,7 +51,7 @@ export function s3ProjectionPolicyStatements(input: S3ProjectionSettingsInput):
48
51
  { actions: ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"], resources: [`${input.bucketArn}/*`] },
49
52
  { actions: ["s3:ListBucket"], resources: [input.bucketArn] },
50
53
  ];
51
- if (input.distributionArn !== undefined) {
54
+ if (input.distributionArn !== undefined && input.cache?.invalidation !== "off") {
52
55
  statements.push({ actions: ["cloudfront:CreateInvalidation"], resources: [input.distributionArn] });
53
56
  }
54
57
  statements.push(
@@ -12,7 +12,8 @@ import { fileURLToPath } from "node:url";
12
12
  import { assertValidPublicationConfig } from "../site-backend-config.js";
13
13
  import { addRenditionsFunction } from "./renditions-function.js";
14
14
  import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
15
- import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
15
+ import { addInvalidationPathAlarm } from "./invalidation-alarm.js";
16
+ import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
16
17
  const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
17
18
  function createSiteBucket(scope, retainBucketOnDelete) {
18
19
  return new s3.Bucket(scope, "SiteBucket", {
@@ -107,9 +108,15 @@ export function addS3ProjectionPublication(scope, props, media) {
107
108
  publicHostName: distribution !== undefined
108
109
  ? (props.distribution?.aliases?.[0] ?? distribution.distributionDomainName)
109
110
  : undefined,
111
+ cache: props.cache,
110
112
  projector: props.projector,
111
113
  staticDir: props.staticDir,
112
114
  };
115
+ const invalidatedDistributionId = distribution?.distributionId
116
+ ?? (props.edgeDistributionArn !== undefined ? distributionIdFromArn(props.edgeDistributionArn) : undefined);
117
+ if (invalidatedDistributionId !== undefined && props.cache?.invalidation !== "off") {
118
+ addInvalidationPathAlarm(scope, "EdgeInvalidationPaths", invalidatedDistributionId);
119
+ }
113
120
  return {
114
121
  bucket,
115
122
  keyValueStore,
@@ -15,7 +15,8 @@ import { fileURLToPath } from "node:url";
15
15
  import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
16
16
  import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
17
17
  import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
18
- import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
18
+ import { addInvalidationPathAlarm } from "./invalidation-alarm";
19
+ import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
19
20
 
20
21
  const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
21
22
 
@@ -154,9 +155,15 @@ export function addS3ProjectionPublication(
154
155
  publicHostName: distribution !== undefined
155
156
  ? (props.distribution?.aliases?.[0] ?? distribution.distributionDomainName)
156
157
  : undefined,
158
+ cache: props.cache,
157
159
  projector: props.projector,
158
160
  staticDir: props.staticDir,
159
161
  };
162
+ const invalidatedDistributionId = distribution?.distributionId
163
+ ?? (props.edgeDistributionArn !== undefined ? distributionIdFromArn(props.edgeDistributionArn) : undefined);
164
+ if (invalidatedDistributionId !== undefined && props.cache?.invalidation !== "off") {
165
+ addInvalidationPathAlarm(scope, "EdgeInvalidationPaths", invalidatedDistributionId);
166
+ }
160
167
  return {
161
168
  bucket,
162
169
  keyValueStore,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Environment and IAM for purging our CloudFront copies of the paths a Pretext reader revalidated.
3
+ * Pure (no CDK imports) so it can be tested without synthesizing.
4
+ */
5
+ export const REVALIDATE_DISTRIBUTION_IDS_ENV = "PAPYRUS_REVALIDATE_DISTRIBUTION_IDS";
6
+ const CLOUDFRONT_DISTRIBUTION_ID = /^E[A-Z0-9]{8,}$/;
7
+ export function validateRevalidateDistributionIds(ids, revalidateBaseUrl) {
8
+ if (ids === undefined)
9
+ return [];
10
+ if (!Array.isArray(ids) || ids.length === 0)
11
+ return ["revalidateDistributionIds must be a non-empty array of CloudFront distribution ids"];
12
+ const problems = [];
13
+ for (const id of ids) {
14
+ if (typeof id !== "string" || !CLOUDFRONT_DISTRIBUTION_ID.test(id)) {
15
+ problems.push(`revalidateDistributionIds entry ${JSON.stringify(id)} is not a CloudFront distribution id`);
16
+ }
17
+ }
18
+ if ((revalidateBaseUrl ?? "").trim() === "")
19
+ problems.push("revalidateDistributionIds requires revalidateBaseUrl");
20
+ return problems;
21
+ }
22
+ export function revalidateDistributionEnvironment(ids) {
23
+ return { [REVALIDATE_DISTRIBUTION_IDS_ENV]: ids.join(",") };
24
+ }
25
+ export function revalidateInvalidationStatement(input) {
26
+ return {
27
+ actions: ["cloudfront:CreateInvalidation"],
28
+ resources: input.distributionIds.map((id) => `arn:${input.partition}:cloudfront::${input.account}:distribution/${id}`),
29
+ };
30
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Environment and IAM for purging our CloudFront copies of the paths a Pretext reader revalidated.
3
+ * Pure (no CDK imports) so it can be tested without synthesizing.
4
+ */
5
+ export const REVALIDATE_DISTRIBUTION_IDS_ENV = "PAPYRUS_REVALIDATE_DISTRIBUTION_IDS";
6
+
7
+ const CLOUDFRONT_DISTRIBUTION_ID = /^E[A-Z0-9]{8,}$/;
8
+
9
+ export function validateRevalidateDistributionIds(ids: unknown, revalidateBaseUrl: string | undefined): string[] {
10
+ if (ids === undefined) return [];
11
+ if (!Array.isArray(ids) || ids.length === 0) return ["revalidateDistributionIds must be a non-empty array of CloudFront distribution ids"];
12
+ const problems: string[] = [];
13
+ for (const id of ids) {
14
+ if (typeof id !== "string" || !CLOUDFRONT_DISTRIBUTION_ID.test(id)) {
15
+ problems.push(`revalidateDistributionIds entry ${JSON.stringify(id)} is not a CloudFront distribution id`);
16
+ }
17
+ }
18
+ if ((revalidateBaseUrl ?? "").trim() === "") problems.push("revalidateDistributionIds requires revalidateBaseUrl");
19
+ return problems;
20
+ }
21
+
22
+ export function revalidateDistributionEnvironment(ids: string[]): Record<string, string> {
23
+ return { [REVALIDATE_DISTRIBUTION_IDS_ENV]: ids.join(",") };
24
+ }
25
+
26
+ export function revalidateInvalidationStatement(input: {
27
+ partition: string;
28
+ account: string;
29
+ distributionIds: string[];
30
+ }): { actions: string[]; resources: string[] } {
31
+ return {
32
+ actions: ["cloudfront:CreateInvalidation"],
33
+ resources: input.distributionIds.map((id) => `arn:${input.partition}:cloudfront::${input.account}:distribution/${id}`),
34
+ };
35
+ }
@@ -1,3 +1,4 @@
1
+ export const MAXIMUM_HTML_S_MAXAGE_SECONDS = 86400;
1
2
  const CLOUDFRONT_DISTRIBUTION_ARN = /^arn:aws[a-z-]*:cloudfront::\d{12}:distribution\/[A-Z0-9]+$/;
2
3
  function isRelativePathWithoutParentSegments(value) {
3
4
  if (value === "" || value.startsWith("/") || value.includes("\\"))
@@ -26,6 +27,19 @@ export function validatePublicationConfig(publication) {
26
27
  if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
27
28
  problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
28
29
  }
30
+ if (config.reprojectOnDeploy !== undefined && typeof config.reprojectOnDeploy !== "boolean") {
31
+ problems.push("publication.reprojectOnDeploy must be a boolean");
32
+ }
33
+ const cache = config.cache;
34
+ if (cache !== undefined) {
35
+ const lifetime = cache.htmlSMaxAgeSeconds;
36
+ if (lifetime !== undefined && (!Number.isInteger(lifetime) || lifetime < 0 || lifetime > MAXIMUM_HTML_S_MAXAGE_SECONDS)) {
37
+ problems.push(`publication.cache.htmlSMaxAgeSeconds must be an integer from 0 to ${MAXIMUM_HTML_S_MAXAGE_SECONDS}`);
38
+ }
39
+ if (cache.invalidation !== undefined && cache.invalidation !== "targeted" && cache.invalidation !== "off") {
40
+ problems.push('publication.cache.invalidation must be "targeted" or "off"');
41
+ }
42
+ }
29
43
  const distribution = config.distribution;
30
44
  if (distribution !== undefined) {
31
45
  if (config.edgeDistributionArn !== undefined && distribution.create === true) {
@@ -29,6 +29,15 @@ export type S3ProjectionDistributionConfig = {
29
29
  acm?: { hostedZoneId: string; hostedZoneName: string };
30
30
  };
31
31
 
32
+ export type PapyrusPublicationCacheConfig = {
33
+ /** Shared-cache lifetime (seconds) of projected HTML. Default 300 with targeted invalidation, 60 with `invalidation: "off"`. */
34
+ htmlSMaxAgeSeconds?: number;
35
+ /** `targeted` (default): purge exactly the written paths after each projection. `off`: rely on the short lifetime alone. */
36
+ invalidation?: "targeted" | "off";
37
+ };
38
+
39
+ export const MAXIMUM_HTML_S_MAXAGE_SECONDS = 86400;
40
+
32
41
  export type PapyrusPublicationConfig = {
33
42
  kind: "s3-projection";
34
43
  /** Site repo path (relative, `.py`) of the SiteProjector bundled into content-actions as `site_projector`. */
@@ -37,10 +46,18 @@ export type PapyrusPublicationConfig = {
37
46
  staticDir?: string;
38
47
  /** Existing CloudFront distribution that reads the site bucket through OAC. */
39
48
  edgeDistributionArn?: string;
49
+ /** Edge cache lifetime of projected HTML and whether each projection purges the edge. */
50
+ cache?: PapyrusPublicationCacheConfig;
40
51
  /** Create the distribution (and optionally its certificate) in the site backend. Default: not created. */
41
52
  distribution?: S3ProjectionDistributionConfig;
42
53
  /** Default true: the site bucket is retained when the stack is deleted. `false` destroys it and its objects. */
43
54
  retainBucketOnDelete?: boolean;
55
+ /**
56
+ * Re-project every published page after each backend deploy, so a theme or code change reaches the
57
+ * site without a manual `papyrus ops content project --all`. Default: true for the publication a
58
+ * Markus site gets when it sets none, false when `publication` is written out in the site config.
59
+ */
60
+ reprojectOnDeploy?: boolean;
44
61
  };
45
62
 
46
63
  const CLOUDFRONT_DISTRIBUTION_ARN = /^arn:aws[a-z-]*:cloudfront::\d{12}:distribution\/[A-Z0-9]+$/;
@@ -72,6 +89,19 @@ export function validatePublicationConfig(publication: unknown): string[] {
72
89
  if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
73
90
  problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
74
91
  }
92
+ if (config.reprojectOnDeploy !== undefined && typeof config.reprojectOnDeploy !== "boolean") {
93
+ problems.push("publication.reprojectOnDeploy must be a boolean");
94
+ }
95
+ const cache = config.cache;
96
+ if (cache !== undefined) {
97
+ const lifetime = cache.htmlSMaxAgeSeconds;
98
+ if (lifetime !== undefined && (!Number.isInteger(lifetime) || lifetime < 0 || lifetime > MAXIMUM_HTML_S_MAXAGE_SECONDS)) {
99
+ problems.push(`publication.cache.htmlSMaxAgeSeconds must be an integer from 0 to ${MAXIMUM_HTML_S_MAXAGE_SECONDS}`);
100
+ }
101
+ if (cache.invalidation !== undefined && cache.invalidation !== "targeted" && cache.invalidation !== "off") {
102
+ problems.push('publication.cache.invalidation must be "targeted" or "off"');
103
+ }
104
+ }
75
105
  const distribution = config.distribution;
76
106
  if (distribution !== undefined) {
77
107
  if (config.edgeDistributionArn !== undefined && distribution.create === true) {
@@ -119,6 +149,11 @@ export type PapyrusSiteBackendConfig = {
119
149
  stagingBuild?: { enabled: boolean };
120
150
  /** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (authenticated by the SSM secret named in branch variable `PAPYRUS_REVALIDATE_SECRET_PARAMETER`). */
121
151
  revalidateBaseUrl?: string;
152
+ /**
153
+ * CloudFront distribution ids (same account) in front of that reader. After a publish refreshes the reader's
154
+ * cache, content-actions invalidates the revalidated paths on each of them. Needs `revalidateBaseUrl`.
155
+ */
156
+ revalidateDistributionIds?: string[];
122
157
  /**
123
158
  * SES receipt options for a site that is not the legacy p.apyr.us backend.
124
159
  * By default the site gets its own brand-named rule set that is never
@@ -130,8 +165,12 @@ export type PapyrusSiteBackendConfig = {
130
165
  /** Let this stack verify the SES domain identity and write its TXT record. Default false: verify once by hand. */
131
166
  manageDomainIdentity?: boolean;
132
167
  };
133
- /** Opt-in static projection of published pages into an S3 site bucket. Unset: nothing is created. */
134
- publication?: PapyrusPublicationConfig;
168
+ /**
169
+ * Static projection of published pages into an S3 site bucket. Unset: a Markus site without a
170
+ * `reader` gets `{ kind: "s3-projection" }`; any other site gets nothing. `false` opts a Markus
171
+ * site out. See `resolveSitePublication`.
172
+ */
173
+ publication?: PapyrusPublicationConfig | false;
135
174
  features?: {
136
175
  consoleResponder?: boolean;
137
176
  inboundEmail?: boolean;
@@ -7,12 +7,16 @@ import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aw
7
7
  import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
10
+ import { resolveSitePublication } from "./publication/default-publication.js";
11
+ import { REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS, addReprojectOnDeploy, reprojectDeployToken } from "./publication/reproject-on-deploy.js";
10
12
  import { addProjectionRetries, addS3ProjectionPublication } from "./publication/s3-projection.js";
11
13
  import { planRenditions } from "./publication/renditions-settings.js";
12
14
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
13
15
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
14
16
  import { assertValidPublicationConfig } from "./site-backend-config.js";
15
17
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
18
+ import { revalidateDistributionEnvironment, revalidateInvalidationStatement, validateRevalidateDistributionIds, } from "./revalidate-invalidation-settings.js";
19
+ import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
16
20
  import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
17
21
  import { data } from "./data/resource.js";
18
22
  import { assignmentAction } from "./functions/assignment-action/resource.js";
@@ -66,10 +70,11 @@ export function defineSiteBackend(papyrusSite) {
66
70
  ...baseAuthConfig,
67
71
  newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
68
72
  };
69
- if (site.publication !== undefined)
70
- assertValidPublicationConfig(site.publication);
71
- const publicationPlan = planS3ProjectionBackend(site.publication);
72
- const renditionsPlan = planRenditions(site.publication, process.cwd());
73
+ const publication = resolveSitePublication(site, activeBrand?.renderer?.kind);
74
+ if (publication !== undefined)
75
+ assertValidPublicationConfig(publication);
76
+ const publicationPlan = planS3ProjectionBackend(publication);
77
+ const renditionsPlan = planRenditions(publication, process.cwd());
73
78
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
74
79
  const backend = defineBackend({
75
80
  assignmentAction,
@@ -312,12 +317,16 @@ export function defineSiteBackend(papyrusSite) {
312
317
  contentActionsLambda.addToRolePolicy(new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }));
313
318
  }
314
319
  let s3ProjectionPublication;
315
- if (site.publication !== undefined) {
316
- s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
320
+ if (publication !== undefined) {
321
+ s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), publication, {
317
322
  mediaBucket: storageBucket,
318
323
  renditions: renditionsPlan,
319
324
  });
320
325
  contentActionsLambda.node.defaultChild.memorySize = publicationPlan.contentActionsMemorySize;
326
+ if (publication.reprojectOnDeploy === true) {
327
+ contentActionsLambda.node.defaultChild.timeout = REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS;
328
+ addReprojectOnDeploy(backend.createStack("reproject-on-deploy"), contentActionsLambda, reprojectDeployToken(process.env));
329
+ }
321
330
  }
322
331
  if (s3ProjectionPublication !== undefined) {
323
332
  addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
@@ -343,6 +352,22 @@ export function defineSiteBackend(papyrusSite) {
343
352
  account: revalidateStack.account,
344
353
  parameterName: revalidateSecretParameter,
345
354
  })));
355
+ const distributionProblems = validateRevalidateDistributionIds(site.revalidateDistributionIds, site.revalidateBaseUrl);
356
+ if (distributionProblems.length > 0)
357
+ throw new Error(`Invalid backend config: ${distributionProblems.join("; ")}`);
358
+ const distributionIds = site.revalidateDistributionIds ?? [];
359
+ if (distributionIds.length > 0) {
360
+ for (const [name, value] of Object.entries(revalidateDistributionEnvironment(distributionIds))) {
361
+ backend.contentActions.addEnvironment(name, value);
362
+ }
363
+ contentActionsLambda.addToRolePolicy(new PolicyStatement(revalidateInvalidationStatement({ partition: revalidateStack.partition, account: revalidateStack.account, distributionIds })));
364
+ for (const id of distributionIds) {
365
+ addInvalidationPathAlarm(revalidateStack, `ReaderEdgeInvalidationPaths${id}`, id);
366
+ }
367
+ }
368
+ }
369
+ else if (site.revalidateDistributionIds !== undefined) {
370
+ throw new Error("Invalid backend config: revalidateDistributionIds requires revalidateBaseUrl");
346
371
  }
347
372
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
348
373
  grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
@@ -10,12 +10,20 @@ import type { Construct } from "constructs";
10
10
  import { dirname, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
13
+ import { resolveSitePublication } from "./publication/default-publication";
14
+ import { REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS, addReprojectOnDeploy, reprojectDeployToken } from "./publication/reproject-on-deploy";
13
15
  import { addProjectionRetries, addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
14
16
  import { planRenditions } from "./publication/renditions-settings";
15
17
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
16
18
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
17
19
  import { assertValidPublicationConfig } from "./site-backend-config";
18
20
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy";
21
+ import {
22
+ revalidateDistributionEnvironment,
23
+ revalidateInvalidationStatement,
24
+ validateRevalidateDistributionIds,
25
+ } from "./revalidate-invalidation-settings";
26
+ import { addInvalidationPathAlarm } from "./publication/invalidation-alarm";
19
27
  import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
20
28
  import { data } from "./data/resource";
21
29
  import { assignmentAction } from "./functions/assignment-action/resource";
@@ -84,9 +92,10 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
84
92
  ...baseAuthConfig,
85
93
  newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
86
94
  };
87
- if (site.publication !== undefined) assertValidPublicationConfig(site.publication);
88
- const publicationPlan = planS3ProjectionBackend(site.publication);
89
- const renditionsPlan = planRenditions(site.publication, process.cwd());
95
+ const publication = resolveSitePublication(site, activeBrand?.renderer?.kind);
96
+ if (publication !== undefined) assertValidPublicationConfig(publication);
97
+ const publicationPlan = planS3ProjectionBackend(publication);
98
+ const renditionsPlan = planRenditions(publication, process.cwd());
90
99
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
91
100
  const backend = defineBackend({
92
101
  assignmentAction,
@@ -397,12 +406,16 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
397
406
  );
398
407
  }
399
408
  let s3ProjectionPublication: S3ProjectionPublication | undefined;
400
- if (site.publication !== undefined) {
401
- s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
409
+ if (publication !== undefined) {
410
+ s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), publication, {
402
411
  mediaBucket: storageBucket,
403
412
  renditions: renditionsPlan,
404
413
  });
405
414
  (contentActionsLambda.node.defaultChild as CfnFunction).memorySize = publicationPlan.contentActionsMemorySize;
415
+ if (publication.reprojectOnDeploy === true) {
416
+ (contentActionsLambda.node.defaultChild as CfnFunction).timeout = REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS;
417
+ addReprojectOnDeploy(backend.createStack("reproject-on-deploy"), contentActionsLambda, reprojectDeployToken(process.env));
418
+ }
406
419
  }
407
420
  if (s3ProjectionPublication !== undefined) {
408
421
  addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
@@ -434,6 +447,24 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
434
447
  }),
435
448
  ),
436
449
  );
450
+ const distributionProblems = validateRevalidateDistributionIds(site.revalidateDistributionIds, site.revalidateBaseUrl);
451
+ if (distributionProblems.length > 0) throw new Error(`Invalid backend config: ${distributionProblems.join("; ")}`);
452
+ const distributionIds = site.revalidateDistributionIds ?? [];
453
+ if (distributionIds.length > 0) {
454
+ for (const [name, value] of Object.entries(revalidateDistributionEnvironment(distributionIds))) {
455
+ backend.contentActions.addEnvironment(name, value);
456
+ }
457
+ contentActionsLambda.addToRolePolicy(
458
+ new PolicyStatement(
459
+ revalidateInvalidationStatement({ partition: revalidateStack.partition, account: revalidateStack.account, distributionIds }),
460
+ ),
461
+ );
462
+ for (const id of distributionIds) {
463
+ addInvalidationPathAlarm(revalidateStack, `ReaderEdgeInvalidationPaths${id}`, id);
464
+ }
465
+ }
466
+ } else if (site.revalidateDistributionIds !== undefined) {
467
+ throw new Error("Invalid backend config: revalidateDistributionIds requires revalidateBaseUrl");
437
468
  }
438
469
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
439
470
  grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
@@ -5,7 +5,7 @@ import { getEditionDatePath, parseEditionArticleRoute } from "../../../../../lib
5
5
  import { buildPresentationFooterEntries } from "../../../../../lib/presentation-footer";
6
6
  import { SITE_BRAND } from "../../../../../lib/site-brand";
7
7
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
8
- export const revalidate = 3600;
8
+ export const revalidate = 60;
9
9
 
10
10
  type DateScopedArticlePageProps = {
11
11
  params: Promise<{
@@ -3,7 +3,7 @@ import { EditionRoutePage } from "../../edition-route-page";
3
3
  import { parseEditionPageRoute } from "../../../../../../lib/edition-routes";
4
4
  import { getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
5
5
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
6
- export const revalidate = 3600;
6
+ export const revalidate = 60;
7
7
 
8
8
  type EditionPageRouteProps = {
9
9
  params: Promise<{
@@ -2,7 +2,7 @@ import { EditionRoutePage } from "./edition-route-page";
2
2
  import { generateEditionDateStaticParams } from "../../../../lib/reader-static-params";
3
3
 
4
4
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
5
- export const revalidate = 3600;
5
+ export const revalidate = 60;
6
6
 
7
7
  export async function generateStaticParams() {
8
8
  return generateEditionDateStaticParams();
@@ -3,7 +3,7 @@ import { EditionRoutePage } from "../../edition-route-page";
3
3
  import { createSectionKey } from "../../../../../../lib/edition-sections";
4
4
  import { parseEditionSectionRoute } from "../../../../../../lib/edition-routes";
5
5
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
6
- export const revalidate = 3600;
6
+ export const revalidate = 60;
7
7
 
8
8
  type EditionSectionRouteProps = {
9
9
  params: Promise<{
@@ -3,7 +3,7 @@ import { loadArchiveEditionPreviews, clampArchiveLimit } from "../../../../lib/a
3
3
  import { ARCHIVE_BATCH_SIZE } from "../../../../lib/archive-types";
4
4
 
5
5
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
6
- export const revalidate = 3600;
6
+ export const revalidate = 60;
7
7
 
8
8
  export async function GET(request: Request) {
9
9
  const url = new URL(request.url);
@@ -4,7 +4,7 @@ import { loadArchiveEditionPreviews } from "../../lib/archive-data";
4
4
  import { ARCHIVE_BATCH_SIZE } from "../../lib/archive-types";
5
5
 
6
6
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
7
- export const revalidate = 3600;
7
+ export const revalidate = 60;
8
8
 
9
9
  export default async function ArchivePage() {
10
10
  const initialBatch = await loadArchiveEditionPreviews({ limit: ARCHIVE_BATCH_SIZE });
@@ -5,7 +5,7 @@ import { generateArticleStaticParams, siteServesNextReaderRoutes } from "../../.
5
5
  import { SITE_BRAND } from "../../../lib/site-brand";
6
6
 
7
7
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
8
- export const revalidate = 3600;
8
+ export const revalidate = 60;
9
9
 
10
10
  export async function generateStaticParams() {
11
11
  return generateArticleStaticParams();
@@ -32,6 +32,9 @@ function reader(config) {
32
32
  throw new Error(`site ${config.siteId} has no reader app (frontend ${config.frontend})`);
33
33
  return config.reader;
34
34
  }
35
+ function stagingPreviewFor(config) {
36
+ return config.cms?.stagingPreview ?? config.reader;
37
+ }
35
38
  function frontendSection(preBuildCommands, buildCommands, artifactsDirectory, cachePaths) {
36
39
  const lines = ["frontend:", " phases:"];
37
40
  if (preBuildCommands.length > 0) {
@@ -62,9 +65,9 @@ export function cmsProductionBuildSpec(config) {
62
65
  export function cmsStagingBuildSpec(config) {
63
66
  const preBuild = [NPM_CLEAN_INSTALL, "npx ampx generate outputs --branch main --app-id $AWS_APP_ID"];
64
67
  const cachePaths = [".next/cache/**/*", ".npm/**/*"];
65
- if (config.frontend === "markus-static") {
66
- const readerConfig = reader(config);
67
- preBuild.push(...pythonProvisioningCommands(config), `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`, readerConfig.buildCommand, `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
68
+ const preview = config.frontend === "markus-static" ? stagingPreviewFor(config) : undefined;
69
+ if (preview !== undefined) {
70
+ preBuild.push(...pythonProvisioningCommands(config), `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`, preview.buildCommand, `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${preview.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
68
71
  cachePaths.push(".uv-cache/**/*");
69
72
  }
70
73
  return [
@@ -43,6 +43,10 @@ function reader(config: AmplifyAppShellSiteConfig): NonNullable<AmplifyAppShellS
43
43
  return config.reader;
44
44
  }
45
45
 
46
+ function stagingPreviewFor(config: AmplifyAppShellSiteConfig): { buildCommand: string; baseDirectory: string } | undefined {
47
+ return config.cms?.stagingPreview ?? config.reader;
48
+ }
49
+
46
50
  function frontendSection(preBuildCommands: string[], buildCommands: string[], artifactsDirectory: string, cachePaths: string[]): string[] {
47
51
  const lines = ["frontend:", " phases:"];
48
52
  if (preBuildCommands.length > 0) {
@@ -94,13 +98,13 @@ export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): strin
94
98
  export function cmsStagingBuildSpec(config: AmplifyAppShellSiteConfig): string {
95
99
  const preBuild = [NPM_CLEAN_INSTALL, "npx ampx generate outputs --branch main --app-id $AWS_APP_ID"];
96
100
  const cachePaths = [".next/cache/**/*", ".npm/**/*"];
97
- if (config.frontend === "markus-static") {
98
- const readerConfig = reader(config);
101
+ const preview = config.frontend === "markus-static" ? stagingPreviewFor(config) : undefined;
102
+ if (preview !== undefined) {
99
103
  preBuild.push(
100
104
  ...pythonProvisioningCommands(config),
101
105
  `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`,
102
- readerConfig.buildCommand,
103
- `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
106
+ preview.buildCommand,
107
+ `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${preview.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
104
108
  );
105
109
  cachePaths.push(".uv-cache/**/*");
106
110
  }
@@ -169,9 +169,19 @@ export function resolveStackName(config) {
169
169
  export function resolveStoragePreviewPrefix(config) {
170
170
  return config.storagePreviewPrefix ?? "preview/";
171
171
  }
172
+ function parseStagingPreview(value) {
173
+ if (value === undefined)
174
+ return undefined;
175
+ const record = requireObject(value, "cms.stagingPreview");
176
+ rejectUnknownKeys(record, ["buildCommand", "baseDirectory"], "cms.stagingPreview");
177
+ return {
178
+ buildCommand: requireString(record.buildCommand, "cms.stagingPreview.buildCommand"),
179
+ baseDirectory: requireString(record.baseDirectory, "cms.stagingPreview.baseDirectory"),
180
+ };
181
+ }
172
182
  function parseCms(value) {
173
183
  const cmsRecord = requireObject(value, "cms");
174
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
184
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
175
185
  const buildComputeType = cmsRecord.buildComputeType;
176
186
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
177
187
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -194,6 +204,7 @@ function parseCms(value) {
194
204
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
195
205
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
196
206
  redirects: parseRedirects(cmsRecord.redirects),
207
+ stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
197
208
  };
198
209
  const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
199
210
  if (reservedWord) {
@@ -306,8 +317,14 @@ export function parseSiteConfig(raw) {
306
317
  if (reader.domainPrefixes && !reader.domainName)
307
318
  fail("reader.domainPrefixes", "requires reader.domainName");
308
319
  }
309
- else if (frontend === "markus-static") {
310
- fail("reader", `is required when frontend is "markus-static"`);
320
+ else if (frontend === "markus-static" && cms === undefined) {
321
+ fail("reader", `is required when frontend is "markus-static" and there is no cms section`);
322
+ }
323
+ if (cms?.stagingPreview !== undefined && frontend !== "markus-static") {
324
+ fail("cms.stagingPreview", `is only allowed when frontend is "markus-static"`);
325
+ }
326
+ if (cms?.stagingPreview !== undefined && cms.staging === false) {
327
+ fail("cms.stagingPreview", "must not be set when cms.staging is false");
311
328
  }
312
329
  let github;
313
330
  if (record.github !== undefined) {
@@ -18,11 +18,22 @@ export type ReaderCustomHeaderRule = {
18
18
 
19
19
  export const READER_REDIRECT_STATUSES: ReaderRedirectStatus[] = ["301", "302", "200", "404", "404-200"];
20
20
 
21
+ /**
22
+ * What the CMS `staging` branch builds to preview drafts of a markus-static site: the site build
23
+ * command and the directory it writes. Without it, and without a `reader` to borrow them from,
24
+ * the staging branch builds the newsroom only.
25
+ */
26
+ export type StagingPreviewConfig = {
27
+ buildCommand: string;
28
+ baseDirectory: string;
29
+ };
30
+
21
31
  export type CmsConfig = {
22
32
  appName?: string;
23
33
  domainName?: string;
24
34
  domainPrefix?: string;
25
35
  staging?: boolean;
36
+ stagingPreview?: StagingPreviewConfig;
26
37
  cognitoDomainPrefix: string;
27
38
  applyCognitoDomainPrefix?: boolean;
28
39
  buildComputeType?: "STANDARD" | "STANDARD_8GB";
@@ -236,9 +247,19 @@ export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConf
236
247
  return config.storagePreviewPrefix ?? "preview/";
237
248
  }
238
249
 
250
+ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
251
+ if (value === undefined) return undefined;
252
+ const record = requireObject(value, "cms.stagingPreview");
253
+ rejectUnknownKeys(record, ["buildCommand", "baseDirectory"], "cms.stagingPreview");
254
+ return {
255
+ buildCommand: requireString(record.buildCommand, "cms.stagingPreview.buildCommand"),
256
+ baseDirectory: requireString(record.baseDirectory, "cms.stagingPreview.baseDirectory"),
257
+ };
258
+ }
259
+
239
260
  function parseCms(value: unknown): CmsConfig {
240
261
  const cmsRecord = requireObject(value, "cms");
241
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
262
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
242
263
  const buildComputeType = cmsRecord.buildComputeType;
243
264
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
244
265
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -266,6 +287,7 @@ function parseCms(value: unknown): CmsConfig {
266
287
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
267
288
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
268
289
  redirects: parseRedirects(cmsRecord.redirects),
290
+ stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
269
291
  };
270
292
 
271
293
  const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
@@ -384,8 +406,14 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
384
406
  skipContentExport: readerRecord.skipContentExport as boolean | undefined,
385
407
  };
386
408
  if (reader.domainPrefixes && !reader.domainName) fail("reader.domainPrefixes", "requires reader.domainName");
387
- } else if (frontend === "markus-static") {
388
- fail("reader", `is required when frontend is "markus-static"`);
409
+ } else if (frontend === "markus-static" && cms === undefined) {
410
+ fail("reader", `is required when frontend is "markus-static" and there is no cms section`);
411
+ }
412
+ if (cms?.stagingPreview !== undefined && frontend !== "markus-static") {
413
+ fail("cms.stagingPreview", `is only allowed when frontend is "markus-static"`);
414
+ }
415
+ if (cms?.stagingPreview !== undefined && cms.staging === false) {
416
+ fail("cms.stagingPreview", "must not be set when cms.staging is false");
389
417
  }
390
418
 
391
419
  let github: AmplifyAppShellSiteConfig["github"];
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Seconds a Pretext reader page may be served from a shared cache (Next sends `s-maxage` equal to its `revalidate`).
3
+ * Next needs a literal in each route's `export const revalidate`, so scripts/test-reader-cache-lifetime.ts keeps them equal to this.
4
+ */
5
+ export const READER_PAGE_REVALIDATE_SECONDS = 60;
package/package.json CHANGED
@@ -195,6 +195,7 @@
195
195
  "./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
196
196
  "./lib/publication-items": "./lib/publication-items.ts",
197
197
  "./lib/reader-base-path": "./lib/reader-base-path.ts",
198
+ "./lib/reader-cache-lifetime": "./lib/reader-cache-lifetime.ts",
198
199
  "./lib/reader-route-config": "./lib/reader-route-config.ts",
199
200
  "./lib/reader-static-params": "./lib/reader-static-params.ts",
200
201
  "./lib/reader-storage-url": "./lib/reader-storage-url.ts",
@@ -302,7 +303,7 @@
302
303
  "optional": true
303
304
  }
304
305
  },
305
- "version": "1.0.0-next.47",
306
+ "version": "1.0.0-next.49",
306
307
  "dependencies": {
307
308
  "@aws-amplify/adapter-nextjs": "^1.7.3",
308
309
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.47",
2
+ "version": "1.0.0-next.49",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -19,7 +19,7 @@
19
19
  "generateMetadata"
20
20
  ],
21
21
  "config": {
22
- "revalidate": "3600"
22
+ "revalidate": "60"
23
23
  }
24
24
  },
25
25
  {
@@ -30,7 +30,7 @@
30
30
  "generateStaticParams"
31
31
  ],
32
32
  "config": {
33
- "revalidate": "3600"
33
+ "revalidate": "60"
34
34
  }
35
35
  },
36
36
  {
@@ -39,7 +39,7 @@
39
39
  "hasDefault": true,
40
40
  "exports": [],
41
41
  "config": {
42
- "revalidate": "3600"
42
+ "revalidate": "60"
43
43
  }
44
44
  },
45
45
  {
@@ -48,7 +48,7 @@
48
48
  "hasDefault": true,
49
49
  "exports": [],
50
50
  "config": {
51
- "revalidate": "3600"
51
+ "revalidate": "60"
52
52
  }
53
53
  },
54
54
  {
@@ -58,7 +58,7 @@
58
58
  "GET"
59
59
  ],
60
60
  "config": {
61
- "revalidate": "3600"
61
+ "revalidate": "60"
62
62
  }
63
63
  },
64
64
  {
@@ -87,7 +87,7 @@
87
87
  "hasDefault": true,
88
88
  "exports": [],
89
89
  "config": {
90
- "revalidate": "3600"
90
+ "revalidate": "60"
91
91
  }
92
92
  },
93
93
  {
@@ -99,7 +99,7 @@
99
99
  "generateStaticParams"
100
100
  ],
101
101
  "config": {
102
- "revalidate": "3600"
102
+ "revalidate": "60"
103
103
  }
104
104
  },
105
105
  {
@@ -1,2 +1,3 @@
1
1
  import type { NextConfig } from "next";
2
2
  export function withPapyrus(nextConfig?: NextConfig, options?: { root?: string }): NextConfig;
3
+ export const READER_EXPIRE_TIME_SECONDS: number;
@@ -23,6 +23,13 @@ const SERVER_EXTERNAL = [
23
23
  "@aws-amplify/data-schema",
24
24
  ];
25
25
 
26
+ /**
27
+ * Next sends `stale-while-revalidate` = `expireTime` - `revalidate` (one year by default), so a shared cache may serve a
28
+ * page for a year past its `s-maxage` while it refreshes in the background. Twice the reader's 60 s page lifetime
29
+ * (lib/reader-cache-lifetime.ts) bounds that to 60 s.
30
+ */
31
+ export const READER_EXPIRE_TIME_SECONDS = 120;
32
+
26
33
  export const DEFAULT_TRANSPILE_PACKAGES = ["@anthusai/papyrus", "cyclotron"];
27
34
 
28
35
  export function mergeTranspilePackages(existing = []) {
@@ -54,6 +61,7 @@ export function withPapyrus(nextConfig = {}, options = {}) {
54
61
  };
55
62
  return {
56
63
  distDir: process.env.NEXT_DIST_DIR || ".next",
64
+ expireTime: READER_EXPIRE_TIME_SECONDS,
57
65
  ...nextConfig,
58
66
  transpilePackages: mergeTranspilePackages(nextConfig.transpilePackages),
59
67
  serverExternalPackages: [...new Set([...(nextConfig.serverExternalPackages ?? []), ...SERVER_EXTERNAL])],