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

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.
@@ -4,6 +4,7 @@ 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
10
  from papyrus_content.projection.engine import project, project_item_pages
@@ -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,15 @@ 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") == "invalidate":
159
+ invalidate = projection_context(client).invalidate
160
+ if invalidate is None:
161
+ return {"ok": True, "paths": []}
162
+ return {"ok": True, "paths": invalidate(list(task["keys"]))}
150
163
  else:
151
164
  raise ProjectionTaskFailed(f"Unknown projection task {task.get('task')!r}")
165
+ if report.unpurged:
166
+ schedule_unpurged_invalidation(report.unpurged)
152
167
  if not report.ok:
153
168
  raise ProjectionTaskFailed("; ".join(report.errors))
154
169
  return {"ok": True, "paths": [*report.written, *report.deleted]}
@@ -193,6 +208,9 @@ def site_triggers_for(client, item_id: str) -> dict:
193
208
  slugs = [item["slug"]] if item.get("slug") else []
194
209
  revalidated = trigger_pretext_revalidation(slugs, None, base_url=revalidate_base_url)
195
210
  triggers["revalidated"] = revalidated if revalidated is not None else {"ok": False, "reason": "not-configured"}
211
+ distribution_ids = distribution_ids_from_environment(os.environ)
212
+ if distribution_ids and revalidated is not None:
213
+ triggers["edgeInvalidated"] = invalidate_revalidated_paths(distribution_ids, revalidated)
196
214
  except Exception as error: # noqa: BLE001 - a trigger failure never fails the publish
197
215
  triggers["revalidated"] = {"ok": False, "error": f"{type(error).__name__}: {error}"}
198
216
  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,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
 
@@ -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,16 @@ 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
+ const cache = config.cache;
31
+ if (cache !== undefined) {
32
+ const lifetime = cache.htmlSMaxAgeSeconds;
33
+ if (lifetime !== undefined && (!Number.isInteger(lifetime) || lifetime < 0 || lifetime > MAXIMUM_HTML_S_MAXAGE_SECONDS)) {
34
+ problems.push(`publication.cache.htmlSMaxAgeSeconds must be an integer from 0 to ${MAXIMUM_HTML_S_MAXAGE_SECONDS}`);
35
+ }
36
+ if (cache.invalidation !== undefined && cache.invalidation !== "targeted" && cache.invalidation !== "off") {
37
+ problems.push('publication.cache.invalidation must be "targeted" or "off"');
38
+ }
39
+ }
29
40
  const distribution = config.distribution;
