jamdesk 1.1.157 → 1.1.159
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/dist/commands/deploy/templates.js +2 -2
- package/package.json +1 -1
- 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/child-process.ts +11 -0
- package/vendored/lib/fonts.ts +10 -4
- 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/revalidation-helpers.ts +7 -0
- package/vendored/lib/revalidation-trigger.ts +112 -0
- package/vendored/shared/status-reporter.ts +1 -0
- package/vendored/themes/base.css +22 -0
- package/vendored/themes/jam/variables.css +5 -5
- package/vendored/themes/pulsar/variables.css +5 -5
- package/vendored/workspace-package-lock.json +201 -117
|
@@ -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
|
+
}
|
|
@@ -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,
|
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);
|
|
@@ -372,7 +372,7 @@ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-d
|
|
|
372
372
|
|
|
373
373
|
/* Prose body */
|
|
374
374
|
body[data-theme="jam"] .prose {
|
|
375
|
-
font-family: var(--font-sans, 'Inter'), sans-serif;
|
|
375
|
+
font-family: var(--font-sans, var(--font-primary, 'Inter')), sans-serif;
|
|
376
376
|
font-size: var(--prose-body-size);
|
|
377
377
|
line-height: var(--prose-body-line-height);
|
|
378
378
|
font-weight: var(--prose-body-weight);
|
|
@@ -382,7 +382,7 @@ body[data-theme="jam"] .prose {
|
|
|
382
382
|
/* Prose headings */
|
|
383
383
|
body[data-theme="jam"] .prose h1,
|
|
384
384
|
body[data-theme="jam"] h1 {
|
|
385
|
-
font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
|
|
385
|
+
font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
|
|
386
386
|
font-size: var(--prose-h1-size);
|
|
387
387
|
line-height: var(--prose-h1-line-height);
|
|
388
388
|
font-weight: var(--prose-h1-weight);
|
|
@@ -391,7 +391,7 @@ body[data-theme="jam"] h1 {
|
|
|
391
391
|
}
|
|
392
392
|
|
|
393
393
|
body[data-theme="jam"] .prose h2 {
|
|
394
|
-
font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
|
|
394
|
+
font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
|
|
395
395
|
font-size: var(--prose-h2-size);
|
|
396
396
|
line-height: var(--prose-h2-line-height);
|
|
397
397
|
font-weight: var(--prose-h2-weight);
|
|
@@ -400,7 +400,7 @@ body[data-theme="jam"] .prose h2 {
|
|
|
400
400
|
}
|
|
401
401
|
|
|
402
402
|
body[data-theme="jam"] .prose h3 {
|
|
403
|
-
font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
|
|
403
|
+
font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
|
|
404
404
|
font-size: var(--prose-h3-size);
|
|
405
405
|
line-height: var(--prose-h3-line-height);
|
|
406
406
|
font-weight: var(--prose-h3-weight);
|
|
@@ -409,7 +409,7 @@ body[data-theme="jam"] .prose h3 {
|
|
|
409
409
|
}
|
|
410
410
|
|
|
411
411
|
body[data-theme="jam"] .prose h4 {
|
|
412
|
-
font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
|
|
412
|
+
font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
|
|
413
413
|
font-size: var(--prose-h4-size);
|
|
414
414
|
line-height: var(--prose-h4-line-height);
|
|
415
415
|
font-weight: var(--prose-h4-weight);
|
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
|
|
176
176
|
/* Prose body */
|
|
177
177
|
.prose {
|
|
178
|
-
font-family: var(--font-sans, 'Inter'), sans-serif;
|
|
178
|
+
font-family: var(--font-sans, var(--font-primary, 'Inter')), sans-serif;
|
|
179
179
|
font-size: var(--prose-body-size);
|
|
180
180
|
line-height: var(--prose-body-line-height);
|
|
181
181
|
font-weight: var(--prose-body-weight);
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
/* Prose headings */
|
|
186
186
|
.prose h1,
|
|
187
187
|
h1 {
|
|
188
|
-
font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
|
|
188
|
+
font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
|
|
189
189
|
font-size: var(--prose-h1-size);
|
|
190
190
|
line-height: var(--prose-h1-line-height);
|
|
191
191
|
font-weight: var(--prose-h1-weight);
|
|
@@ -194,7 +194,7 @@ h1 {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
.prose h2 {
|
|
197
|
-
font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
|
|
197
|
+
font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
|
|
198
198
|
font-size: var(--prose-h2-size);
|
|
199
199
|
line-height: var(--prose-h2-line-height);
|
|
200
200
|
font-weight: var(--prose-h2-weight);
|
|
@@ -203,7 +203,7 @@ h1 {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
.prose h3 {
|
|
206
|
-
font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
|
|
206
|
+
font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
|
|
207
207
|
font-size: var(--prose-h3-size);
|
|
208
208
|
line-height: var(--prose-h3-line-height);
|
|
209
209
|
font-weight: var(--prose-h3-weight);
|
|
@@ -212,7 +212,7 @@ h1 {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
.prose h4 {
|
|
215
|
-
font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
|
|
215
|
+
font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
|
|
216
216
|
font-size: var(--prose-h4-size);
|
|
217
217
|
line-height: var(--prose-h4-line-height);
|
|
218
218
|
font-weight: var(--prose-h4-weight);
|