@anthusai/papyrus 1.0.0-next.54 → 1.0.0-next.56
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/components/amplify-client-provider.tsx +1 -0
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/graphql-content-repository.ts +6 -18
- package/lib/rate-limit-retry.ts +32 -4
- package/lib/reader-storage-url.ts +9 -12
- package/lib/shared-credentials-provider.ts +61 -0
- package/package.json +2 -1
- package/routes.manifest.json +1 -1
- package/src/with-papyrus.d.mts +8 -0
- package/src/with-papyrus.mjs +21 -1
|
@@ -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";
|
|
@@ -134,10 +134,9 @@ const IMAGE_ROLES: NonNullable<ArticleImageAsset["roles"]> = [
|
|
|
134
134
|
"thumbnail",
|
|
135
135
|
];
|
|
136
136
|
|
|
137
|
-
let readerContextClient: DataClient | null = null;
|
|
138
137
|
let buildTimeClient: DataClient | null = null;
|
|
139
138
|
|
|
140
|
-
function authMode(): "userPool" | "identityPool" {
|
|
139
|
+
export function authMode(): "userPool" | "identityPool" {
|
|
141
140
|
return currentContentSource() === "drafts" ? "userPool" : "identityPool";
|
|
142
141
|
}
|
|
143
142
|
|
|
@@ -147,7 +146,7 @@ function isVisibleItemStatus(status: string): boolean {
|
|
|
147
146
|
}
|
|
148
147
|
|
|
149
148
|
async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
|
|
150
|
-
const {
|
|
149
|
+
const { config } = getAmplifyServerRuntime();
|
|
151
150
|
const source = getContentSource();
|
|
152
151
|
if (source === "drafts") {
|
|
153
152
|
// Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
|
|
@@ -157,19 +156,9 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
|
|
|
157
156
|
const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
|
|
158
157
|
return runWithContentSource(source, operation, client);
|
|
159
158
|
}
|
|
160
|
-
return
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
}),
|
|
159
|
+
return withRateLimitRetry(() =>
|
|
160
|
+
runWithGuestAmplifyContext(() =>
|
|
161
|
+
runWithContentSource(source, operation, generateClient<Schema>({ authMode: authMode() })),
|
|
173
162
|
),
|
|
174
163
|
);
|
|
175
164
|
}
|
|
@@ -260,7 +249,6 @@ export const graphqlContentRepository: ContentRepository = {
|
|
|
260
249
|
function getClient(): DataClient {
|
|
261
250
|
const requestClient = currentRequestClient<DataClient>();
|
|
262
251
|
if (requestClient) return requestClient;
|
|
263
|
-
if (readerContextClient) return readerContextClient;
|
|
264
252
|
|
|
265
253
|
if (!buildTimeClient) {
|
|
266
254
|
getAmplifyServerRuntime();
|
package/lib/rate-limit-retry.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
export const RATE_LIMIT_RETRY_ATTEMPTS =
|
|
2
|
-
export const RATE_LIMIT_RETRY_BASE_MILLISECONDS =
|
|
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;
|
|
3
5
|
|
|
4
6
|
export function isRateLimitError(error: unknown): boolean {
|
|
5
7
|
if (!error || typeof error !== "object") return false;
|
|
@@ -8,20 +10,46 @@ export function isRateLimitError(error: unknown): boolean {
|
|
|
8
10
|
return /TooManyRequests|ThrottlingException|Rate exceeded|Unauthorized[\s\S]*set the \\?"authMode\\?"/i.test(text);
|
|
9
11
|
}
|
|
10
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
|
+
|
|
11
33
|
export async function withRateLimitRetry<T>(
|
|
12
34
|
operation: () => Promise<T>,
|
|
13
|
-
options:
|
|
35
|
+
options: RateLimitRetryOptions = {},
|
|
14
36
|
): Promise<T> {
|
|
15
37
|
const attempts = options.attempts ?? RATE_LIMIT_RETRY_ATTEMPTS;
|
|
16
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;
|
|
17
41
|
const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
18
42
|
const random = options.random ?? Math.random;
|
|
43
|
+
const now = options.now ?? Date.now;
|
|
44
|
+
const startedAt = now();
|
|
19
45
|
for (let attempt = 1; ; attempt += 1) {
|
|
20
46
|
try {
|
|
21
47
|
return await operation();
|
|
22
48
|
} catch (error) {
|
|
23
49
|
if (attempt >= attempts || !isRateLimitError(error)) throw error;
|
|
24
|
-
|
|
50
|
+
const delay = fullJitterDelayMilliseconds(attempt, baseMilliseconds, capMilliseconds, random);
|
|
51
|
+
if (now() - startedAt + delay > budgetMilliseconds) throw error;
|
|
52
|
+
await sleep(delay);
|
|
25
53
|
}
|
|
26
54
|
}
|
|
27
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
|
@@ -216,6 +216,7 @@
|
|
|
216
216
|
"./lib/robots-policy": "./lib/robots-policy.ts",
|
|
217
217
|
"./lib/root-route-redirect": "./lib/root-route-redirect.ts",
|
|
218
218
|
"./lib/semantic-graph": "./lib/semantic-graph.ts",
|
|
219
|
+
"./lib/shared-credentials-provider": "./lib/shared-credentials-provider.ts",
|
|
219
220
|
"./lib/signup-csv": "./lib/signup-csv.ts",
|
|
220
221
|
"./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
|
|
221
222
|
"./lib/site-brand": "./lib/site-brand.ts",
|
|
@@ -310,7 +311,7 @@
|
|
|
310
311
|
"optional": true
|
|
311
312
|
}
|
|
312
313
|
},
|
|
313
|
-
"version": "1.0.0-next.
|
|
314
|
+
"version": "1.0.0-next.56",
|
|
314
315
|
"dependencies": {
|
|
315
316
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
316
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: {
|