@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.
@@ -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") == "all":
161
- report = project_all(projection_context(client))
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 { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
3
- export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 300;
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
- * 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.
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 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
- ]),
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 type { IFunction } from "aws-cdk-lib/aws-lambda";
4
- import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
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 = 300;
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
- * 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.
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 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
- ]),
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 { runWithAmplifyServerContext, config } = getAmplifyServerRuntime();
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 runWithContentSource(source, () =>
160
- runWithAmplifyServerContext({
161
- nextServerContext: null,
162
- operation: async () => {
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 { getAmplifyServerRuntime } from "./amplify-server-runtime";
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 { runWithAmplifyServerContext } = getAmplifyServerRuntime();
47
- const result = await runWithAmplifyServerContext({
48
- nextServerContext: null,
49
- operation: (contextSpec) =>
50
- getUrl(contextSpec, {
51
- path: normalized,
52
- options: {
53
- expiresIn: STORAGE_URL_EXPIRES_IN_SECONDS,
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.53",
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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.53",
2
+ "version": "1.0.0-next.55",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -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
+ };
@@ -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: {