@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.
- package/amplify/functions/content-actions/handler.py +23 -1
- package/amplify/functions/renditions/handler.py +35 -9
- package/amplify/publication/default-publication.js +18 -0
- package/amplify/publication/default-publication.ts +20 -0
- 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/reproject-on-deploy.js +31 -0
- package/amplify/publication/reproject-on-deploy.ts +36 -0
- 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 +14 -0
- package/amplify/site-backend-config.ts +41 -2
- package/amplify/site-backend.js +31 -6
- package/amplify/site-backend.ts +36 -5
- 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/infra/build-specs.js +6 -3
- package/infra/build-specs.ts +8 -4
- package/infra/site-config.js +20 -3
- package/infra/site-config.ts +31 -3
- 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,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
|
|
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,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
|
|
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
|
|
|
@@ -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 {
|
|
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,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
|
-
/**
|
|
134
|
-
|
|
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;
|
package/amplify/site-backend.js
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
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 (
|
|
316
|
-
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"),
|
|
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);
|
package/amplify/site-backend.ts
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
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 (
|
|
401
|
-
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"),
|
|
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 =
|
|
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();
|
package/infra/build-specs.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
preBuild.push(...pythonProvisioningCommands(config), `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`,
|
|
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 [
|
package/infra/build-specs.ts
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
`${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${
|
|
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
|
}
|
package/infra/site-config.js
CHANGED
|
@@ -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) {
|
package/infra/site-config.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.0.0-next.
|
|
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": "
|
|
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])],
|