jamdesk 1.1.156 → 1.1.158
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/package.json +1 -1
- package/vendored/app/not-found.tsx +6 -0
- package/vendored/components/mdx/Card.tsx +4 -0
- package/vendored/components/theme/ThemeToggle.tsx +24 -14
- package/vendored/lib/auth-resolver.ts +36 -0
- package/vendored/lib/build/r2-upload.ts +2 -0
- package/vendored/lib/openapi/operation-description.ts +110 -0
- package/vendored/lib/project-deploy-id.ts +197 -0
- package/vendored/lib/project-deploy-publisher.ts +95 -0
- package/vendored/lib/r2-cleanup.ts +159 -30
- package/vendored/lib/r2-feature-flags.ts +46 -0
- package/vendored/lib/r2-key-builder.ts +28 -0
- package/vendored/lib/r2-manifest.ts +2 -0
- package/vendored/lib/r2-public-fetch.ts +133 -0
- package/vendored/lib/r2-public-storage-guard.ts +113 -0
- package/vendored/lib/r2-upload-lock.ts +277 -0
- package/vendored/lib/render-doc-page.tsx +80 -1
- package/vendored/lib/revalidation-helpers.ts +7 -0
- package/vendored/lib/revalidation-trigger.ts +112 -0
- package/vendored/lib/static-artifacts.ts +8 -2
- package/vendored/lib/validate-page-frontmatter.ts +39 -3
- package/vendored/shared/status-reporter.ts +1 -0
- package/vendored/themes/base.css +22 -0
- package/vendored/themes/jam/variables.css +16 -5
- package/vendored/themes/nebula/variables.css +10 -0
- package/vendored/themes/pulsar/variables.css +5 -5
- package/vendored/workspace-package-lock.json +172 -256
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis NX upload lock with heartbeat-and-steal semantics — TS-native.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `scripts/lib/upload-lock.cjs` semantics but uses the @upstash/redis
|
|
5
|
+
* SDK via `lib/redis.ts` so the production ISR build path (build.ts +
|
|
6
|
+
* scripts/upload-content-to-r2.ts) can reuse the same wire-level behaviour
|
|
7
|
+
* without shelling out to the CJS path.
|
|
8
|
+
*
|
|
9
|
+
* Defense-in-depth against concurrent ISR uploads for the same project. Two
|
|
10
|
+
* builds racing the manifest read/write would stamp the wrong deployId into
|
|
11
|
+
* Redis and point readers at a half-uploaded R2 prefix. The Firebase Functions
|
|
12
|
+
* queue is supposed to serialize these, but it lives in a separate process;
|
|
13
|
+
* this lock is the fallback that catches mistakes and orchestration drift.
|
|
14
|
+
*
|
|
15
|
+
* Lock key: r2UploadLock:<projectName>
|
|
16
|
+
* Lock value: <deployId>:<startedAtMs>:<lastHeartbeatMs>
|
|
17
|
+
* TTL: 600s (absolute ceiling so SIGKILL self-cleans)
|
|
18
|
+
* Heartbeat: 30s (refreshes value + EX TTL)
|
|
19
|
+
* Stale threshold: 90s (a holder whose heartbeat is older may be stolen)
|
|
20
|
+
*
|
|
21
|
+
* Best-effort: when Redis isn't configured (no @upstash/redis client) or a
|
|
22
|
+
* call throws, `acquireUploadLock` returns `{ acquired: false }` plus either
|
|
23
|
+
* `skipped: true` (no client) or `errored: true` (Redis threw) so the caller
|
|
24
|
+
* can degrade to "no lock" rather than block uploads on Upstash availability.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { redis } from './redis.js';
|
|
28
|
+
import { logger } from '../shared/logger.js';
|
|
29
|
+
|
|
30
|
+
export const STALE_AFTER_MS = 90_000;
|
|
31
|
+
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
32
|
+
export const TTL_SECONDS = 600;
|
|
33
|
+
|
|
34
|
+
export function lockKeyFor(projectName: string): string {
|
|
35
|
+
return `r2UploadLock:${projectName}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildLockValue(
|
|
39
|
+
deployId: string,
|
|
40
|
+
startedAtMs: number,
|
|
41
|
+
heartbeatMs: number,
|
|
42
|
+
): string {
|
|
43
|
+
return `${deployId}:${startedAtMs}:${heartbeatMs}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AcquireResult {
|
|
47
|
+
acquired: boolean;
|
|
48
|
+
/** Redis call threw (network blip, outage). Caller should warn and proceed. */
|
|
49
|
+
errored?: boolean;
|
|
50
|
+
/** No Redis client configured. Caller should treat as "skip locking". */
|
|
51
|
+
skipped?: boolean;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface AcquireArgs {
|
|
56
|
+
projectName: string;
|
|
57
|
+
deployId: string;
|
|
58
|
+
startedAtMs?: number;
|
|
59
|
+
now?: () => number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ReleaseArgs {
|
|
63
|
+
projectName: string;
|
|
64
|
+
deployId: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface HeartbeatArgs {
|
|
68
|
+
projectName: string;
|
|
69
|
+
deployId: string;
|
|
70
|
+
startedAtMs: number;
|
|
71
|
+
/** Called on lock theft (GET returns null OR a value not prefixed with our deployId). */
|
|
72
|
+
onTheft?: (current: unknown) => void;
|
|
73
|
+
now?: () => number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Coerce `redis.get`'s parsed return to a string for prefix comparisons.
|
|
78
|
+
* The Upstash SDK auto-JSON-parses bodies, so a stored value like
|
|
79
|
+
* "deploy-abc:1:2" round-trips as the JS string "deploy-abc:1:2", but a
|
|
80
|
+
* value like "1234:5678:9012" *might* round-trip as a number depending on
|
|
81
|
+
* how the writer encoded it. String-coercing keeps the prefix check honest.
|
|
82
|
+
*/
|
|
83
|
+
function asString(value: unknown): string | null {
|
|
84
|
+
if (value === null || value === undefined) return null;
|
|
85
|
+
return String(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Try to acquire the lock for ${projectName}.
|
|
90
|
+
*
|
|
91
|
+
* Behaviour by case:
|
|
92
|
+
* 1. Lock free → SET NX returns 'OK', acquired.
|
|
93
|
+
* 2. Lock held, fresh holder → returns false.
|
|
94
|
+
* 3. Lock vanished between SET NX & GET → retry SET NX once.
|
|
95
|
+
* 4. Lock held, holder is stale (>90s) → SET (no NX) steals it; warns.
|
|
96
|
+
* 5. Lock value malformed (non-numeric ts) → treat as fresh, returns false.
|
|
97
|
+
*
|
|
98
|
+
* Returns acquired:false errored:true on Redis throw so the caller can
|
|
99
|
+
* proceed without the lock — best-effort defense-in-depth, not a hard
|
|
100
|
+
* dependency. An Upstash outage must NOT block all uploads.
|
|
101
|
+
*
|
|
102
|
+
* Returns acquired:false skipped:true when Redis isn't configured (e.g. local
|
|
103
|
+
* dev without KV credentials) — same degradation path as the CJS lockEnabled
|
|
104
|
+
* check.
|
|
105
|
+
*/
|
|
106
|
+
export async function acquireUploadLock(
|
|
107
|
+
args: AcquireArgs,
|
|
108
|
+
): Promise<AcquireResult> {
|
|
109
|
+
if (!redis) {
|
|
110
|
+
return { acquired: false, skipped: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const now = args.now ?? Date.now;
|
|
114
|
+
const startedAtMs = args.startedAtMs ?? now();
|
|
115
|
+
const key = lockKeyFor(args.projectName);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const initial = await redis.set(
|
|
119
|
+
key,
|
|
120
|
+
buildLockValue(args.deployId, startedAtMs, startedAtMs),
|
|
121
|
+
{ nx: true, ex: TTL_SECONDS },
|
|
122
|
+
);
|
|
123
|
+
if (initial === 'OK') return { acquired: true };
|
|
124
|
+
|
|
125
|
+
const currentRaw = await redis.get(key);
|
|
126
|
+
const current = asString(currentRaw);
|
|
127
|
+
if (current === null) {
|
|
128
|
+
// Lock was released between the failed SET NX and the GET. Retry once
|
|
129
|
+
// — concurrent racer might have just finished a tiny upload.
|
|
130
|
+
const retry = await redis.set(
|
|
131
|
+
key,
|
|
132
|
+
buildLockValue(args.deployId, startedAtMs, now()),
|
|
133
|
+
{ nx: true, ex: TTL_SECONDS },
|
|
134
|
+
);
|
|
135
|
+
return { acquired: retry === 'OK' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const parts = current.split(':');
|
|
139
|
+
const lastHeartbeat = Number(parts[2]);
|
|
140
|
+
if (
|
|
141
|
+
Number.isFinite(lastHeartbeat) &&
|
|
142
|
+
now() - lastHeartbeat > STALE_AFTER_MS
|
|
143
|
+
) {
|
|
144
|
+
// Steal the stale lock. SET without NX overwrites; preserves TTL.
|
|
145
|
+
await redis.set(
|
|
146
|
+
key,
|
|
147
|
+
buildLockValue(args.deployId, startedAtMs, now()),
|
|
148
|
+
{ ex: TTL_SECONDS },
|
|
149
|
+
);
|
|
150
|
+
const ageSec = Math.round((now() - lastHeartbeat) / 1000);
|
|
151
|
+
logger.warn(
|
|
152
|
+
`Stole stale upload lock for ${args.projectName} (last heartbeat ${ageSec}s ago)`,
|
|
153
|
+
);
|
|
154
|
+
return { acquired: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Lock held by a fresh, live holder — or value malformed (Number.isFinite
|
|
158
|
+
// false). In both cases we abort: malformed values are not stealable
|
|
159
|
+
// because we can't tell if the writer is alive.
|
|
160
|
+
return { acquired: false };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return {
|
|
163
|
+
acquired: false,
|
|
164
|
+
errored: true,
|
|
165
|
+
error: err instanceof Error ? err.message : String(err),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Release the lock if and only if we still hold it (compare-and-delete).
|
|
172
|
+
* Best-effort: never throws. Caller is responsible for stopping the
|
|
173
|
+
* heartbeat timer BEFORE calling this so the timer doesn't race the
|
|
174
|
+
* release and re-extend the TTL after DEL.
|
|
175
|
+
*/
|
|
176
|
+
export async function releaseUploadLock(args: ReleaseArgs): Promise<void> {
|
|
177
|
+
if (!redis) return;
|
|
178
|
+
try {
|
|
179
|
+
const key = lockKeyFor(args.projectName);
|
|
180
|
+
const currentRaw = await redis.get(key);
|
|
181
|
+
const current = asString(currentRaw);
|
|
182
|
+
if (current && current.startsWith(`${args.deployId}:`)) {
|
|
183
|
+
await redis.del(key);
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// best-effort
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Handle returned by `startHeartbeat`. `stop()` MUST be called before
|
|
192
|
+
* `releaseUploadLock` — clearing the interval alone is insufficient because
|
|
193
|
+
* a tick that started before clearInterval can still be suspended on
|
|
194
|
+
* `await redis.get` and would re-extend TTL after the lock has been DEL'd
|
|
195
|
+
* (issue M1 from the Phase 4 QA pass — non-corrupting but ~90s self-recovery
|
|
196
|
+
* via stale-steal). `stop()` flips an `aborted` flag the tick re-checks at
|
|
197
|
+
* every async boundary AND clears the timer so no new ticks fire.
|
|
198
|
+
*/
|
|
199
|
+
export interface HeartbeatHandle {
|
|
200
|
+
stop: () => void;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Start the heartbeat interval. Returns a handle whose `stop()` aborts
|
|
205
|
+
* any in-flight tick AND clears the timer.
|
|
206
|
+
*
|
|
207
|
+
* Each tick:
|
|
208
|
+
* 1. GETs the lock value.
|
|
209
|
+
* 2. If the value is missing or no longer prefixed with our deployId,
|
|
210
|
+
* the lock has been stolen (or expired + re-acquired) — call onTheft
|
|
211
|
+
* with the observed value so the caller can throw / abort cleanly.
|
|
212
|
+
* 3. Otherwise, SET (no NX) refreshes the heartbeat ms and bumps TTL.
|
|
213
|
+
*
|
|
214
|
+
* Default `onTheft` logs an error — callers are expected to provide a
|
|
215
|
+
* theft handler that throws or sets a flag the upload phase reads, so
|
|
216
|
+
* theft cleanly aborts the build instead of relying on `process.exit(2)`
|
|
217
|
+
* (which would skip the build's failure-reporting path).
|
|
218
|
+
*
|
|
219
|
+
* Transient Redis errors during a tick are swallowed: a single missed
|
|
220
|
+
* heartbeat shouldn't kill the build, and the 600s TTL is the backstop.
|
|
221
|
+
*/
|
|
222
|
+
export function startHeartbeat(args: HeartbeatArgs): HeartbeatHandle {
|
|
223
|
+
const { projectName, deployId, startedAtMs } = args;
|
|
224
|
+
const now = args.now ?? Date.now;
|
|
225
|
+
const onTheft =
|
|
226
|
+
args.onTheft ??
|
|
227
|
+
((current: unknown) => {
|
|
228
|
+
logger.error(
|
|
229
|
+
`[r2-upload] lock stolen by another build (was: ${current})`,
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
const key = lockKeyFor(projectName);
|
|
233
|
+
|
|
234
|
+
let aborted = false;
|
|
235
|
+
|
|
236
|
+
const tick = async (): Promise<void> => {
|
|
237
|
+
if (aborted || !redis) return;
|
|
238
|
+
try {
|
|
239
|
+
const currentRaw = await redis.get(key);
|
|
240
|
+
// After every async boundary, re-check the abort flag. The release
|
|
241
|
+
// path flips it; a tick that resumed mid-flight must not call
|
|
242
|
+
// onTheft (would surface a stale theft signal) or extend TTL
|
|
243
|
+
// (would defeat the DEL).
|
|
244
|
+
if (aborted) return;
|
|
245
|
+
const current = asString(currentRaw);
|
|
246
|
+
if (!current || !current.startsWith(`${deployId}:`)) {
|
|
247
|
+
onTheft(current);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const heartbeatMs = now();
|
|
251
|
+
if (aborted) return;
|
|
252
|
+
await redis.set(
|
|
253
|
+
key,
|
|
254
|
+
buildLockValue(deployId, startedAtMs, heartbeatMs),
|
|
255
|
+
{ ex: TTL_SECONDS },
|
|
256
|
+
);
|
|
257
|
+
} catch {
|
|
258
|
+
// Transient Redis error — swallow. TTL backstop covers worst case.
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const timer = setInterval(() => {
|
|
263
|
+
tick().catch(() => {
|
|
264
|
+
// Already swallowed inside tick(); this catch is for the ESLint
|
|
265
|
+
// floating-promise rule on setInterval callbacks.
|
|
266
|
+
});
|
|
267
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
268
|
+
// Don't keep the event loop alive for the heartbeat alone.
|
|
269
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
stop: () => {
|
|
273
|
+
aborted = true;
|
|
274
|
+
clearInterval(timer);
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
type AuthMethod,
|
|
85
85
|
} from '@/lib/openapi';
|
|
86
86
|
import { classifyOpenApiLoadError } from '@/lib/openapi/classify-load-error';
|
|
87
|
+
import { findOperation, deriveOperationDescription, collectLocalSpecPaths } from '@/lib/openapi/operation-description';
|
|
87
88
|
import { extractLanguageFromPath, isValidLanguageCode } from '@/lib/language-utils';
|
|
88
89
|
import { findFirstNavPage } from '@/lib/find-first-nav-page';
|
|
89
90
|
import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
|
|
@@ -210,6 +211,47 @@ function resolveSlug(normalizedSlug: string[], config: DocsConfig): string[] {
|
|
|
210
211
|
return normalizedSlug;
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a page `<meta>`/og description from its OpenAPI operation when the
|
|
216
|
+
* page has no frontmatter `description`. Pure: the spec loader is injected so
|
|
217
|
+
* this is unit-testable independent of the render path (and so `buildDocMetadata`
|
|
218
|
+
* can hand it the SAME cached loader the renderer uses — no extra spec fetch).
|
|
219
|
+
*
|
|
220
|
+
* `loadSpec` MUST be a cached loader (see `buildDocMetadata` wiring): docs
|
|
221
|
+
* routes are `force-dynamic`, so this runs on every page view; an uncached
|
|
222
|
+
* fetch+parse here would add an R2 round-trip per render. Remote (`http(s)://`)
|
|
223
|
+
* spec entries are dropped — they are fetched by the renderer, never resolvable
|
|
224
|
+
* as a meta-time read. Returns null on any unresolvable/unparseable input so the
|
|
225
|
+
* caller degrades to `generateAutoDescription`.
|
|
226
|
+
*/
|
|
227
|
+
export async function resolveOpenApiMetaDescription(
|
|
228
|
+
openapiRef: string,
|
|
229
|
+
specPaths: string[],
|
|
230
|
+
loadSpec: (specPath: string) => Promise<unknown | null>,
|
|
231
|
+
): Promise<string | null> {
|
|
232
|
+
const local = specPaths.filter((p) => !/^https?:\/\//i.test(p));
|
|
233
|
+
if (local.length === 0) return null;
|
|
234
|
+
let parsed;
|
|
235
|
+
try {
|
|
236
|
+
parsed = parseOpenApiFrontmatter(openapiRef, local);
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
// Full-format ref carries its own spec path; short-format must try each
|
|
241
|
+
// configured spec until the operation resolves.
|
|
242
|
+
const candidates = parsed.isShortFormat ? local : [parsed.specPath];
|
|
243
|
+
for (const sp of candidates) {
|
|
244
|
+
if (!sp) continue;
|
|
245
|
+
const spec = await loadSpec(sp);
|
|
246
|
+
if (!spec) continue;
|
|
247
|
+
const op = findOperation(spec, parsed.method, parsed.path);
|
|
248
|
+
if (!op) continue;
|
|
249
|
+
const d = deriveOperationDescription(op);
|
|
250
|
+
if (d) return d;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
213
255
|
export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
|
|
214
256
|
const { slug: slugInput, projectSlug, hostAtDocs, requestHeaders } = input;
|
|
215
257
|
|
|
@@ -252,7 +294,44 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
|
|
|
252
294
|
const data = parsed.data as FrontmatterData;
|
|
253
295
|
|
|
254
296
|
if (!data.description) {
|
|
255
|
-
data.
|
|
297
|
+
if (typeof data.openapi === 'string' && data.openapi && config.api?.openapi) {
|
|
298
|
+
// Source the description from the OpenAPI operation before falling back to
|
|
299
|
+
// first-paragraph extraction (which is empty for prose-less API pages).
|
|
300
|
+
// The spec loader REUSES the render path's cached loaders — the ISR module
|
|
301
|
+
// cache (`r2:${slug}:${specPath}`, 10-min TTL) that `renderDocPage`
|
|
302
|
+
// populates when it renders the page's <ApiEndpoint>, or the static
|
|
303
|
+
// `getCachedSpec`. So the spec is the same warm parse the render already
|
|
304
|
+
// did: the marginal cost of this lookup is a cache hit, never an uncached
|
|
305
|
+
// fetch+parse. That matters because docs routes are force-dynamic, so this
|
|
306
|
+
// metadata runs on EVERY page view (every human + crawler). Mirrors the
|
|
307
|
+
// `useIsr`/`projectDir` derivation in `renderDocPage`'s OpenAPI branch.
|
|
308
|
+
const specPaths = collectLocalSpecPaths(config.api.openapi);
|
|
309
|
+
const useIsr = isIsrMode() && !!projectSlug;
|
|
310
|
+
const projectDir = useIsr ? null : getContentDir();
|
|
311
|
+
const loadSpecForMeta = async (sp: string): Promise<unknown | null> => {
|
|
312
|
+
try {
|
|
313
|
+
if (useIsr && projectSlug) {
|
|
314
|
+
const { resolveOpenApiSpec } = await import('@/lib/openapi-isr');
|
|
315
|
+
return await resolveOpenApiSpec(projectSlug, sp);
|
|
316
|
+
}
|
|
317
|
+
if (projectDir) {
|
|
318
|
+
const { api } = await getCachedSpec(sp, projectDir);
|
|
319
|
+
return api;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
} catch {
|
|
323
|
+
// Any load/parse error degrades to generateAutoDescription below.
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const sourced = await resolveOpenApiMetaDescription(
|
|
328
|
+
data.openapi, specPaths, loadSpecForMeta,
|
|
329
|
+
);
|
|
330
|
+
if (sourced) data.description = sourced;
|
|
331
|
+
}
|
|
332
|
+
if (!data.description) {
|
|
333
|
+
data.description = generateAutoDescription(parsed.content);
|
|
334
|
+
}
|
|
256
335
|
}
|
|
257
336
|
|
|
258
337
|
const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
|
|
@@ -13,6 +13,7 @@ import { clearProjectCache as clearMcpProjectCache, clearCache as clearMcpCache
|
|
|
13
13
|
import { clearNavigationCache } from './navigation-resolver';
|
|
14
14
|
import { STATIC_REVALIDATION_PATHS } from './static-file-route';
|
|
15
15
|
import { clearRedirectCache } from './redirect-matcher';
|
|
16
|
+
import { clearProjectDeployIdCache } from './project-deploy-id';
|
|
16
17
|
import { mdxCacheTag, projectCacheTag } from './cache-tags';
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -132,6 +133,11 @@ export async function executeRevalidation(
|
|
|
132
133
|
|
|
133
134
|
if (isWholeProjectClear) {
|
|
134
135
|
const project = request.project!;
|
|
136
|
+
// Evict the deployId pointer LRU first: cached fetchers key the Data
|
|
137
|
+
// Cache on the resolved deployId, so a ≤30s-stale pointer on this
|
|
138
|
+
// instance would otherwise keep reads pinned to the previous deploy's
|
|
139
|
+
// (dead) cache keys until the LRU TTL lapses.
|
|
140
|
+
clearProjectDeployIdCache(project);
|
|
135
141
|
clearConfigCache(project);
|
|
136
142
|
clearSnippetCache(project);
|
|
137
143
|
clearOpenApiCache(project);
|
|
@@ -193,6 +199,7 @@ export async function executeRevalidation(
|
|
|
193
199
|
clearOpenApiCache();
|
|
194
200
|
clearMcpCache();
|
|
195
201
|
clearNavigationCache();
|
|
202
|
+
clearProjectDeployIdCache();
|
|
196
203
|
await clearRedirectCache();
|
|
197
204
|
if (revalidatePath) {
|
|
198
205
|
revalidatePath('/', 'layout');
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { log } from './logger.js';
|
|
7
|
+
import { redis } from './redis.js';
|
|
8
|
+
|
|
9
|
+
export const DEPLOY_FLIP_TAG = '[deploy-flip]';
|
|
7
10
|
|
|
8
11
|
export interface RevalidationOptions {
|
|
9
12
|
projectSlug: string;
|
|
@@ -56,6 +59,115 @@ export async function triggerRevalidation(options: RevalidationOptions): Promise
|
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Permanent revalidation failure after all retry attempts.
|
|
64
|
+
*
|
|
65
|
+
* Signals to the build orchestrator that Vercel Data Cache may now be serving
|
|
66
|
+
* stale `DocsConfig` (which references the OLD `projectDeployId` for nested
|
|
67
|
+
* asset URLs). The build is marked `succeeded_with_warnings` rather than
|
|
68
|
+
* silently degrading — there's no automatic recovery because the cache key
|
|
69
|
+
* is `[config, slug]`, not deployId-keyed.
|
|
70
|
+
*/
|
|
71
|
+
export class RevalidationFailedError extends Error {
|
|
72
|
+
constructor(public readonly slug: string, public readonly cause: Error) {
|
|
73
|
+
super(`Revalidation failed permanently for ${slug}: ${cause.message}`);
|
|
74
|
+
this.name = 'RevalidationFailedError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wraps `triggerRevalidation` with bounded retry + escalation.
|
|
80
|
+
*
|
|
81
|
+
* On every successful flip of `projectDeployId` in Redis the build pipeline
|
|
82
|
+
* MUST flush Vercel Data Cache via `revalidateTag(projectCacheTag(slug))`,
|
|
83
|
+
* or readers keep serving the OLD config (asset URLs refer to the previous
|
|
84
|
+
* deployId, breaking the new build for cached pages).
|
|
85
|
+
*
|
|
86
|
+
* This wrapper:
|
|
87
|
+
* 1. Calls `triggerRevalidation`. On rejection, sleeps `1s * attemptIndex`
|
|
88
|
+
* and retries up to `attempts` total.
|
|
89
|
+
* 2. On permanent failure, best-effort SETs `revalidationFailure:<slug>`
|
|
90
|
+
* in Redis with a 24h TTL so dashboards/on-call can surface it.
|
|
91
|
+
* 3. Throws `RevalidationFailedError` so the build orchestrator can mark
|
|
92
|
+
* the build `succeeded_with_warnings` instead of swallowing the error.
|
|
93
|
+
*/
|
|
94
|
+
export async function triggerRevalidationWithRetry(
|
|
95
|
+
options: RevalidationOptions,
|
|
96
|
+
attempts = 2,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
log('info', `${DEPLOY_FLIP_TAG} revalidation triggered`, { projectSlug: options.projectSlug });
|
|
99
|
+
|
|
100
|
+
let lastError: Error | undefined;
|
|
101
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
102
|
+
try {
|
|
103
|
+
await triggerRevalidation(options);
|
|
104
|
+
// Success path: clear any prior `revalidationFailure:<slug>` flag so
|
|
105
|
+
// dashboards/on-call don't see stale alerts after the next clean build.
|
|
106
|
+
// Best-effort — the flag has a 24h TTL so it self-clears regardless.
|
|
107
|
+
await clearRevalidationFailureFlag(options.projectSlug);
|
|
108
|
+
return;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
111
|
+
if (attempt < attempts) {
|
|
112
|
+
log('warn', `${DEPLOY_FLIP_TAG} revalidation failed, retrying`, {
|
|
113
|
+
projectSlug: options.projectSlug,
|
|
114
|
+
attempt,
|
|
115
|
+
error: lastError.message,
|
|
116
|
+
});
|
|
117
|
+
// Linear backoff: 1s before attempt 2, 2s before attempt 3, etc.
|
|
118
|
+
// Bounded retry — short sleep is fine; the orchestrator already
|
|
119
|
+
// gates this phase with a phase-level timer.
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// All attempts exhausted — escalate.
|
|
126
|
+
const cause = lastError ?? new Error('Revalidation failed (unknown cause)');
|
|
127
|
+
log('error', `${DEPLOY_FLIP_TAG} revalidation failed permanently — stale Data Cache risk`, {
|
|
128
|
+
projectSlug: options.projectSlug,
|
|
129
|
+
error: cause.message,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Best-effort flag write: if Redis is unconfigured or throws, swallow —
|
|
133
|
+
// the throw below is the load-bearing escalation path.
|
|
134
|
+
try {
|
|
135
|
+
if (redis) {
|
|
136
|
+
const payload = JSON.stringify({
|
|
137
|
+
at: new Date().toISOString(),
|
|
138
|
+
error: cause.message,
|
|
139
|
+
});
|
|
140
|
+
await redis.set(`revalidationFailure:${options.projectSlug}`, payload, {
|
|
141
|
+
ex: 86400, // 24h TTL — dashboards/on-call can surface within a day.
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} catch (flagErr) {
|
|
145
|
+
log('warn', `${DEPLOY_FLIP_TAG} failed to write revalidationFailure flag (non-fatal)`, {
|
|
146
|
+
projectSlug: options.projectSlug,
|
|
147
|
+
error: flagErr instanceof Error ? flagErr.message : String(flagErr),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
throw new RevalidationFailedError(options.projectSlug, cause);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* DEL the 24h `revalidationFailure:<slug>` alert flag. Called on the
|
|
156
|
+
* success path so a recovery build clears the alert from a prior failure.
|
|
157
|
+
* Best-effort: never throws. DEL on a non-existent key is a no-op.
|
|
158
|
+
*/
|
|
159
|
+
async function clearRevalidationFailureFlag(slug: string): Promise<void> {
|
|
160
|
+
if (!redis) return;
|
|
161
|
+
try {
|
|
162
|
+
await redis.del(`revalidationFailure:${slug}`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log('warn', `${DEPLOY_FLIP_TAG} failed to clear revalidationFailure flag (non-fatal)`, {
|
|
165
|
+
projectSlug: slug,
|
|
166
|
+
error: err instanceof Error ? err.message : String(err),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
59
171
|
async function revalidateIsrApp(
|
|
60
172
|
isrAppUrl: string,
|
|
61
173
|
secret: string,
|
|
@@ -436,14 +436,20 @@ export interface RawPageInfo {
|
|
|
436
436
|
* @param pages - Array of raw page info
|
|
437
437
|
* @returns Array of page metadata
|
|
438
438
|
*/
|
|
439
|
-
export function extractPageMetadata(
|
|
439
|
+
export function extractPageMetadata(
|
|
440
|
+
pages: RawPageInfo[],
|
|
441
|
+
sourced?: Map<string, {description: string}>,
|
|
442
|
+
): PageMetadata[] {
|
|
440
443
|
return pages.map(page => {
|
|
441
444
|
const pathWithoutExt = page.path.replace(/\.mdx?$/, '');
|
|
442
445
|
|
|
443
446
|
return {
|
|
444
447
|
path: pathWithoutExt,
|
|
445
448
|
title: page.frontmatter.title || pathWithoutExt,
|
|
446
|
-
description
|
|
449
|
+
// Frontmatter wins; else fall back to a description sourced from the
|
|
450
|
+
// page's OpenAPI operation (keyed by the extension-ful page.path).
|
|
451
|
+
description:
|
|
452
|
+
page.frontmatter.description ?? sourced?.get(page.path)?.description,
|
|
447
453
|
noindex: page.frontmatter.noindex || page.frontmatter.seo?.noindex,
|
|
448
454
|
hidden: page.frontmatter.hidden,
|
|
449
455
|
lastModified: page.frontmatter.lastModified,
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
|
|
2
|
+
import { MIN_FRONTMATTER_DESCRIPTION_CHARS } from './openapi/operation-description.js';
|
|
2
3
|
|
|
3
4
|
const MIN_DESCRIPTION_CHARS = 110; // matches the Ahrefs "too short" floor used elsewhere
|
|
5
|
+
// Task 1 invariant: MIN_DESCRIPTION_CHARS must equal MIN_FRONTMATTER_DESCRIPTION_CHARS.
|
|
6
|
+
// Asserted by test. Do NOT change either value without updating both.
|
|
4
7
|
|
|
5
8
|
interface PageInfo {
|
|
6
9
|
path: string;
|
|
7
|
-
frontmatter: { title?: unknown; description?: unknown; hidden?: unknown };
|
|
10
|
+
frontmatter: { title?: unknown; description?: unknown; hidden?: unknown; openapi?: unknown };
|
|
8
11
|
content: string;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
export function validatePageFrontmatter(
|
|
14
|
+
export function validatePageFrontmatter(
|
|
15
|
+
pages: PageInfo[],
|
|
16
|
+
sourced?: Map<string, { description: string; length: number }>,
|
|
17
|
+
): BuildWarning[] {
|
|
12
18
|
const warnings: BuildWarning[] = [];
|
|
13
19
|
for (const page of pages) {
|
|
14
20
|
const fm = page.frontmatter ?? {};
|
|
@@ -21,7 +27,37 @@ export function validatePageFrontmatter(pages: PageInfo[]): BuildWarning[] {
|
|
|
21
27
|
warnings.push({ type: 'page_missing_title', file: page.path, message: 'Page has no frontmatter title; navigation and llms.txt fall back to the file path.' });
|
|
22
28
|
}
|
|
23
29
|
if (!description) {
|
|
24
|
-
|
|
30
|
+
const src = sourced?.get(page.path);
|
|
31
|
+
if (src) {
|
|
32
|
+
if (src.length >= MIN_FRONTMATTER_DESCRIPTION_CHARS) {
|
|
33
|
+
// Cat 1: OpenAPI-sourced description is long enough for Fix-with-AI to write
|
|
34
|
+
// into frontmatter — keep the warning but re-message it toward that action.
|
|
35
|
+
warnings.push({
|
|
36
|
+
type: 'page_missing_description',
|
|
37
|
+
file: page.path,
|
|
38
|
+
message: "No frontmatter description — your page's description is auto-sourced " +
|
|
39
|
+
"from your OpenAPI spec. Use Fix with AI to write it into the page for portability.",
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
// Cat 2: sourced text IS live in <meta>/llms, but too short to commit via
|
|
43
|
+
// Fix-with-AI. Do NOT suppress — the author still needs a longer description.
|
|
44
|
+
// A sourced-short description must warn no less than an authored-short one
|
|
45
|
+
// (else `summary: "TODO"` silently becomes the live meta + OG card with zero
|
|
46
|
+
// signal to fix it).
|
|
47
|
+
warnings.push({
|
|
48
|
+
type: 'page_missing_description',
|
|
49
|
+
file: page.path,
|
|
50
|
+
message:
|
|
51
|
+
`Description auto-sourced from your OpenAPI spec is only ` +
|
|
52
|
+
`${src.length} chars — add a longer frontmatter description ` +
|
|
53
|
+
`(aim for ${MIN_FRONTMATTER_DESCRIPTION_CHARS}–160; too short ` +
|
|
54
|
+
`for Fix with AI to write into the page).`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
// Cat 3: genuinely missing, nothing to source from the spec.
|
|
59
|
+
warnings.push({ type: 'page_missing_description', file: page.path, message: 'Page has no frontmatter description; add one for SEO and llms.txt.' });
|
|
60
|
+
}
|
|
25
61
|
} else if (description.length < MIN_DESCRIPTION_CHARS) {
|
|
26
62
|
warnings.push({ type: 'page_short_description', file: page.path, message: `Description is ${description.length} chars; aim for ${MIN_DESCRIPTION_CHARS}–160.` });
|
|
27
63
|
}
|
package/vendored/themes/base.css
CHANGED
|
@@ -221,6 +221,28 @@
|
|
|
221
221
|
margin-bottom: 0.8rem;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/* Card body typography — must apply at FIRST PAINT.
|
|
225
|
+
Keep in sync with the same rules in Card.tsx's styled-jsx (they exist there
|
|
226
|
+
so the styles travel with the vendored component; they exist here so they
|
|
227
|
+
apply at first paint — Next.js dev injects styled-jsx only AFTER first paint,
|
|
228
|
+
so card paragraphs would briefly inherit the looser .prose line-height/margins
|
|
229
|
+
and the card would visibly shrink on load). These bare selectors sit after
|
|
230
|
+
`.prose p` above, so they win by source order (margins) or specificity
|
|
231
|
+
(:last-child) and also cover cards rendered outside .prose. */
|
|
232
|
+
.card-content > p {
|
|
233
|
+
line-height: 1.375;
|
|
234
|
+
margin: 0 0 0.75rem 0;
|
|
235
|
+
}
|
|
236
|
+
.card-content > p:last-child {
|
|
237
|
+
margin-bottom: 0;
|
|
238
|
+
}
|
|
239
|
+
.card-content > *:first-child {
|
|
240
|
+
margin-top: 0;
|
|
241
|
+
}
|
|
242
|
+
.card-content > *:last-child {
|
|
243
|
+
margin-bottom: 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
224
246
|
/* Links */
|
|
225
247
|
.prose a {
|
|
226
248
|
color: var(--color-text-primary);
|