@anthusai/papyrus 1.0.0-next.53 → 1.0.0-next.55
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/components/amplify-client-provider.tsx +1 -0
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/graphql-content-repository.ts +8 -17
- package/lib/rate-limit-retry.ts +55 -0
- package/lib/reader-storage-url.ts +9 -12
- package/lib/shared-credentials-provider.ts +61 -0
- package/package.json +3 -1
- package/routes.manifest.json +1 -1
- package/src/with-papyrus.d.mts +8 -0
- package/src/with-papyrus.mjs +21 -1
|
@@ -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
|
}
|
|
@@ -15,6 +15,7 @@ let configured = false;
|
|
|
15
15
|
|
|
16
16
|
export function configureAmplifyClient() {
|
|
17
17
|
if (configured) return;
|
|
18
|
+
if (typeof window === "undefined") return;
|
|
18
19
|
assertSandboxAmplifyOutputsForDev(amplifyOutputs as Record<string, unknown>);
|
|
19
20
|
Amplify.configure(prioritizeCurrentOrigin(amplifyOutputs as ResourcesConfig), { ssr: true });
|
|
20
21
|
applyClientTokenStorageForCurrentProtocol();
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { createServerRunner } from "@aws-amplify/adapter-nextjs";
|
|
3
3
|
import { Amplify, type ResourcesConfig } from "aws-amplify";
|
|
4
|
+
import {
|
|
5
|
+
createAWSCredentialsAndIdentityIdProvider,
|
|
6
|
+
createUserPoolsTokenProvider,
|
|
7
|
+
runWithAmplifyServerContext as runWithAmplifyContext,
|
|
8
|
+
} from "aws-amplify/adapter-core";
|
|
9
|
+
import { sharedInMemoryStorage } from "aws-amplify/utils";
|
|
10
|
+
import { createSharedCredentialsProvider } from "./shared-credentials-provider";
|
|
4
11
|
import { assertSandboxAmplifyOutputsForDev } from "./amplify-outputs-guard";
|
|
5
12
|
import { getAmplifyOutputsPath } from "./amplify-outputs-path";
|
|
6
13
|
|
|
@@ -14,14 +21,42 @@ export function getAmplifyServerRuntime(): AmplifyServerRuntime {
|
|
|
14
21
|
if (cachedRuntime) return cachedRuntime;
|
|
15
22
|
|
|
16
23
|
const config = loadAmplifyOutputs();
|
|
17
|
-
Amplify.configure(config, { ssr: true });
|
|
18
24
|
cachedRuntime = {
|
|
19
25
|
...createServerRunner({ config }),
|
|
20
26
|
config,
|
|
21
27
|
};
|
|
28
|
+
Amplify.configure(config, config.Auth ? { ssr: true, Auth: getGuestProviders(config) } : { ssr: true });
|
|
22
29
|
return cachedRuntime;
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
type GuestProviders = {
|
|
33
|
+
credentialsProvider: ReturnType<typeof createAWSCredentialsAndIdentityIdProvider>;
|
|
34
|
+
tokenProvider: ReturnType<typeof createUserPoolsTokenProvider>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const GUEST_PROVIDERS_KEY = Symbol.for("papyrus.guestAmplifyProviders");
|
|
38
|
+
|
|
39
|
+
function getGuestProviders(config: ResourcesConfig): GuestProviders {
|
|
40
|
+
const holder = globalThis as unknown as Record<symbol, GuestProviders | undefined>;
|
|
41
|
+
const existing = holder[GUEST_PROVIDERS_KEY];
|
|
42
|
+
if (existing) return existing;
|
|
43
|
+
const auth = config.Auth as NonNullable<ResourcesConfig["Auth"]>;
|
|
44
|
+
const created: GuestProviders = {
|
|
45
|
+
credentialsProvider: createSharedCredentialsProvider(
|
|
46
|
+
createAWSCredentialsAndIdentityIdProvider(auth, sharedInMemoryStorage),
|
|
47
|
+
),
|
|
48
|
+
tokenProvider: createUserPoolsTokenProvider(auth, sharedInMemoryStorage),
|
|
49
|
+
};
|
|
50
|
+
holder[GUEST_PROVIDERS_KEY] = created;
|
|
51
|
+
return created;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function runWithGuestAmplifyContext<T>(operation: (contextSpec: Parameters<Parameters<typeof runWithAmplifyContext>[2]>[0]) => Promise<T>): Promise<T> {
|
|
55
|
+
const { config } = getAmplifyServerRuntime();
|
|
56
|
+
if (!config.Auth) return runWithAmplifyContext(config, {}, operation) as Promise<T>;
|
|
57
|
+
return runWithAmplifyContext(config, { Auth: getGuestProviders(config) }, operation) as Promise<T>;
|
|
58
|
+
}
|
|
59
|
+
|
|
25
60
|
export function hasAmplifyOutputs(): boolean {
|
|
26
61
|
return fs.existsSync(getAmplifyOutputsPath());
|
|
27
62
|
}
|
|
@@ -3,7 +3,7 @@ import { generateClient } from "aws-amplify/data";
|
|
|
3
3
|
import type { Schema } from "../amplify/data/resource";
|
|
4
4
|
import type { Article, ArticleImage, ArticleImageAsset, ArticleImageLayout, ArticleImageThemeVariants, ArticleVideoAsset, ArticleVideoThemeVariants } from "./articles";
|
|
5
5
|
import { withContentLoadTiming } from "./content-load-timing";
|
|
6
|
-
import { getAmplifyServerRuntime } from "./amplify-server-runtime";
|
|
6
|
+
import { getAmplifyServerRuntime, runWithGuestAmplifyContext } from "./amplify-server-runtime";
|
|
7
7
|
import { resolveReaderStorageUrl, signStorageUrl } from "./reader-storage-url";
|
|
8
8
|
import type { ContentRepository, EditionContent, EditionRouteSummary, ListPublishedEditionsOptions, LoadEditionContentOptions } from "./content-types";
|
|
9
9
|
import { isDemoAmplifyOutputs } from "./demo-amplify-outputs";
|
|
@@ -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";
|
|
@@ -133,10 +134,9 @@ const IMAGE_ROLES: NonNullable<ArticleImageAsset["roles"]> = [
|
|
|
133
134
|
"thumbnail",
|
|
134
135
|
];
|
|
135
136
|
|
|
136
|
-
let readerContextClient: DataClient | null = null;
|
|
137
137
|
let buildTimeClient: DataClient | null = null;
|
|
138
138
|
|
|
139
|
-
function authMode(): "userPool" | "identityPool" {
|
|
139
|
+
export function authMode(): "userPool" | "identityPool" {
|
|
140
140
|
return currentContentSource() === "drafts" ? "userPool" : "identityPool";
|
|
141
141
|
}
|
|
142
142
|
|
|
@@ -146,7 +146,7 @@ function isVisibleItemStatus(status: string): boolean {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
|
|
149
|
-
const {
|
|
149
|
+
const { config } = getAmplifyServerRuntime();
|
|
150
150
|
const source = getContentSource();
|
|
151
151
|
if (source === "drafts") {
|
|
152
152
|
// Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
|
|
@@ -156,18 +156,10 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
|
|
|
156
156
|
const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
|
|
157
157
|
return runWithContentSource(source, operation, client);
|
|
158
158
|
}
|
|
159
|
-
return
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
readerContextClient = generateClient<Schema>({ authMode: authMode() });
|
|
164
|
-
try {
|
|
165
|
-
return await operation();
|
|
166
|
-
} finally {
|
|
167
|
-
readerContextClient = null;
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
|
-
}),
|
|
159
|
+
return withRateLimitRetry(() =>
|
|
160
|
+
runWithGuestAmplifyContext(() =>
|
|
161
|
+
runWithContentSource(source, operation, generateClient<Schema>({ authMode: authMode() })),
|
|
162
|
+
),
|
|
171
163
|
);
|
|
172
164
|
}
|
|
173
165
|
|
|
@@ -257,7 +249,6 @@ export const graphqlContentRepository: ContentRepository = {
|
|
|
257
249
|
function getClient(): DataClient {
|
|
258
250
|
const requestClient = currentRequestClient<DataClient>();
|
|
259
251
|
if (requestClient) return requestClient;
|
|
260
|
-
if (readerContextClient) return readerContextClient;
|
|
261
252
|
|
|
262
253
|
if (!buildTimeClient) {
|
|
263
254
|
getAmplifyServerRuntime();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const RATE_LIMIT_RETRY_ATTEMPTS = 14;
|
|
2
|
+
export const RATE_LIMIT_RETRY_BASE_MILLISECONDS = 500;
|
|
3
|
+
export const RATE_LIMIT_RETRY_CAP_MILLISECONDS = 20_000;
|
|
4
|
+
export const RATE_LIMIT_RETRY_BUDGET_MILLISECONDS = 120_000;
|
|
5
|
+
|
|
6
|
+
export function isRateLimitError(error: unknown): boolean {
|
|
7
|
+
if (!error || typeof error !== "object") return false;
|
|
8
|
+
const { name, message } = error as { name?: unknown; message?: unknown };
|
|
9
|
+
const text = `${typeof name === "string" ? name : ""} ${typeof message === "string" ? message : ""}`;
|
|
10
|
+
return /TooManyRequests|ThrottlingException|Rate exceeded|Unauthorized[\s\S]*set the \\?"authMode\\?"/i.test(text);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RateLimitRetryOptions = {
|
|
14
|
+
attempts?: number;
|
|
15
|
+
baseMilliseconds?: number;
|
|
16
|
+
capMilliseconds?: number;
|
|
17
|
+
budgetMilliseconds?: number;
|
|
18
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
19
|
+
random?: () => number;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function fullJitterDelayMilliseconds(
|
|
24
|
+
attempt: number,
|
|
25
|
+
baseMilliseconds: number,
|
|
26
|
+
capMilliseconds: number,
|
|
27
|
+
random: () => number,
|
|
28
|
+
): number {
|
|
29
|
+
const ceiling = Math.min(capMilliseconds, baseMilliseconds * 2 ** (attempt - 1));
|
|
30
|
+
return random() * ceiling;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function withRateLimitRetry<T>(
|
|
34
|
+
operation: () => Promise<T>,
|
|
35
|
+
options: RateLimitRetryOptions = {},
|
|
36
|
+
): Promise<T> {
|
|
37
|
+
const attempts = options.attempts ?? RATE_LIMIT_RETRY_ATTEMPTS;
|
|
38
|
+
const baseMilliseconds = options.baseMilliseconds ?? RATE_LIMIT_RETRY_BASE_MILLISECONDS;
|
|
39
|
+
const capMilliseconds = options.capMilliseconds ?? RATE_LIMIT_RETRY_CAP_MILLISECONDS;
|
|
40
|
+
const budgetMilliseconds = options.budgetMilliseconds ?? RATE_LIMIT_RETRY_BUDGET_MILLISECONDS;
|
|
41
|
+
const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
42
|
+
const random = options.random ?? Math.random;
|
|
43
|
+
const now = options.now ?? Date.now;
|
|
44
|
+
const startedAt = now();
|
|
45
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
46
|
+
try {
|
|
47
|
+
return await operation();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (attempt >= attempts || !isRateLimitError(error)) throw error;
|
|
50
|
+
const delay = fullJitterDelayMilliseconds(attempt, baseMilliseconds, capMilliseconds, random);
|
|
51
|
+
if (now() - startedAt + delay > budgetMilliseconds) throw error;
|
|
52
|
+
await sleep(delay);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getUrl } from "aws-amplify/storage/server";
|
|
2
|
-
import {
|
|
2
|
+
import { runWithGuestAmplifyContext } from "./amplify-server-runtime";
|
|
3
3
|
|
|
4
4
|
const STORAGE_URL_EXPIRES_IN_SECONDS = 60 * 60;
|
|
5
5
|
const STORAGE_URL_CACHE_TTL_MS = 55 * 60 * 1000;
|
|
@@ -43,17 +43,14 @@ export async function signStorageUrl(storagePath: string): Promise<string> {
|
|
|
43
43
|
return cached.url;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
},
|
|
55
|
-
}),
|
|
56
|
-
});
|
|
46
|
+
const result = await runWithGuestAmplifyContext((contextSpec) =>
|
|
47
|
+
getUrl(contextSpec, {
|
|
48
|
+
path: normalized,
|
|
49
|
+
options: {
|
|
50
|
+
expiresIn: STORAGE_URL_EXPIRES_IN_SECONDS,
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
57
54
|
const url = result.url.toString();
|
|
58
55
|
signedUrlCache.set(normalized, {
|
|
59
56
|
expiresAt: Date.now() + STORAGE_URL_CACHE_TTL_MS,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { withRateLimitRetry, type RateLimitRetryOptions } from "./rate-limit-retry";
|
|
2
|
+
|
|
3
|
+
export const CREDENTIAL_EXPIRY_SAFETY_MILLISECONDS = 5 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
export type CredentialsAndIdentityIdLike = {
|
|
6
|
+
credentials: { expiration?: Date };
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type CredentialsProviderLike<Options, Result extends CredentialsAndIdentityIdLike> = {
|
|
10
|
+
getCredentialsAndIdentityId(options: Options): Promise<Result | undefined>;
|
|
11
|
+
clearCredentialsAndIdentityId(): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SharedCredentialsProviderOptions = {
|
|
15
|
+
retry?: RateLimitRetryOptions;
|
|
16
|
+
expirySafetyMilliseconds?: number;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function createSharedCredentialsProvider<
|
|
21
|
+
Options extends { forceRefresh?: boolean },
|
|
22
|
+
Result extends CredentialsAndIdentityIdLike,
|
|
23
|
+
>(
|
|
24
|
+
inner: CredentialsProviderLike<Options, Result>,
|
|
25
|
+
settings: SharedCredentialsProviderOptions = {},
|
|
26
|
+
): CredentialsProviderLike<Options, Result> {
|
|
27
|
+
const now = settings.now ?? Date.now;
|
|
28
|
+
const safety = settings.expirySafetyMilliseconds ?? CREDENTIAL_EXPIRY_SAFETY_MILLISECONDS;
|
|
29
|
+
let cached: Result | undefined;
|
|
30
|
+
let inflight: Promise<Result | undefined> | undefined;
|
|
31
|
+
|
|
32
|
+
const isFresh = (value: Result | undefined): value is Result => {
|
|
33
|
+
if (!value) return false;
|
|
34
|
+
const expiration = value.credentials.expiration;
|
|
35
|
+
return !expiration || expiration.getTime() - safety > now();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
async getCredentialsAndIdentityId(options) {
|
|
40
|
+
if (!options?.forceRefresh && isFresh(cached)) return cached;
|
|
41
|
+
if (inflight) return inflight;
|
|
42
|
+
const request = withRateLimitRetry(() => inner.getCredentialsAndIdentityId(options), {
|
|
43
|
+
now,
|
|
44
|
+
...settings.retry,
|
|
45
|
+
})
|
|
46
|
+
.then((result) => {
|
|
47
|
+
cached = result;
|
|
48
|
+
return result;
|
|
49
|
+
})
|
|
50
|
+
.finally(() => {
|
|
51
|
+
inflight = undefined;
|
|
52
|
+
});
|
|
53
|
+
inflight = request;
|
|
54
|
+
return request;
|
|
55
|
+
},
|
|
56
|
+
clearCredentialsAndIdentityId() {
|
|
57
|
+
cached = undefined;
|
|
58
|
+
inner.clearCredentialsAndIdentityId();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
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",
|
|
@@ -215,6 +216,7 @@
|
|
|
215
216
|
"./lib/robots-policy": "./lib/robots-policy.ts",
|
|
216
217
|
"./lib/root-route-redirect": "./lib/root-route-redirect.ts",
|
|
217
218
|
"./lib/semantic-graph": "./lib/semantic-graph.ts",
|
|
219
|
+
"./lib/shared-credentials-provider": "./lib/shared-credentials-provider.ts",
|
|
218
220
|
"./lib/signup-csv": "./lib/signup-csv.ts",
|
|
219
221
|
"./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
|
|
220
222
|
"./lib/site-brand": "./lib/site-brand.ts",
|
|
@@ -309,7 +311,7 @@
|
|
|
309
311
|
"optional": true
|
|
310
312
|
}
|
|
311
313
|
},
|
|
312
|
-
"version": "1.0.0-next.
|
|
314
|
+
"version": "1.0.0-next.55",
|
|
313
315
|
"dependencies": {
|
|
314
316
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
315
317
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
package/routes.manifest.json
CHANGED
package/src/with-papyrus.d.mts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
import type { NextConfig } from "next";
|
|
2
2
|
export function withPapyrus(nextConfig?: NextConfig, options?: { root?: string }): NextConfig;
|
|
3
3
|
export const READER_EXPIRE_TIME_SECONDS: number;
|
|
4
|
+
export const DEFAULT_STATIC_GENERATION_WORKERS: number;
|
|
5
|
+
export const DEFAULT_STATIC_GENERATION_CONCURRENCY: number;
|
|
6
|
+
export const DEFAULT_STATIC_GENERATION_RETRY_COUNT: number;
|
|
7
|
+
export function staticGenerationSettings(): {
|
|
8
|
+
cpus: number;
|
|
9
|
+
staticGenerationMaxConcurrency: number;
|
|
10
|
+
staticGenerationRetryCount: number;
|
|
11
|
+
};
|
package/src/with-papyrus.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
* `papyrus-site-theme` -> ./publication/theme.css when it exists, else the shipped
|
|
11
11
|
* empty lib/empty-theme.css (both webpack and turbopack),
|
|
12
12
|
* - trace the corpora/ YAML and amplify_outputs.json that the reader reads via fs at runtime,
|
|
13
|
+
* - cap static generation at 2 workers x 3 pages (override with PAPYRUS_STATIC_GENERATION_WORKERS / _CONCURRENCY) and retry
|
|
14
|
+
* failed pages 3 times, so pre-building the reader pages stays under Cognito guest-credential throttles,
|
|
13
15
|
* - carry Papyrus's own server-external packages and image settings.
|
|
14
16
|
*/
|
|
15
17
|
const SERVER_EXTERNAL = [
|
|
@@ -30,6 +32,24 @@ const SERVER_EXTERNAL = [
|
|
|
30
32
|
*/
|
|
31
33
|
export const READER_EXPIRE_TIME_SECONDS = 120;
|
|
32
34
|
|
|
35
|
+
export const DEFAULT_STATIC_GENERATION_WORKERS = 2;
|
|
36
|
+
export const DEFAULT_STATIC_GENERATION_CONCURRENCY = 3;
|
|
37
|
+
export const DEFAULT_STATIC_GENERATION_RETRY_COUNT = 3;
|
|
38
|
+
|
|
39
|
+
function positiveIntegerFromEnvironment(name) {
|
|
40
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
41
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function staticGenerationSettings() {
|
|
45
|
+
return {
|
|
46
|
+
cpus: positiveIntegerFromEnvironment("PAPYRUS_STATIC_GENERATION_WORKERS") ?? DEFAULT_STATIC_GENERATION_WORKERS,
|
|
47
|
+
staticGenerationMaxConcurrency:
|
|
48
|
+
positiveIntegerFromEnvironment("PAPYRUS_STATIC_GENERATION_CONCURRENCY") ?? DEFAULT_STATIC_GENERATION_CONCURRENCY,
|
|
49
|
+
staticGenerationRetryCount: DEFAULT_STATIC_GENERATION_RETRY_COUNT,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
export const DEFAULT_TRANSPILE_PACKAGES = ["@anthusai/papyrus", "cyclotron"];
|
|
34
54
|
|
|
35
55
|
export function mergeTranspilePackages(existing = []) {
|
|
@@ -67,7 +87,7 @@ export function withPapyrus(nextConfig = {}, options = {}) {
|
|
|
67
87
|
serverExternalPackages: [...new Set([...(nextConfig.serverExternalPackages ?? []), ...SERVER_EXTERNAL])],
|
|
68
88
|
outputFileTracingIncludes: mergeOutputFileTracingIncludes(nextConfig.outputFileTracingIncludes),
|
|
69
89
|
images: nextConfig.images ?? { remotePatterns: [{ protocol: "https", hostname: "**" }] },
|
|
70
|
-
experimental: { devtoolSegmentExplorer: false, ...nextConfig.experimental },
|
|
90
|
+
experimental: { devtoolSegmentExplorer: false, ...staticGenerationSettings(), ...nextConfig.experimental },
|
|
71
91
|
turbopack: {
|
|
72
92
|
...nextConfig.turbopack,
|
|
73
93
|
resolveAlias: {
|