@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.
- package/amplify/functions/content-actions/handler.py +18 -0
- package/amplify/functions/renditions/handler.py +35 -9
- package/amplify/publication/invalidation-alarm.js +26 -0
- package/amplify/publication/invalidation-alarm.ts +30 -0
- package/amplify/publication/renditions-settings.js +11 -3
- package/amplify/publication/renditions-settings.ts +11 -3
- package/amplify/publication/s3-projection-settings.js +5 -1
- package/amplify/publication/s3-projection-settings.ts +4 -1
- package/amplify/publication/s3-projection.js +8 -1
- package/amplify/publication/s3-projection.ts +8 -1
- package/amplify/revalidate-invalidation-settings.js +30 -0
- package/amplify/revalidate-invalidation-settings.ts +35 -0
- package/amplify/site-backend-config.js +11 -0
- package/amplify/site-backend-config.ts +26 -0
- package/amplify/site-backend.js +18 -0
- package/amplify/site-backend.ts +24 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +1 -1
- package/app/api/archive/editions/route.ts +1 -1
- package/app/archive/page.tsx +1 -1
- package/app/articles/[slug]/page.tsx +1 -1
- package/lib/reader-cache-lifetime.ts +5 -0
- package/package.json +2 -1
- package/routes.manifest.json +8 -8
- package/src/with-papyrus.d.mts +1 -0
- package/src/with-papyrus.mjs +8 -0
|
@@ -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
|
|
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
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
package/amplify/site-backend.js
CHANGED
|
@@ -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);
|
package/amplify/site-backend.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
6
|
+
export const revalidate = 60;
|
|
7
7
|
|
|
8
8
|
export async function GET(request: Request) {
|
|
9
9
|
const url = new URL(request.url);
|
package/app/archive/page.tsx
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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.
|
|
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",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.0.0-next.
|
|
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": "
|
|
22
|
+
"revalidate": "60"
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
{
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"generateStaticParams"
|
|
31
31
|
],
|
|
32
32
|
"config": {
|
|
33
|
-
"revalidate": "
|
|
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": "
|
|
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": "
|
|
51
|
+
"revalidate": "60"
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
{
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"GET"
|
|
59
59
|
],
|
|
60
60
|
"config": {
|
|
61
|
-
"revalidate": "
|
|
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": "
|
|
90
|
+
"revalidate": "60"
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
{
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"generateStaticParams"
|
|
100
100
|
],
|
|
101
101
|
"config": {
|
|
102
|
-
"revalidate": "
|
|
102
|
+
"revalidate": "60"
|
|
103
103
|
}
|
|
104
104
|
},
|
|
105
105
|
{
|
package/src/with-papyrus.d.mts
CHANGED
package/src/with-papyrus.mjs
CHANGED
|
@@ -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])],
|