@anthusai/papyrus 1.0.0-next.52 → 1.0.0-next.54
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 +15 -2
- package/amplify/publication/reproject-on-deploy.js +63 -21
- package/amplify/publication/reproject-on-deploy.ts +66 -24
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +5 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +5 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +5 -0
- package/app/api/revalidate/route.ts +7 -0
- package/app/page.tsx +4 -3
- package/lib/graphql-content-repository.ts +14 -11
- package/lib/rate-limit-retry.ts +27 -0
- package/lib/reader-static-params.ts +73 -5
- package/lib/revalidate-prewarm.ts +53 -0
- package/package.json +3 -1
- package/routes.manifest.json +9 -4
|
@@ -7,6 +7,7 @@ from typing import Any
|
|
|
7
7
|
from papyrus_content.edge_invalidation import distribution_ids_from_environment, invalidate_revalidated_paths
|
|
8
8
|
from papyrus_content.graphql_authoring import create_authoring_client
|
|
9
9
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
10
|
+
from papyrus_content.projection.deploy_render import deploy_render, drift_metric_line
|
|
10
11
|
from papyrus_content.projection.engine import project, project_all, project_item_pages
|
|
11
12
|
from papyrus_content.projection.runtime import (
|
|
12
13
|
DEAD_LETTER_QUEUE_ENV,
|
|
@@ -146,6 +147,18 @@ def schedule_unpurged_invalidation(keys: list[str]) -> None:
|
|
|
146
147
|
schedule_projection_task({"task": "invalidate", "keys": keys}, "edge invalidation failed")
|
|
147
148
|
|
|
148
149
|
|
|
150
|
+
def run_deploy_render_task(client) -> dict:
|
|
151
|
+
"""Render every published page after a backend deploy, then report drift (a dry run: nothing is deleted)."""
|
|
152
|
+
context = projection_context(client)
|
|
153
|
+
report = deploy_render(context)
|
|
154
|
+
print(drift_metric_line(report))
|
|
155
|
+
if report.render.unpurged:
|
|
156
|
+
schedule_unpurged_invalidation(report.render.unpurged)
|
|
157
|
+
if not report.ok:
|
|
158
|
+
raise ProjectionTaskFailed("; ".join([*report.render.errors, *report.drift.errors]))
|
|
159
|
+
return report.to_dict()
|
|
160
|
+
|
|
161
|
+
|
|
149
162
|
def run_projection_task(task: dict) -> dict:
|
|
150
163
|
"""An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
|
|
151
164
|
client, _claims = create_authoring_client()
|
|
@@ -157,8 +170,8 @@ def run_projection_task(task: dict) -> dict:
|
|
|
157
170
|
schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
|
|
158
171
|
elif task.get("task") == "all":
|
|
159
172
|
report = project_all(projection_context(client))
|
|
160
|
-
elif task.get("task") == "
|
|
161
|
-
|
|
173
|
+
elif task.get("task") == "deploy-render":
|
|
174
|
+
return run_deploy_render_task(client)
|
|
162
175
|
elif task.get("task") == "invalidate":
|
|
163
176
|
invalidate = projection_context(client).invalidate
|
|
164
177
|
if invalidate is None:
|
|
@@ -1,31 +1,73 @@
|
|
|
1
|
+
import { CustomResource, Duration } from "aws-cdk-lib";
|
|
2
|
+
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
|
1
3
|
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import { Provider } from "aws-cdk-lib/custom-resources";
|
|
6
|
+
export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 900;
|
|
7
|
+
export const DEPLOY_RENDER_METRIC_NAMESPACE = "Papyrus/Projection";
|
|
8
|
+
export const DEPLOY_RENDER_DRIFT_METRIC_NAME = "DeployRenderDrift";
|
|
9
|
+
export const DEPLOY_RENDER_TASK = "deploy-render";
|
|
10
|
+
const FAILURE_MESSAGE_LIMIT = 600;
|
|
11
|
+
const RESULT_SUMMARY_LIMIT = 900;
|
|
4
12
|
export function reprojectDeployToken(environment, now = Date.now) {
|
|
5
13
|
const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
|
|
6
14
|
return buildJobId !== "" ? `job-${buildJobId}` : `time-${now()}`;
|
|
7
15
|
}
|
|
8
16
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
17
|
+
* Runs inside the deploy: invokes the content-actions Lambda synchronously, so a render that fails fails the
|
|
18
|
+
* stack update (and with it the Amplify build) with the failure in the build log. The Lambda's result, which
|
|
19
|
+
* includes the drift check, is written to its log and returned as the resource's data.
|
|
20
|
+
*/
|
|
21
|
+
export const DEPLOY_RENDER_HANDLER_SOURCE = `
|
|
22
|
+
const { LambdaClient, InvokeCommand } = require("@aws-sdk/client-lambda");
|
|
23
|
+
|
|
24
|
+
exports.handler = async (event) => {
|
|
25
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
26
|
+
const properties = event.ResourceProperties;
|
|
27
|
+
const client = new LambdaClient({ maxAttempts: 1 });
|
|
28
|
+
const response = await client.send(new InvokeCommand({
|
|
29
|
+
FunctionName: properties.FunctionName,
|
|
30
|
+
InvocationType: "RequestResponse",
|
|
31
|
+
Payload: JSON.stringify({ projectionTask: { task: properties.Task, deployToken: properties.DeployToken } }),
|
|
32
|
+
}));
|
|
33
|
+
const text = Buffer.from(response.Payload || []).toString("utf8");
|
|
34
|
+
console.log("deploy render result: " + text);
|
|
35
|
+
if (response.FunctionError) {
|
|
36
|
+
throw new Error("The post-deploy render failed (" + response.FunctionError + "): " + text.slice(0, ${FAILURE_MESSAGE_LIMIT}));
|
|
37
|
+
}
|
|
38
|
+
return { PhysicalResourceId: "reproject-" + properties.DeployToken, Data: { Result: text.slice(0, ${RESULT_SUMMARY_LIMIT}) } };
|
|
39
|
+
};
|
|
40
|
+
`;
|
|
41
|
+
/**
|
|
42
|
+
* Renders every published page after each backend deploy and checks the site store for drift (a dry run:
|
|
43
|
+
* nothing is deleted). The token is a property, so CloudFormation runs the resource again on every deploy.
|
|
11
44
|
*/
|
|
12
45
|
export function addReprojectOnDeploy(stack, contentActions, deployToken) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
46
|
+
const handler = new lambda.Function(stack, "DeployRenderHandler", {
|
|
47
|
+
runtime: lambda.Runtime.NODEJS_20_X,
|
|
48
|
+
handler: "index.handler",
|
|
49
|
+
code: lambda.Code.fromInline(DEPLOY_RENDER_HANDLER_SOURCE),
|
|
50
|
+
timeout: Duration.seconds(REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS),
|
|
51
|
+
description: "Starts the post-deploy render of the site and fails the deploy if it cannot finish.",
|
|
52
|
+
});
|
|
53
|
+
handler.addToRolePolicy(new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }));
|
|
54
|
+
const provider = new Provider(stack, "DeployRenderProvider", { onEventHandler: handler });
|
|
55
|
+
new cloudwatch.Alarm(stack, "DeployRenderDriftAlarm", {
|
|
56
|
+
metric: new cloudwatch.Metric({
|
|
57
|
+
namespace: DEPLOY_RENDER_METRIC_NAMESPACE,
|
|
58
|
+
metricName: DEPLOY_RENDER_DRIFT_METRIC_NAME,
|
|
59
|
+
dimensionsMap: { FunctionName: contentActions.functionName },
|
|
60
|
+
statistic: "Maximum",
|
|
61
|
+
period: Duration.days(1),
|
|
62
|
+
}),
|
|
63
|
+
evaluationPeriods: 1,
|
|
64
|
+
threshold: 1,
|
|
65
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
66
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
67
|
+
alarmDescription: "The last post-deploy render found drift between the published content and the site store (stale pages, orphan pages or redirects). Nothing was deleted; run papyrus ops content project --reconcile to repair.",
|
|
68
|
+
});
|
|
69
|
+
return new CustomResource(stack, "ReprojectOnDeploy", {
|
|
70
|
+
serviceToken: provider.serviceToken,
|
|
71
|
+
properties: { FunctionName: contentActions.functionName, Task: DEPLOY_RENDER_TASK, DeployToken: deployToken },
|
|
30
72
|
});
|
|
31
73
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { Stack } from "aws-cdk-lib";
|
|
1
|
+
import { CustomResource, Duration, Stack } from "aws-cdk-lib";
|
|
2
|
+
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
|
2
3
|
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import { Provider } from "aws-cdk-lib/custom-resources";
|
|
5
6
|
|
|
6
|
-
export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS =
|
|
7
|
+
export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 900;
|
|
8
|
+
export const DEPLOY_RENDER_METRIC_NAMESPACE = "Papyrus/Projection";
|
|
9
|
+
export const DEPLOY_RENDER_DRIFT_METRIC_NAME = "DeployRenderDrift";
|
|
10
|
+
export const DEPLOY_RENDER_TASK = "deploy-render";
|
|
11
|
+
const FAILURE_MESSAGE_LIMIT = 600;
|
|
12
|
+
const RESULT_SUMMARY_LIMIT = 900;
|
|
7
13
|
|
|
8
14
|
export function reprojectDeployToken(environment: Record<string, string | undefined>, now: () => number = Date.now): string {
|
|
9
15
|
const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
|
|
@@ -11,26 +17,62 @@ export function reprojectDeployToken(environment: Record<string, string | undefi
|
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
20
|
+
* Runs inside the deploy: invokes the content-actions Lambda synchronously, so a render that fails fails the
|
|
21
|
+
* stack update (and with it the Amplify build) with the failure in the build log. The Lambda's result, which
|
|
22
|
+
* includes the drift check, is written to its log and returned as the resource's data.
|
|
16
23
|
*/
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
export const DEPLOY_RENDER_HANDLER_SOURCE = `
|
|
25
|
+
const { LambdaClient, InvokeCommand } = require("@aws-sdk/client-lambda");
|
|
26
|
+
|
|
27
|
+
exports.handler = async (event) => {
|
|
28
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
29
|
+
const properties = event.ResourceProperties;
|
|
30
|
+
const client = new LambdaClient({ maxAttempts: 1 });
|
|
31
|
+
const response = await client.send(new InvokeCommand({
|
|
32
|
+
FunctionName: properties.FunctionName,
|
|
33
|
+
InvocationType: "RequestResponse",
|
|
34
|
+
Payload: JSON.stringify({ projectionTask: { task: properties.Task, deployToken: properties.DeployToken } }),
|
|
35
|
+
}));
|
|
36
|
+
const text = Buffer.from(response.Payload || []).toString("utf8");
|
|
37
|
+
console.log("deploy render result: " + text);
|
|
38
|
+
if (response.FunctionError) {
|
|
39
|
+
throw new Error("The post-deploy render failed (" + response.FunctionError + "): " + text.slice(0, ${FAILURE_MESSAGE_LIMIT}));
|
|
40
|
+
}
|
|
41
|
+
return { PhysicalResourceId: "reproject-" + properties.DeployToken, Data: { Result: text.slice(0, ${RESULT_SUMMARY_LIMIT}) } };
|
|
42
|
+
};
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders every published page after each backend deploy and checks the site store for drift (a dry run:
|
|
47
|
+
* nothing is deleted). The token is a property, so CloudFormation runs the resource again on every deploy.
|
|
48
|
+
*/
|
|
49
|
+
export function addReprojectOnDeploy(stack: Stack, contentActions: lambda.IFunction, deployToken: string): CustomResource {
|
|
50
|
+
const handler = new lambda.Function(stack, "DeployRenderHandler", {
|
|
51
|
+
runtime: lambda.Runtime.NODEJS_20_X,
|
|
52
|
+
handler: "index.handler",
|
|
53
|
+
code: lambda.Code.fromInline(DEPLOY_RENDER_HANDLER_SOURCE),
|
|
54
|
+
timeout: Duration.seconds(REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS),
|
|
55
|
+
description: "Starts the post-deploy render of the site and fails the deploy if it cannot finish.",
|
|
56
|
+
});
|
|
57
|
+
handler.addToRolePolicy(new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }));
|
|
58
|
+
const provider = new Provider(stack, "DeployRenderProvider", { onEventHandler: handler });
|
|
59
|
+
new cloudwatch.Alarm(stack, "DeployRenderDriftAlarm", {
|
|
60
|
+
metric: new cloudwatch.Metric({
|
|
61
|
+
namespace: DEPLOY_RENDER_METRIC_NAMESPACE,
|
|
62
|
+
metricName: DEPLOY_RENDER_DRIFT_METRIC_NAME,
|
|
63
|
+
dimensionsMap: { FunctionName: contentActions.functionName },
|
|
64
|
+
statistic: "Maximum",
|
|
65
|
+
period: Duration.days(1),
|
|
66
|
+
}),
|
|
67
|
+
evaluationPeriods: 1,
|
|
68
|
+
threshold: 1,
|
|
69
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
70
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
71
|
+
alarmDescription:
|
|
72
|
+
"The last post-deploy render found drift between the published content and the site store (stale pages, orphan pages or redirects). Nothing was deleted; run papyrus ops content project --reconcile to repair.",
|
|
73
|
+
});
|
|
74
|
+
return new CustomResource(stack, "ReprojectOnDeploy", {
|
|
75
|
+
serviceToken: provider.serviceToken,
|
|
76
|
+
properties: { FunctionName: contentActions.functionName, Task: DEPLOY_RENDER_TASK, DeployToken: deployToken },
|
|
35
77
|
});
|
|
36
78
|
}
|
|
@@ -4,9 +4,14 @@ import { getCachedEditionItem, loadCachedEditionContent } from "../../../../../l
|
|
|
4
4
|
import { getEditionDatePath, parseEditionArticleRoute } from "../../../../../lib/edition-routes";
|
|
5
5
|
import { buildPresentationFooterEntries } from "../../../../../lib/presentation-footer";
|
|
6
6
|
import { SITE_BRAND } from "../../../../../lib/site-brand";
|
|
7
|
+
import { generateEditionItemStaticParams } from "../../../../../lib/reader-static-params";
|
|
7
8
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
8
9
|
export const revalidate = 60;
|
|
9
10
|
|
|
11
|
+
export async function generateStaticParams() {
|
|
12
|
+
return generateEditionItemStaticParams();
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
type DateScopedArticlePageProps = {
|
|
11
16
|
params: Promise<{
|
|
12
17
|
year: string;
|
|
@@ -2,9 +2,14 @@ import { notFound, redirect } from "next/navigation";
|
|
|
2
2
|
import { EditionRoutePage } from "../../edition-route-page";
|
|
3
3
|
import { parseEditionPageRoute } from "../../../../../../lib/edition-routes";
|
|
4
4
|
import { getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
|
|
5
|
+
import { generateEditionPageStaticParams } from "../../../../../../lib/reader-static-params";
|
|
5
6
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
6
7
|
export const revalidate = 60;
|
|
7
8
|
|
|
9
|
+
export async function generateStaticParams() {
|
|
10
|
+
return generateEditionPageStaticParams();
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
type EditionPageRouteProps = {
|
|
9
14
|
params: Promise<{
|
|
10
15
|
year: string;
|
|
@@ -2,9 +2,14 @@ import { notFound, redirect } from "next/navigation";
|
|
|
2
2
|
import { EditionRoutePage } from "../../edition-route-page";
|
|
3
3
|
import { createSectionKey } from "../../../../../../lib/edition-sections";
|
|
4
4
|
import { parseEditionSectionRoute } from "../../../../../../lib/edition-routes";
|
|
5
|
+
import { generateEditionSectionStaticParams } from "../../../../../../lib/reader-static-params";
|
|
5
6
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
6
7
|
export const revalidate = 60;
|
|
7
8
|
|
|
9
|
+
export async function generateStaticParams() {
|
|
10
|
+
return generateEditionSectionStaticParams();
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
type EditionSectionRouteProps = {
|
|
9
14
|
params: Promise<{
|
|
10
15
|
year: string;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
editionItemCacheTag,
|
|
11
11
|
} from "../../../lib/cached-content-repository";
|
|
12
12
|
import { isAuthorizedRevalidateRequest } from "../../../lib/revalidate-secret";
|
|
13
|
+
import { prewarmPaths, resolvePrewarmOrigin } from "../../../lib/revalidate-prewarm";
|
|
13
14
|
import { getEditionArticlePath, getEditionDatePath } from "../../../lib/edition-routes";
|
|
14
15
|
|
|
15
16
|
export const dynamic = "force-dynamic";
|
|
@@ -20,6 +21,7 @@ type RevalidateRequestBody = {
|
|
|
20
21
|
itemSlugs?: string[];
|
|
21
22
|
paths?: string[];
|
|
22
23
|
tags?: string[];
|
|
24
|
+
prewarm?: boolean;
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
export async function POST(request: Request) {
|
|
@@ -87,9 +89,14 @@ export async function POST(request: Request) {
|
|
|
87
89
|
revalidatedPaths.add(normalized);
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
const prewarmed = body.prewarm === false
|
|
93
|
+
? []
|
|
94
|
+
: await prewarmPaths(resolvePrewarmOrigin(request.url), [...revalidatedPaths]);
|
|
95
|
+
|
|
90
96
|
return NextResponse.json({
|
|
91
97
|
ok: true,
|
|
92
98
|
revalidatedTags: [...revalidatedTags],
|
|
93
99
|
revalidatedPaths: [...revalidatedPaths],
|
|
100
|
+
prewarmed,
|
|
94
101
|
});
|
|
95
102
|
}
|
package/app/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { getSiteRenderer } from "../lib/site-renderer";
|
|
|
2
2
|
import { contentRepository, getScenarioIdParam } from "../lib/content-repository";
|
|
3
3
|
import type { EditionContent } from "../lib/content-types";
|
|
4
4
|
import { createEditionSectionPlan } from "../lib/edition-sections";
|
|
5
|
+
import { getCachedLatestPublishedEdition, loadCachedEditionContent } from "../lib/cached-content-repository";
|
|
5
6
|
import { getEditionDatePath } from "../lib/edition-routes";
|
|
6
7
|
import {
|
|
7
8
|
EMPTY_EDITION_PLACEHOLDER_SLUG,
|
|
@@ -77,7 +78,7 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|
|
77
78
|
);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
const latestEdition = await
|
|
81
|
+
const latestEdition = await getCachedLatestPublishedEdition();
|
|
81
82
|
if (latestEdition) redirect(getEditionDatePath(latestEdition.editionDate));
|
|
82
83
|
return <siteRenderer.renderEdition content={createEmptyGraphQLEdition()} />;
|
|
83
84
|
}
|
|
@@ -88,9 +89,9 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
async function loadLatestGraphQLEdition(): Promise<EditionContent | null> {
|
|
91
|
-
const latestEdition = await
|
|
92
|
+
const latestEdition = await getCachedLatestPublishedEdition();
|
|
92
93
|
if (!latestEdition) return null;
|
|
93
|
-
return
|
|
94
|
+
return loadCachedEditionContent({ editionDate: latestEdition.editionDate });
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
async function loadFirstPublishedEditionPath(): Promise<string | undefined> {
|
|
@@ -21,6 +21,7 @@ import { SITE_BRAND } from "./site-brand";
|
|
|
21
21
|
import { shouldFetchVideoScripts } from "./video-mode";
|
|
22
22
|
import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
|
|
23
23
|
import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
|
|
24
|
+
import { withRateLimitRetry } from "./rate-limit-retry";
|
|
24
25
|
import { getContentSource } from "./site-env";
|
|
25
26
|
|
|
26
27
|
const DEFAULT_EDITION_SLUG = "current";
|
|
@@ -157,17 +158,19 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
|
|
|
157
158
|
return runWithContentSource(source, operation, client);
|
|
158
159
|
}
|
|
159
160
|
return runWithContentSource(source, () =>
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
161
|
+
withRateLimitRetry(() =>
|
|
162
|
+
runWithAmplifyServerContext({
|
|
163
|
+
nextServerContext: null,
|
|
164
|
+
operation: async () => {
|
|
165
|
+
readerContextClient = generateClient<Schema>({ authMode: authMode() });
|
|
166
|
+
try {
|
|
167
|
+
return await operation();
|
|
168
|
+
} finally {
|
|
169
|
+
readerContextClient = null;
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
171
174
|
);
|
|
172
175
|
}
|
|
173
176
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const RATE_LIMIT_RETRY_ATTEMPTS = 6;
|
|
2
|
+
export const RATE_LIMIT_RETRY_BASE_MILLISECONDS = 300;
|
|
3
|
+
|
|
4
|
+
export function isRateLimitError(error: unknown): boolean {
|
|
5
|
+
if (!error || typeof error !== "object") return false;
|
|
6
|
+
const { name, message } = error as { name?: unknown; message?: unknown };
|
|
7
|
+
const text = `${typeof name === "string" ? name : ""} ${typeof message === "string" ? message : ""}`;
|
|
8
|
+
return /TooManyRequests|ThrottlingException|Rate exceeded|Unauthorized[\s\S]*set the \\?"authMode\\?"/i.test(text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function withRateLimitRetry<T>(
|
|
12
|
+
operation: () => Promise<T>,
|
|
13
|
+
options: { attempts?: number; baseMilliseconds?: number; sleep?: (milliseconds: number) => Promise<void>; random?: () => number } = {},
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
const attempts = options.attempts ?? RATE_LIMIT_RETRY_ATTEMPTS;
|
|
16
|
+
const baseMilliseconds = options.baseMilliseconds ?? RATE_LIMIT_RETRY_BASE_MILLISECONDS;
|
|
17
|
+
const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
18
|
+
const random = options.random ?? Math.random;
|
|
19
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
20
|
+
try {
|
|
21
|
+
return await operation();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (attempt >= attempts || !isRateLimitError(error)) throw error;
|
|
24
|
+
await sleep(baseMilliseconds * 2 ** (attempt - 1) * (0.5 + random()));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ContentRepository, EditionContent } from "./content-types";
|
|
1
2
|
import { graphqlContentRepository } from "./graphql-content-repository";
|
|
2
3
|
import { SITE_BRAND } from "./site-brand";
|
|
3
4
|
import { getContentSource } from "./site-env";
|
|
@@ -32,11 +33,23 @@ type EditionDateStaticParam = {
|
|
|
32
33
|
day: string;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const PUBLISHED_EDITION_ENUMERATION_LIMIT = 100;
|
|
37
|
+
const DEEP_ROUTE_EDITION_LIMIT = 10;
|
|
38
|
+
const RESERVED_DATE_CHILD_SEGMENTS = new Set(["page", "section"]);
|
|
39
|
+
|
|
40
|
+
type EnumerationRepository = Pick<ContentRepository, "listPublishedEditions" | "loadEditionContent">;
|
|
41
|
+
|
|
42
|
+
function shouldEnumerateAtBuild(): boolean {
|
|
43
|
+
if (process.env.NODE_ENV === "development") return false;
|
|
44
|
+
return siteEnumeratesReaderRoutesAtBuild();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function generateEditionDateStaticParams(
|
|
48
|
+
repository: EnumerationRepository = graphqlContentRepository,
|
|
49
|
+
): Promise<EditionDateStaticParam[]> {
|
|
50
|
+
if (!shouldEnumerateAtBuild()) return [];
|
|
38
51
|
|
|
39
|
-
const { editions } = await
|
|
52
|
+
const { editions } = await repository.listPublishedEditions({ limit: PUBLISHED_EDITION_ENUMERATION_LIMIT });
|
|
40
53
|
const params: EditionDateStaticParam[] = [];
|
|
41
54
|
for (const edition of editions) {
|
|
42
55
|
const route = editionDateToStaticParam(edition.editionDate);
|
|
@@ -45,6 +58,61 @@ export async function generateEditionDateStaticParams(): Promise<EditionDateStat
|
|
|
45
58
|
return params;
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
async function loadRecentEditionContents(repository: EnumerationRepository): Promise<EditionContent[]> {
|
|
62
|
+
const { editions } = await repository.listPublishedEditions({ limit: PUBLISHED_EDITION_ENUMERATION_LIMIT });
|
|
63
|
+
const recent = [...editions]
|
|
64
|
+
.sort((left, right) => right.editionDate.localeCompare(left.editionDate))
|
|
65
|
+
.slice(0, DEEP_ROUTE_EDITION_LIMIT);
|
|
66
|
+
return Promise.all(recent.map((edition) => repository.loadEditionContent({ editionDate: edition.editionDate })));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function generateEditionItemStaticParams(
|
|
70
|
+
repository: EnumerationRepository = graphqlContentRepository,
|
|
71
|
+
): Promise<Array<EditionDateStaticParam & { articleSlug: string }>> {
|
|
72
|
+
if (!shouldEnumerateAtBuild()) return [];
|
|
73
|
+
|
|
74
|
+
const params: Array<EditionDateStaticParam & { articleSlug: string }> = [];
|
|
75
|
+
for (const content of await loadRecentEditionContents(repository)) {
|
|
76
|
+
const date = editionDateToStaticParam(content.editionDate);
|
|
77
|
+
if (!date) continue;
|
|
78
|
+
for (const item of content.items) {
|
|
79
|
+
if (RESERVED_DATE_CHILD_SEGMENTS.has(item.slug.toLowerCase())) continue;
|
|
80
|
+
params.push({ ...date, articleSlug: item.slug });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return params;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function generateEditionPageStaticParams(
|
|
87
|
+
repository: EnumerationRepository = graphqlContentRepository,
|
|
88
|
+
): Promise<Array<EditionDateStaticParam & { pageNumber: string }>> {
|
|
89
|
+
if (!shouldEnumerateAtBuild()) return [];
|
|
90
|
+
|
|
91
|
+
const params: Array<EditionDateStaticParam & { pageNumber: string }> = [];
|
|
92
|
+
for (const content of await loadRecentEditionContents(repository)) {
|
|
93
|
+
const date = editionDateToStaticParam(content.editionDate);
|
|
94
|
+
if (!date) continue;
|
|
95
|
+
for (let pageNumber = 2; pageNumber <= content.layoutPlan.pages.length; pageNumber += 1) {
|
|
96
|
+
params.push({ ...date, pageNumber: String(pageNumber) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return params;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function generateEditionSectionStaticParams(
|
|
103
|
+
repository: EnumerationRepository = graphqlContentRepository,
|
|
104
|
+
): Promise<Array<EditionDateStaticParam & { sectionKey: string }>> {
|
|
105
|
+
if (!shouldEnumerateAtBuild()) return [];
|
|
106
|
+
|
|
107
|
+
const params: Array<EditionDateStaticParam & { sectionKey: string }> = [];
|
|
108
|
+
for (const content of await loadRecentEditionContents(repository)) {
|
|
109
|
+
const date = editionDateToStaticParam(content.editionDate);
|
|
110
|
+
if (!date) continue;
|
|
111
|
+
for (const section of content.sections) params.push({ ...date, sectionKey: section.key });
|
|
112
|
+
}
|
|
113
|
+
return params;
|
|
114
|
+
}
|
|
115
|
+
|
|
48
116
|
export async function generateArticleStaticParams(): Promise<Array<{ slug: string }>> {
|
|
49
117
|
if (process.env.NODE_ENV === "development") return [];
|
|
50
118
|
if (!siteEnumeratesReaderRoutesAtBuild()) return [];
|
|
@@ -64,6 +132,6 @@ function editionDateToStaticParam(editionDate: string): EditionDateStaticParam |
|
|
|
64
132
|
return {
|
|
65
133
|
year: match[1],
|
|
66
134
|
month: monthName,
|
|
67
|
-
day:
|
|
135
|
+
day: match[3],
|
|
68
136
|
};
|
|
69
137
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const PREWARM_ORIGIN_ENV = "PAPYRUS_PREWARM_ORIGIN";
|
|
2
|
+
export const PREWARM_CONCURRENCY = 4;
|
|
3
|
+
export const PREWARM_MAX_PATHS = 40;
|
|
4
|
+
export const PREWARM_TIMEOUT_MILLISECONDS = 25_000;
|
|
5
|
+
export const PREWARM_REQUEST_HEADER = "x-papyrus-prewarm";
|
|
6
|
+
|
|
7
|
+
export type PrewarmFetch = (url: string, init: { headers: Record<string, string>; signal: AbortSignal; redirect: "manual" }) => Promise<{ status: number }>;
|
|
8
|
+
|
|
9
|
+
export type PrewarmResult = {
|
|
10
|
+
path: string;
|
|
11
|
+
status: number | null;
|
|
12
|
+
milliseconds: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function resolvePrewarmOrigin(requestUrl: string, environment: Record<string, string | undefined> = process.env): string {
|
|
16
|
+
const configured = environment[PREWARM_ORIGIN_ENV]?.trim();
|
|
17
|
+
if (configured) return configured.replace(/\/+$/, "");
|
|
18
|
+
return new URL(requestUrl).origin;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function prewarmPaths(
|
|
22
|
+
origin: string,
|
|
23
|
+
paths: string[],
|
|
24
|
+
options: { fetchPage?: PrewarmFetch; now?: () => number } = {},
|
|
25
|
+
): Promise<PrewarmResult[]> {
|
|
26
|
+
const fetchPage = options.fetchPage ?? (fetch as unknown as PrewarmFetch);
|
|
27
|
+
const now = options.now ?? Date.now;
|
|
28
|
+
const queue = [...new Set(paths)].filter((path) => path.startsWith("/")).slice(0, PREWARM_MAX_PATHS);
|
|
29
|
+
const results: PrewarmResult[] = [];
|
|
30
|
+
|
|
31
|
+
async function worker(): Promise<void> {
|
|
32
|
+
for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
|
|
33
|
+
const startedAt = now();
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), PREWARM_TIMEOUT_MILLISECONDS);
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetchPage(`${origin}${path}`, {
|
|
38
|
+
headers: { [PREWARM_REQUEST_HEADER]: "1" },
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
redirect: "manual",
|
|
41
|
+
});
|
|
42
|
+
results.push({ path, status: response.status, milliseconds: now() - startedAt });
|
|
43
|
+
} catch {
|
|
44
|
+
results.push({ path, status: null, milliseconds: now() - startedAt });
|
|
45
|
+
} finally {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await Promise.all(Array.from({ length: Math.min(PREWARM_CONCURRENCY, queue.length) }, worker));
|
|
52
|
+
return results;
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -197,6 +197,7 @@
|
|
|
197
197
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
198
198
|
"./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
|
|
199
199
|
"./lib/publication-items": "./lib/publication-items.ts",
|
|
200
|
+
"./lib/rate-limit-retry": "./lib/rate-limit-retry.ts",
|
|
200
201
|
"./lib/reader-base-path": "./lib/reader-base-path.ts",
|
|
201
202
|
"./lib/reader-cache-lifetime": "./lib/reader-cache-lifetime.ts",
|
|
202
203
|
"./lib/reader-route-config": "./lib/reader-route-config.ts",
|
|
@@ -210,6 +211,7 @@
|
|
|
210
211
|
"./lib/renderer-config": "./lib/renderer-config.ts",
|
|
211
212
|
"./lib/renderer": "./lib/renderer.ts",
|
|
212
213
|
"./lib/reporting-story-budget": "./lib/reporting-story-budget.ts",
|
|
214
|
+
"./lib/revalidate-prewarm": "./lib/revalidate-prewarm.ts",
|
|
213
215
|
"./lib/revalidate-secret": "./lib/revalidate-secret.ts",
|
|
214
216
|
"./lib/robots-policy": "./lib/robots-policy.ts",
|
|
215
217
|
"./lib/root-route-redirect": "./lib/root-route-redirect.ts",
|
|
@@ -308,7 +310,7 @@
|
|
|
308
310
|
"optional": true
|
|
309
311
|
}
|
|
310
312
|
},
|
|
311
|
-
"version": "1.0.0-next.
|
|
313
|
+
"version": "1.0.0-next.54",
|
|
312
314
|
"dependencies": {
|
|
313
315
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
314
316
|
"@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.54",
|
|
3
3
|
"routes": [
|
|
4
4
|
{
|
|
5
5
|
"file": "app/%5F_preview/[[...path]]/route.ts",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"reader": true,
|
|
17
17
|
"hasDefault": true,
|
|
18
18
|
"exports": [
|
|
19
|
-
"generateMetadata"
|
|
19
|
+
"generateMetadata",
|
|
20
|
+
"generateStaticParams"
|
|
20
21
|
],
|
|
21
22
|
"config": {
|
|
22
23
|
"revalidate": "60"
|
|
@@ -37,7 +38,9 @@
|
|
|
37
38
|
"file": "app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
|
|
38
39
|
"reader": true,
|
|
39
40
|
"hasDefault": true,
|
|
40
|
-
"exports": [
|
|
41
|
+
"exports": [
|
|
42
|
+
"generateStaticParams"
|
|
43
|
+
],
|
|
41
44
|
"config": {
|
|
42
45
|
"revalidate": "60"
|
|
43
46
|
}
|
|
@@ -46,7 +49,9 @@
|
|
|
46
49
|
"file": "app/[year]/[month]/[day]/section/[sectionKey]/page.tsx",
|
|
47
50
|
"reader": true,
|
|
48
51
|
"hasDefault": true,
|
|
49
|
-
"exports": [
|
|
52
|
+
"exports": [
|
|
53
|
+
"generateStaticParams"
|
|
54
|
+
],
|
|
50
55
|
"config": {
|
|
51
56
|
"revalidate": "60"
|
|
52
57
|
}
|