30
41
  if (distribution !== undefined) {
31
42
  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,6 +46,8 @@ 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. */
@@ -72,6 +83,16 @@ export function validatePublicationConfig(publication: unknown): string[] {
72
83
  if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
73
84
  problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
74
85
  }
86
+ const cache = config.cache;
87
+ if (cache !== undefined) {
88
+ const lifetime = cache.htmlSMaxAgeSeconds;
89
+ if (lifetime !== undefined && (!Number.isInteger(lifetime) || lifetime < 0 || lifetime > MAXIMUM_HTML_S_MAXAGE_SECONDS)) {
90
+ problems.push(`publication.cache.htmlSMaxAgeSeconds must be an integer from 0 to ${MAXIMUM_HTML_S_MAXAGE_SECONDS}`);
91
+ }
92
+ if (cache.invalidation !== undefined && cache.invalidation !== "targeted" && cache.invalidation !== "off") {
93
+ problems.push('publication.cache.invalidation must be "targeted" or "off"');
94
+ }
95
+ }
75
96
  const distribution = config.distribution;
76
97
  if (distribution !== undefined) {
77
98
  if (config.edgeDistributionArn !== undefined && distribution.create === true) {
@@ -119,6 +140,11 @@ export type PapyrusSiteBackendConfig = {
119
140
  stagingBuild?: { enabled: boolean };
120
141
  /** 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
142
  revalidateBaseUrl?: string;
143
+ /**
144
+ * CloudFront distribution ids (same account) in front of that reader. After a publish refreshes the reader's
145
+ * cache, content-actions invalidates the revalidated paths on each of them. Needs `revalidateBaseUrl`.
146
+ */
147
+ revalidateDistributionIds?: string[];
122
148
  /**
123
149
  * SES receipt options for a site that is not the legacy p.apyr.us backend.
124
150
  * By default the site gets its own brand-named rule set that is never
@@ -13,6 +13,8 @@ import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publ
13
13
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
14
14
  import { assertValidPublicationConfig } from "./site-backend-config.js";
15
15
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
16
+ import { revalidateDistributionEnvironment, revalidateInvalidationStatement, validateRevalidateDistributionIds, } from "./revalidate-invalidation-settings.js";
17
+ import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
16
18
  import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
17
19
  import { data } from "./data/resource.js";
18
20
  import { assignmentAction } from "./functions/assignment-action/resource.js";
@@ -343,6 +345,22 @@ export function defineSiteBackend(papyrusSite) {
343
345
  account: revalidateStack.account,
344
346
  parameterName: revalidateSecretParameter,
345
347
  })));
348
+ const distributionProblems = validateRevalidateDistributionIds(site.revalidateDistributionIds, site.revalidateBaseUrl);
349
+ if (distributionProblems.length > 0)
350
+ throw new Error(`Invalid backend config: ${distributionProblems.join("; ")}`);
351
+ const distributionIds = site.revalidateDistributionIds ?? [];
352
+ if (distributionIds.length > 0) {
353
+ for (const [name, value] of Object.entries(revalidateDistributionEnvironment(distributionIds))) {
354
+ backend.contentActions.addEnvironment(name, value);
355
+ }
356
+ contentActionsLambda.addToRolePolicy(new PolicyStatement(revalidateInvalidationStatement({ partition: revalidateStack.partition, account: revalidateStack.account, distributionIds })));
357
+ for (const id of distributionIds) {
358
+ addInvalidationPathAlarm(revalidateStack, `ReaderEdgeInvalidationPaths${id}`, id);
359
+ }
360
+ }
361
+ }
362
+ else if (site.revalidateDistributionIds !== undefined) {
363
+ throw new Error("Invalid backend config: revalidateDistributionIds requires revalidateBaseUrl");
346
364
  }
347
365
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
348
366
  grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
@@ -16,6 +16,12 @@ import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publ
16
16
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
17
17
  import { assertValidPublicationConfig } from "./site-backend-config";
18
18
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy";
19
+ import {
20
+ revalidateDistributionEnvironment,
21
+ revalidateInvalidationStatement,
22
+ validateRevalidateDistributionIds,
23
+ } from "./revalidate-invalidation-settings";
24
+ import { addInvalidationPathAlarm } from "./publication/invalidation-alarm";
19
25
  import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
20
26
  import { data } from "./data/resource";
21
27
  import { assignmentAction } from "./functions/assignment-action/resource";
@@ -434,6 +440,24 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
434
440
  }),
435
441
  ),
436
442
  );
443
+ const distributionProblems = validateRevalidateDistributionIds(site.revalidateDistributionIds, site.revalidateBaseUrl);
444
+ if (distributionProblems.length > 0) throw new Error(`Invalid backend config: ${distributionProblems.join("; ")}`);
445
+ const distributionIds = site.revalidateDistributionIds ?? [];
446
+ if (distributionIds.length > 0) {
447
+ for (const [name, value] of Object.entries(revalidateDistributionEnvironment(distributionIds))) {
448
+ backend.contentActions.addEnvironment(name, value);
449
+ }
450
+ contentActionsLambda.addToRolePolicy(
451
+ new PolicyStatement(
452
+ revalidateInvalidationStatement({ partition: revalidateStack.partition, account: revalidateStack.account, distributionIds }),
453
+ ),
454
+ );
455
+ for (const id of distributionIds) {
456
+ addInvalidationPathAlarm(revalidateStack, `ReaderEdgeInvalidationPaths${id}`, id);
457
+ }
458
+ }
459
+ } else if (site.revalidateDistributionIds !== undefined) {
460
+ throw new Error("Invalid backend config: revalidateDistributionIds requires revalidateBaseUrl");
437
461
  }
438
462
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
439
463
  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();
@@ -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.48",
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.48",
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])],