jamdesk 1.1.214 → 1.1.215
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/README.md +2 -2
- package/dist/__tests__/unit/dusk-embargo.test.js +3 -2
- package/dist/__tests__/unit/dusk-embargo.test.js.map +1 -1
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +6 -0
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/dusk-embargo.d.ts.map +1 -1
- package/dist/lib/dusk-embargo.js +1 -1
- package/dist/lib/dusk-embargo.js.map +1 -1
- package/package.json +1 -1
- package/templates/quickstart.mdx +1 -1
- package/vendored/app/api/revalidate/route.ts +24 -1
- package/vendored/components/navigation/Header.tsx +16 -29
- package/vendored/components/navigation/NavbarItems.tsx +49 -0
- package/vendored/components/navigation/Sidebar.tsx +44 -1
- package/vendored/lib/cache-tags.ts +8 -0
- package/vendored/lib/dusk-embargo.ts +1 -1
- package/vendored/lib/indexnow.ts +162 -9
- package/vendored/lib/navbar-labels.ts +34 -0
- package/vendored/lib/project-deploy-id.ts +1 -1
- package/vendored/lib/revalidation-helpers.ts +26 -8
- package/vendored/lib/revalidation-trigger.ts +122 -34
- package/vendored/lib/static-artifacts.ts +15 -0
- package/vendored/lib/static-cdn-purge.ts +72 -0
- package/vendored/lib/static-file-route.ts +8 -0
- package/vendored/themes/dusk/variables.css +5 -2
- package/vendored/workspace-package-lock.json +405 -114
package/vendored/lib/indexnow.ts
CHANGED
|
@@ -9,6 +9,12 @@ import { createHash } from 'crypto';
|
|
|
9
9
|
|
|
10
10
|
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';
|
|
11
11
|
const FETCH_TIMEOUT_MS = 5_000;
|
|
12
|
+
|
|
13
|
+
/** Identifies the key self-check to bot protection (no shared outbound UA constant exists). */
|
|
14
|
+
const INDEXNOW_CHECK_USER_AGENT = 'Jamdesk-IndexNow-KeyCheck/1.0 (+https://jamdesk.com)';
|
|
15
|
+
// Separate from the single-URL GET budget: a batch body can carry thousands of
|
|
16
|
+
// URLs, so the POST needs more headroom than a one-URL ping.
|
|
17
|
+
const BATCH_FETCH_TIMEOUT_MS = 15_000;
|
|
12
18
|
const MAX_BATCH_SIZE = 10_000;
|
|
13
19
|
const KEY_SALT = 'jamdesk-indexnow-v1';
|
|
14
20
|
|
|
@@ -48,38 +54,185 @@ export function buildChangedUrls(
|
|
|
48
54
|
});
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Submit URLs to IndexNow. Single URL uses GET, multiple uses batch POST.
|
|
59
|
+
*
|
|
60
|
+
* @param keyLocation - Where the key file is served. Omit for root tenants
|
|
61
|
+
* (IndexNow's default `/{key}.txt` lookup works); required for prefixed
|
|
62
|
+
* tenants (see the comment at the batch call below).
|
|
63
|
+
*/
|
|
52
64
|
export async function submitToIndexNow(
|
|
53
|
-
urls: string[], host: string, key: string
|
|
65
|
+
urls: string[], host: string, key: string, keyLocation?: string
|
|
54
66
|
): Promise<IndexNowResult> {
|
|
55
67
|
if (urls.length === 0) {
|
|
56
68
|
return { success: true, status: 200, submitted: 0 };
|
|
57
69
|
}
|
|
58
70
|
|
|
59
|
-
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
60
|
-
|
|
61
71
|
if (urls.length === 1) {
|
|
62
72
|
const params = new URLSearchParams({ url: urls[0], key });
|
|
73
|
+
if (keyLocation) params.set('keyLocation', keyLocation);
|
|
63
74
|
const res = await fetch(`${INDEXNOW_ENDPOINT}?${params}`, {
|
|
64
|
-
signal:
|
|
75
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
65
76
|
});
|
|
66
77
|
return { success: isSuccessStatus(res.status), status: res.status, submitted: 1 };
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
const submitted = Math.min(urls.length, MAX_BATCH_SIZE);
|
|
70
|
-
// keyLocation
|
|
71
|
-
//
|
|
72
|
-
//
|
|
81
|
+
// keyLocation is sent only when the caller passes it. Prefixed tenants
|
|
82
|
+
// (/docs, /help) need it: their Cloudflare Worker proxies only the prefix, so
|
|
83
|
+
// IndexNow's default root lookup (/{key}.txt) never reaches us, and a key
|
|
84
|
+
// served under /docs/ authorizes /docs/* URLs. The old /api/indexnow/{key}
|
|
85
|
+
// location was rejected because it did not sit above the page URLs it claimed.
|
|
73
86
|
const res = await fetch(INDEXNOW_ENDPOINT, {
|
|
74
87
|
method: 'POST',
|
|
75
88
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
76
|
-
signal:
|
|
89
|
+
signal: AbortSignal.timeout(BATCH_FETCH_TIMEOUT_MS),
|
|
77
90
|
body: JSON.stringify({
|
|
78
91
|
host,
|
|
79
92
|
key,
|
|
93
|
+
...(keyLocation ? { keyLocation } : {}),
|
|
80
94
|
urlList: urls.slice(0, MAX_BATCH_SIZE),
|
|
81
95
|
}),
|
|
82
96
|
});
|
|
83
97
|
|
|
84
98
|
return { success: isSuccessStatus(res.status), status: res.status, submitted };
|
|
85
99
|
}
|
|
100
|
+
|
|
101
|
+
export interface IndexNowKeyCheck {
|
|
102
|
+
/**
|
|
103
|
+
* `ok`: 200 and the body is the key.
|
|
104
|
+
* `missing`: a definite misconfiguration (404/410, 200 with a different body,
|
|
105
|
+
* or any 3xx), so submitting would only be discarded.
|
|
106
|
+
* `inconclusive`: we could not tell (401/403/429/5xx, timeout, network error).
|
|
107
|
+
*/
|
|
108
|
+
status: 'ok' | 'missing' | 'inconclusive';
|
|
109
|
+
/** Absent when no response arrived (timeout, network error). */
|
|
110
|
+
httpStatus?: number;
|
|
111
|
+
/** Value of the `cf-mitigated` response header (e.g. `challenge`), when sent. */
|
|
112
|
+
mitigated?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Self-check that the key file is served before we submit. IndexNow verifies
|
|
117
|
+
* it asynchronously and silently discards submissions it cannot verify, so a
|
|
118
|
+
* missing key file is otherwise invisible.
|
|
119
|
+
*
|
|
120
|
+
* Tri-state on purpose: only a definite `missing` should suppress the
|
|
121
|
+
* submission. Bot protection (a Cloudflare challenge, a 403/429) or a transient
|
|
122
|
+
* 5xx on a customer zone blocks OUR check without saying anything about
|
|
123
|
+
* IndexNow's own fetcher, and before this check existed we submitted without
|
|
124
|
+
* any verification, so `inconclusive` callers should still submit (and warn).
|
|
125
|
+
*
|
|
126
|
+
* `redirect: 'manual'` because IndexNow's own fetch behaviour on redirects is
|
|
127
|
+
* undocumented: a key that only resolves through a redirect is treated as
|
|
128
|
+
* `missing` rather than optimistically trusted.
|
|
129
|
+
*/
|
|
130
|
+
export async function verifyIndexNowKeyFile(keyFileUrl: string, key: string): Promise<IndexNowKeyCheck> {
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(keyFileUrl, {
|
|
133
|
+
// Descriptive UA: Node's default is a common target for bot challenges on
|
|
134
|
+
// Vercel, Cloudflare or a customer zone.
|
|
135
|
+
headers: { 'User-Agent': INDEXNOW_CHECK_USER_AGENT },
|
|
136
|
+
redirect: 'manual',
|
|
137
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
138
|
+
});
|
|
139
|
+
const httpStatus = res.status;
|
|
140
|
+
const mitigated = res.headers?.get('cf-mitigated') ?? undefined;
|
|
141
|
+
const withEvidence = (status: IndexNowKeyCheck['status']): IndexNowKeyCheck => ({
|
|
142
|
+
status,
|
|
143
|
+
httpStatus,
|
|
144
|
+
...(mitigated ? { mitigated } : {}),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (httpStatus === 200) {
|
|
148
|
+
return withEvidence((await res.text()).trim() === key ? 'ok' : 'missing');
|
|
149
|
+
}
|
|
150
|
+
if (httpStatus === 404 || httpStatus === 410 || (httpStatus >= 300 && httpStatus < 400)) {
|
|
151
|
+
return withEvidence('missing');
|
|
152
|
+
}
|
|
153
|
+
return withEvidence('inconclusive');
|
|
154
|
+
} catch {
|
|
155
|
+
// Timeout, DNS/TLS failure or a body-read error: no evidence either way.
|
|
156
|
+
return { status: 'inconclusive' };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface IndexNowPlanInput {
|
|
161
|
+
slug: string;
|
|
162
|
+
/** Custom domain (bare host); undefined for *.jamdesk.app tenants. */
|
|
163
|
+
domain?: string;
|
|
164
|
+
/** e.g. https://jamdesk.com — no trailing slash, no prefix. */
|
|
165
|
+
baseUrl: string;
|
|
166
|
+
/** '' | '/docs' | '/help' */
|
|
167
|
+
docsPrefix: string;
|
|
168
|
+
/** Site-wide robots noindex. */
|
|
169
|
+
noindex: boolean;
|
|
170
|
+
/** build.ts authPlaneGated. */
|
|
171
|
+
authGated: boolean;
|
|
172
|
+
/** Changed .mdx paths (extension stripped, locale-prefixed like `fr/intro`). */
|
|
173
|
+
mdxChangedPaths: string[];
|
|
174
|
+
mdxDeletedPaths: string[];
|
|
175
|
+
/** null = unavailable, so fail closed. */
|
|
176
|
+
pageIndexability: ReadonlyMap<string, boolean> | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface IndexNowSubmission {
|
|
180
|
+
urls: string[];
|
|
181
|
+
host: string;
|
|
182
|
+
key: string;
|
|
183
|
+
/** Where the key is served (always set; used for the self-check). */
|
|
184
|
+
keyFileUrl: string;
|
|
185
|
+
/** Sent to IndexNow only for prefixed tenants. */
|
|
186
|
+
keyLocation?: string;
|
|
187
|
+
/** URLs dropped past MAX_BATCH_SIZE. */
|
|
188
|
+
truncated: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Decide what (if anything) to tell IndexNow after a build. Pure: never
|
|
193
|
+
* mutates its inputs (changedPaths and mdxChangedPaths can be the same array).
|
|
194
|
+
*
|
|
195
|
+
* Returns null — submit nothing — when the site is noindex or auth-gated, when
|
|
196
|
+
* indexability is unknown (fail closed), or when a prefixed tenant has no
|
|
197
|
+
* custom domain (its *.jamdesk.app URLs are x-jd-noindex, so not canonical).
|
|
198
|
+
*
|
|
199
|
+
* A changed page is kept only if the sitemap would list it. Deleted pages are
|
|
200
|
+
* kept because they are absent from the build by definition; IndexNow is how
|
|
201
|
+
* engines learn to drop them. Pages that just became hidden/noindex are NOT
|
|
202
|
+
* submitted — engines drop them on their next crawl, and we cannot tell "just
|
|
203
|
+
* became hidden" from "was always hidden" without the previous build's state.
|
|
204
|
+
*
|
|
205
|
+
* The same blindness applies to deletions: a deleted page is submitted even if
|
|
206
|
+
* it was noindex or hidden before it was deleted. We have no previous-build
|
|
207
|
+
* visibility to tell, and the cost is benign (engines fetch the URL, get a
|
|
208
|
+
* 404, and drop it or ignore it).
|
|
209
|
+
*/
|
|
210
|
+
export function planIndexNowSubmission(input: IndexNowPlanInput): IndexNowSubmission | null {
|
|
211
|
+
const {
|
|
212
|
+
slug, domain, baseUrl, docsPrefix, noindex, authGated,
|
|
213
|
+
mdxChangedPaths, mdxDeletedPaths, pageIndexability,
|
|
214
|
+
} = input;
|
|
215
|
+
|
|
216
|
+
if (noindex || authGated || pageIndexability === null) return null;
|
|
217
|
+
if (docsPrefix !== '' && !domain) return null;
|
|
218
|
+
|
|
219
|
+
const deleted = new Set(mdxDeletedPaths);
|
|
220
|
+
const kept = [...new Set([...mdxChangedPaths, ...mdxDeletedPaths])].filter(
|
|
221
|
+
(p) => pageIndexability.get(p) === true || deleted.has(p),
|
|
222
|
+
);
|
|
223
|
+
if (kept.length === 0) return null;
|
|
224
|
+
|
|
225
|
+
const capped = kept.slice(0, MAX_BATCH_SIZE);
|
|
226
|
+
const key = generateIndexNowKey(slug);
|
|
227
|
+
const keyFileUrl = `${baseUrl}${docsPrefix}/${key}.txt`;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
urls: buildChangedUrls(capped, baseUrl, docsPrefix !== '', docsPrefix),
|
|
231
|
+
host: new URL(baseUrl).host,
|
|
232
|
+
key,
|
|
233
|
+
keyFileUrl,
|
|
234
|
+
// Root tenants: IndexNow's default /{key}.txt lookup already works.
|
|
235
|
+
keyLocation: docsPrefix ? keyFileUrl : undefined,
|
|
236
|
+
truncated: kept.length - capped.length,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { NavbarLink, NavbarPrimary } from './docs-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Navbar labels in the reader's language. Shared by the header (md and up)
|
|
5
|
+
* and the mobile drawer (below md), which render the same navbar entries and
|
|
6
|
+
* must not disagree on what they're called.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The override for `lang`. Exact key first, then case-insensitive:
|
|
11
|
+
* extractLanguageFromPath returns codes like 'pt-BR' and 'zh-Hans', while
|
|
12
|
+
* authors write `labels` keys by hand ("pt-br").
|
|
13
|
+
*/
|
|
14
|
+
function override(labels: Record<string, string> | undefined, lang: string | undefined): string | undefined {
|
|
15
|
+
if (!lang || !labels) return undefined;
|
|
16
|
+
if (labels[lang]) return labels[lang];
|
|
17
|
+
const wanted = lang.toLowerCase();
|
|
18
|
+
const key = Object.keys(labels).find((k) => k.toLowerCase() === wanted);
|
|
19
|
+
return key ? labels[key] : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A link's label for `lang`, falling back to `label`. */
|
|
23
|
+
export function navbarLinkLabel(link: NavbarLink, lang: string | undefined): string {
|
|
24
|
+
return override(link.labels, lang) || link.label;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The primary button's label for `lang`, then `label`, then the default for its type. */
|
|
28
|
+
export function navbarPrimaryLabel(primary: NavbarPrimary, lang: string | undefined): string {
|
|
29
|
+
return (
|
|
30
|
+
override(primary.labels, lang) ||
|
|
31
|
+
primary.label ||
|
|
32
|
+
(primary.type === 'github' ? 'GitHub' : 'Get Started')
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -56,7 +56,7 @@ function lruSet(slug: string, value: string | null): void {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Clear the in-process LRU. With a slug, evicts just that project's entry —
|
|
59
|
-
*
|
|
59
|
+
* project-scoped revalidation calls this so the next read re-resolves the
|
|
60
60
|
* pointer from Redis instead of serving a ≤30s-stale deployId into freshly
|
|
61
61
|
* purged Data Cache entries. Without a slug, clears everything (tests,
|
|
62
62
|
* bare `{all: true}` revalidation).
|
|
@@ -15,7 +15,8 @@ import { getStaticRevalidationPaths } from './static-file-route';
|
|
|
15
15
|
import { clearRedirectCache } from './redirect-matcher';
|
|
16
16
|
import { clearLanguageCache } from './language-matcher';
|
|
17
17
|
import { clearProjectDeployIdCache } from './project-deploy-id';
|
|
18
|
-
import { mdxCacheTag, projectCacheTag } from './cache-tags';
|
|
18
|
+
import { mdxCacheTag, projectCacheTag, staticFileCdnTag } from './cache-tags';
|
|
19
|
+
import { purgeStaticFileCdn } from './static-cdn-purge';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Validate the revalidation secret using constant-time comparison.
|
|
@@ -137,6 +138,16 @@ export async function executeRevalidation(
|
|
|
137
138
|
): Promise<RevalidateResult> {
|
|
138
139
|
const revalidated: string[] = [];
|
|
139
140
|
|
|
141
|
+
// Every project-scoped request, targeted or not: evict this instance's
|
|
142
|
+
// deployId pointer LRU first. Cached fetchers key the Data Cache on the
|
|
143
|
+
// resolved deployId, so a <=30s-stale pointer here would keep reads pinned
|
|
144
|
+
// to the previous deploy's (dead) keys — and would let this instance refill
|
|
145
|
+
// the static-file CDN entry the tag purge below just deleted (that purge runs
|
|
146
|
+
// for paths-only builds too). Unlike the shims gated below, this only drops a
|
|
147
|
+
// Redis pointer cache: it emits no tag and discards no parsed data, so it
|
|
148
|
+
// cannot widen a targeted invalidation.
|
|
149
|
+
if (request.project) clearProjectDeployIdCache(request.project);
|
|
150
|
+
|
|
140
151
|
// Whole-project clear: only when a project is named and the caller gave us
|
|
141
152
|
// nothing to scope on. A targeted request (tags or paths supplied) must NOT
|
|
142
153
|
// run the project-wide shims — clearConfigCache emits a revalidateTag that
|
|
@@ -149,11 +160,6 @@ export async function executeRevalidation(
|
|
|
149
160
|
|
|
150
161
|
if (isWholeProjectClear) {
|
|
151
162
|
const project = request.project!;
|
|
152
|
-
// Evict the deployId pointer LRU first: cached fetchers key the Data
|
|
153
|
-
// Cache on the resolved deployId, so a ≤30s-stale pointer on this
|
|
154
|
-
// instance would otherwise keep reads pinned to the previous deploy's
|
|
155
|
-
// (dead) cache keys until the LRU TTL lapses.
|
|
156
|
-
clearProjectDeployIdCache(project);
|
|
157
163
|
clearConfigCache(project);
|
|
158
164
|
clearSnippetCache(project);
|
|
159
165
|
clearOpenApiCache(project);
|
|
@@ -205,6 +211,16 @@ export async function executeRevalidation(
|
|
|
205
211
|
}
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
// Purge the tenant's static-file responses (sitemap, llms.txt, ...) from the
|
|
215
|
+
// Vercel CDN by tag. Runs for targeted builds too: they re-upload the
|
|
216
|
+
// sitemap/llms files as well, and revalidatePath cannot evict them.
|
|
217
|
+
if (request.project) {
|
|
218
|
+
const outcome = await purgeStaticFileCdn(request.project);
|
|
219
|
+
if (outcome === 'purged') {
|
|
220
|
+
revalidated.push(`cdn-tag:${staticFileCdnTag(request.project)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
208
224
|
// Revalidate everything
|
|
209
225
|
if (request.all) {
|
|
210
226
|
// Bare {all: true} (no project scope) must also flush the in-memory parsed
|
|
@@ -221,8 +237,10 @@ export async function executeRevalidation(
|
|
|
221
237
|
await clearLanguageCache();
|
|
222
238
|
if (revalidatePath) {
|
|
223
239
|
revalidatePath('/', 'layout');
|
|
224
|
-
// Also
|
|
225
|
-
//
|
|
240
|
+
// Also revalidate the static file route paths (sitemap, robots, llms.txt, etc.).
|
|
241
|
+
// NOTE: revalidatePath does not purge Vercel CDN entries of these
|
|
242
|
+
// force-dynamic s-maxage route handlers (prod evidence 2026-09-30); the
|
|
243
|
+
// tag delete above does. This loop still refreshes the ISR layer.
|
|
226
244
|
// Absent docsPrefix/previousDocsSubpath fall back to the legacy '/docs'-only
|
|
227
245
|
// set, so older in-flight requests purge exactly what they always did.
|
|
228
246
|
const staticPaths = getStaticRevalidationPaths(
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { log } from './logger.js';
|
|
7
7
|
import { redis } from './redis.js';
|
|
8
|
+
// Constant only: @vercel/functions is dynamically imported inside
|
|
9
|
+
// purgeStaticFileCdn, so this adds nothing to module load.
|
|
10
|
+
import { STATIC_CDN_REPURGE_DELAY_MS } from './static-cdn-purge.js';
|
|
8
11
|
|
|
9
12
|
export const DEPLOY_FLIP_TAG = '[deploy-flip]';
|
|
10
13
|
|
|
@@ -56,11 +59,18 @@ export async function triggerRevalidation(options: RevalidationOptions): Promise
|
|
|
56
59
|
if (previousDocsSubpath) body.previousDocsSubpath = previousDocsSubpath;
|
|
57
60
|
|
|
58
61
|
// Proxy/CF failures are non-fatal — caches will expire on their own
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
let isrResult: Awaited<ReturnType<typeof revalidateIsrApp>>;
|
|
63
|
+
try {
|
|
64
|
+
[isrResult] = await Promise.all([
|
|
65
|
+
revalidateIsrApp(isrAppUrl, secret, body),
|
|
66
|
+
revalidateProxy(secret, projectSlug),
|
|
67
|
+
]);
|
|
68
|
+
} finally {
|
|
69
|
+
// After ISR settles (success or failure), never alongside it: Cloudflare's
|
|
70
|
+
// purge_everything racing the Vercel CDN purge refilled Cloudflare from the
|
|
71
|
+
// stale Vercel HIT. Never throws.
|
|
72
|
+
await purgeCloudflareCacheTwice(projectSlug);
|
|
73
|
+
}
|
|
64
74
|
|
|
65
75
|
// Only throw if ISR revalidation failed (it's the critical one)
|
|
66
76
|
if (!isrResult.ok) {
|
|
@@ -238,20 +248,54 @@ async function revalidateProxy(secret: string, projectSlug: string): Promise<voi
|
|
|
238
248
|
}
|
|
239
249
|
}
|
|
240
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Purge Cloudflare, wait past the stale-deployId window, purge again.
|
|
253
|
+
*
|
|
254
|
+
* Other ISR instances keep a per-instance deployId LRU for up to 30s
|
|
255
|
+
* (`lib/project-deploy-id.ts`); a jamdesk.com/docs request landing on one in
|
|
256
|
+
* that window refills Vercel with stale content, and Cloudflare then caches
|
|
257
|
+
* it for up to 4h (max-age=14400). The 2nd purge lands after every LRU entry
|
|
258
|
+
* has expired (repurge delay + 5s of slack). Awaited, not fire-and-forget:
|
|
259
|
+
* Cloud Run may throttle CPU once the build returns. Never throws.
|
|
260
|
+
*/
|
|
261
|
+
async function purgeCloudflareCacheTwice(projectSlug: string): Promise<void> {
|
|
262
|
+
await purgeCloudflareCache(projectSlug, 1);
|
|
263
|
+
// Without credentials pass 1 was a no-op, so don't sleep for nothing.
|
|
264
|
+
if (!isCloudflarePurgeEnabled(projectSlug)) return;
|
|
265
|
+
await sleep(CLOUDFLARE_REPURGE_DELAY_MS);
|
|
266
|
+
await purgeCloudflareCache(projectSlug, 2);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const CLOUDFLARE_REPURGE_DELAY_MS = STATIC_CDN_REPURGE_DELAY_MS + 5_000;
|
|
270
|
+
|
|
271
|
+
function sleep(ms: number): Promise<void> {
|
|
272
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isCloudflarePurgeEnabled(projectSlug: string): boolean {
|
|
276
|
+
return (
|
|
277
|
+
projectSlug === 'jamdesk-docs' &&
|
|
278
|
+
Boolean(process.env.CLOUDFLARE_ZONE_ID) &&
|
|
279
|
+
Boolean(process.env.CLOUDFLARE_API_TOKEN)
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
241
283
|
/**
|
|
242
284
|
* Purge Cloudflare CDN cache for jamdesk.com docs assets.
|
|
243
285
|
* Only runs for the jamdesk-docs project (served via Cloudflare Worker at jamdesk.com/docs).
|
|
244
286
|
* Customer projects at *.jamdesk.app go through Vercel CDN, not Cloudflare.
|
|
245
287
|
* Non-fatal on failure — cache will expire based on TTL.
|
|
288
|
+
*
|
|
289
|
+
* @param pass 1 = immediate purge, 2 = delayed re-purge (see purgeCloudflareCacheTwice)
|
|
246
290
|
*/
|
|
247
|
-
async function purgeCloudflareCache(projectSlug: string): Promise<void> {
|
|
291
|
+
async function purgeCloudflareCache(projectSlug: string, pass: 1 | 2): Promise<void> {
|
|
248
292
|
if (projectSlug !== 'jamdesk-docs') return;
|
|
249
293
|
|
|
250
294
|
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
|
|
251
295
|
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
252
296
|
|
|
253
297
|
if (!zoneId || !apiToken) {
|
|
254
|
-
log('info', 'Skipping Cloudflare cache purge (credentials not configured)', { projectSlug });
|
|
298
|
+
log('info', 'Skipping Cloudflare cache purge (credentials not configured)', { projectSlug, pass });
|
|
255
299
|
return;
|
|
256
300
|
}
|
|
257
301
|
|
|
@@ -271,16 +315,16 @@ async function purgeCloudflareCache(projectSlug: string): Promise<void> {
|
|
|
271
315
|
const data = await response.json() as { success: boolean; errors?: Array<{ message: string }> };
|
|
272
316
|
|
|
273
317
|
if (response.ok && data.success) {
|
|
274
|
-
log('info', 'Cloudflare cache purged for jamdesk.com', { projectSlug });
|
|
318
|
+
log('info', 'Cloudflare cache purged for jamdesk.com', { projectSlug, pass });
|
|
275
319
|
} else {
|
|
276
320
|
const errorMsg = data.errors?.[0]?.message || `HTTP ${response.status}`;
|
|
277
321
|
log('warn', 'Cloudflare cache purge failed (non-fatal)', {
|
|
278
|
-
projectSlug, error: errorMsg,
|
|
322
|
+
projectSlug, pass, error: errorMsg,
|
|
279
323
|
});
|
|
280
324
|
}
|
|
281
325
|
} catch (err) {
|
|
282
326
|
log('warn', 'Cloudflare cache purge error (non-fatal)', {
|
|
283
|
-
projectSlug, error: (err as Error).message,
|
|
327
|
+
projectSlug, pass, error: (err as Error).message,
|
|
284
328
|
});
|
|
285
329
|
}
|
|
286
330
|
}
|
|
@@ -297,36 +341,67 @@ export interface Manifest {
|
|
|
297
341
|
docsPrefix?: string;
|
|
298
342
|
}
|
|
299
343
|
|
|
344
|
+
/**
|
|
345
|
+
* IndexNow's per-request URL limit. Collection stops here so a pathological
|
|
346
|
+
* sweep (e.g., a 50k-page Prettier run) can't build an unbounded list; past
|
|
347
|
+
* it the tail is simply not submitted, which IndexNow's own cap would force
|
|
348
|
+
* anyway. The old cap was MANY_MDX_THRESHOLD + 1 because the list was
|
|
349
|
+
* discarded once the decision escalated to `all` — IndexNow now reads it
|
|
350
|
+
* exactly in that case, so the list must survive escalation.
|
|
351
|
+
*/
|
|
352
|
+
const MAX_TRACKED_MDX_CHANGES = 10_000;
|
|
353
|
+
|
|
300
354
|
function getMdxChangedPaths(
|
|
301
355
|
oldManifest: Manifest | null,
|
|
302
356
|
newManifest: Manifest
|
|
303
|
-
): string[] {
|
|
357
|
+
): { changed: string[]; deleted: string[] } {
|
|
304
358
|
if (!oldManifest) {
|
|
305
|
-
return
|
|
359
|
+
return {
|
|
360
|
+
changed: Object.keys(newManifest.files).map(path => path.replace('.mdx', '')),
|
|
361
|
+
deleted: [],
|
|
362
|
+
};
|
|
306
363
|
}
|
|
307
364
|
|
|
308
|
-
// Past the threshold the decision escalates to project-wide and the array
|
|
309
|
-
// is discarded, so cap collection at threshold+1 to avoid building huge
|
|
310
|
-
// path lists for bulk edits (e.g., 5k-page Prettier sweeps).
|
|
311
|
-
const cap = MANY_MDX_THRESHOLD + 1;
|
|
312
365
|
const changed: string[] = [];
|
|
366
|
+
const deleted: string[] = [];
|
|
367
|
+
let truncated = false;
|
|
313
368
|
|
|
314
369
|
for (const [path, info] of Object.entries(newManifest.files)) {
|
|
315
|
-
if (changed.length >= cap) return changed;
|
|
316
370
|
const oldInfo = oldManifest.files[path];
|
|
317
371
|
if (!oldInfo || oldInfo.hash !== info.hash) {
|
|
372
|
+
if (changed.length >= MAX_TRACKED_MDX_CHANGES) {
|
|
373
|
+
truncated = true;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
318
376
|
changed.push(path.replace('.mdx', ''));
|
|
319
377
|
}
|
|
320
378
|
}
|
|
321
379
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
changed.
|
|
380
|
+
if (!truncated) {
|
|
381
|
+
for (const path of Object.keys(oldManifest.files)) {
|
|
382
|
+
if (path in newManifest.files) continue;
|
|
383
|
+
if (changed.length >= MAX_TRACKED_MDX_CHANGES) {
|
|
384
|
+
truncated = true;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
const stripped = path.replace('.mdx', '');
|
|
388
|
+
changed.push(stripped);
|
|
389
|
+
// Deletions stay distinguishable: a deleted page needs a different
|
|
390
|
+
// IndexNow treatment than an edited one (its URL now 404s).
|
|
391
|
+
deleted.push(stripped);
|
|
326
392
|
}
|
|
327
393
|
}
|
|
328
394
|
|
|
329
|
-
|
|
395
|
+
// Silent truncation would look like a complete list downstream; one line per
|
|
396
|
+
// decision makes a capped sweep visible in Cloud Run logs.
|
|
397
|
+
if (truncated) {
|
|
398
|
+
log('warn', 'MDX change list truncated at the tracking cap; remaining changed pages will not be submitted to IndexNow', {
|
|
399
|
+
maxTrackedMdxChanges: MAX_TRACKED_MDX_CHANGES,
|
|
400
|
+
tracked: changed.length,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { changed, deleted };
|
|
330
405
|
}
|
|
331
406
|
|
|
332
407
|
function hasConfigChanges(oldManifest: Manifest, newManifest: Manifest): boolean {
|
|
@@ -372,9 +447,7 @@ function hasAssetChanges(oldManifest: Manifest, newManifest: Manifest): boolean
|
|
|
372
447
|
* `previousDocsSubpath`, which is sticky BY DESIGN (Task 3 keeps it forever
|
|
373
448
|
* so the old prefix's 308 redirect keeps working) and would otherwise make
|
|
374
449
|
* every build after a rename look like a rename, permanently replacing
|
|
375
|
-
* targeted revalidation with a full purge
|
|
376
|
-
* (build.ts's `indexNowPaths.length > 0` gate never fires again once `all`
|
|
377
|
-
* empties changedPaths on every build).
|
|
450
|
+
* targeted revalidation with a full purge on every build.
|
|
378
451
|
*
|
|
379
452
|
* Manifest-based comparison self-clears: the build that performs the rename
|
|
380
453
|
* records the new prefix into ITS OWN manifest, so the very next build's
|
|
@@ -392,13 +465,11 @@ function hasAssetChanges(oldManifest: Manifest, newManifest: Manifest): boolean
|
|
|
392
465
|
* build after this shipped, then self-clears once the new manifest records
|
|
393
466
|
* a value to compare against next time.
|
|
394
467
|
*
|
|
395
|
-
* That one legacy fire
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
* but it means a wave of full-project purges with no IndexNow pings is the
|
|
401
|
-
* EXPECTED signature in the days after this ships, not a regression.
|
|
468
|
+
* That one legacy fire escalates the build to `all`, which empties
|
|
469
|
+
* `changedPaths` — but IndexNow reads `mdxChangedPaths`, which `all` never
|
|
470
|
+
* touches, so the fire-once build still submits whatever MDX actually
|
|
471
|
+
* changed. A full purge with few or no IndexNow pings is therefore just a
|
|
472
|
+
* build that changed no MDX, not a sign IndexNow was starved.
|
|
402
473
|
*/
|
|
403
474
|
function hasDocsPrefixChange(oldManifest: Manifest, newManifest: Manifest): boolean {
|
|
404
475
|
if (newManifest.docsPrefix === undefined) return false;
|
|
@@ -421,6 +492,15 @@ export interface RevalidationDecision {
|
|
|
421
492
|
changedPaths: string[];
|
|
422
493
|
all: boolean;
|
|
423
494
|
reasons: RevalidationReason[];
|
|
495
|
+
/** Added, modified and deleted MDX paths (`.mdx` stripped, locale-prefixed
|
|
496
|
+
* e.g. `fr/intro`), regardless of `all`. Unlike `changedPaths` — which is
|
|
497
|
+
* emptied whenever the decision escalates to a full purge — this survives
|
|
498
|
+
* config/many_mdx/docs_prefix/FORCE_ALL escalation, because IndexNow must
|
|
499
|
+
* not be starved by a cache-escalation decision. `[]` on first build. */
|
|
500
|
+
mdxChangedPaths: string[];
|
|
501
|
+
/** Subset of `mdxChangedPaths` present in the old manifest but not the new
|
|
502
|
+
* (deleted pages). `[]` on first build. */
|
|
503
|
+
mdxDeletedPaths: string[];
|
|
424
504
|
}
|
|
425
505
|
|
|
426
506
|
/**
|
|
@@ -436,10 +516,16 @@ export function getRevalidationDecision(
|
|
|
436
516
|
newManifest: Manifest
|
|
437
517
|
): RevalidationDecision {
|
|
438
518
|
if (!oldManifest) {
|
|
439
|
-
return {
|
|
519
|
+
return {
|
|
520
|
+
changedPaths: [],
|
|
521
|
+
all: true,
|
|
522
|
+
reasons: ['first_build'],
|
|
523
|
+
mdxChangedPaths: [],
|
|
524
|
+
mdxDeletedPaths: [],
|
|
525
|
+
};
|
|
440
526
|
}
|
|
441
527
|
|
|
442
|
-
const changedPaths = getMdxChangedPaths(oldManifest, newManifest);
|
|
528
|
+
const { changed: changedPaths, deleted: deletedPaths } = getMdxChangedPaths(oldManifest, newManifest);
|
|
443
529
|
const reasons: RevalidationReason[] = [];
|
|
444
530
|
if (changedPaths.length > MANY_MDX_THRESHOLD) {
|
|
445
531
|
reasons.push('many_mdx');
|
|
@@ -468,6 +554,8 @@ export function getRevalidationDecision(
|
|
|
468
554
|
changedPaths: all ? [] : changedPaths,
|
|
469
555
|
all,
|
|
470
556
|
reasons,
|
|
557
|
+
mdxChangedPaths: changedPaths,
|
|
558
|
+
mdxDeletedPaths: deletedPaths,
|
|
471
559
|
};
|
|
472
560
|
}
|
|
473
561
|
|
|
@@ -93,6 +93,21 @@ function isPageMetadataIncluded(page: PageMetadata, visibility?: VisibilityInput
|
|
|
93
93
|
).inArtifacts;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Per-page indexability for every page in the build, keyed by page path.
|
|
98
|
+
*
|
|
99
|
+
* Uses the same rule as the sitemap and llms.txt (isPageMetadataIncluded), so
|
|
100
|
+
* IndexNow submissions can never disagree with what we tell crawlers is
|
|
101
|
+
* indexable. A path missing from the map means the page is not in this build
|
|
102
|
+
* at all (deleted, or never existed) — callers must not treat that as "true".
|
|
103
|
+
*/
|
|
104
|
+
export function getPageIndexability(
|
|
105
|
+
pages: PageMetadata[],
|
|
106
|
+
visibility?: VisibilityInputs,
|
|
107
|
+
): Map<string, boolean> {
|
|
108
|
+
return new Map(pages.map((p) => [p.path, isPageMetadataIncluded(p, visibility)]));
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
/**
|
|
97
112
|
* Generate sitemap.xml from page metadata.
|
|
98
113
|
*
|