jamdesk 1.1.75 → 1.1.77
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/__tests__/unit/deps.test.js +184 -0
- package/dist/__tests__/unit/deps.test.js.map +1 -1
- package/dist/__tests__/unit/dev-spinner-ownership.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-spinner-ownership.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-spinner-ownership.test.js +37 -0
- package/dist/__tests__/unit/dev-spinner-ownership.test.js.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
- package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
- package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
- package/dist/__tests__/unit/language-filter.test.js +166 -0
- package/dist/__tests__/unit/language-filter.test.js.map +1 -0
- package/dist/__tests__/unit/output.test.d.ts +2 -0
- package/dist/__tests__/unit/output.test.d.ts.map +1 -0
- package/dist/__tests__/unit/output.test.js +61 -0
- package/dist/__tests__/unit/output.test.js.map +1 -0
- package/dist/__tests__/unit/spinner.test.d.ts +2 -0
- package/dist/__tests__/unit/spinner.test.d.ts.map +1 -0
- package/dist/__tests__/unit/spinner.test.js +83 -0
- package/dist/__tests__/unit/spinner.test.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +13 -3
- package/dist/commands/dev.js.map +1 -1
- package/dist/lib/deps.d.ts +22 -0
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +121 -27
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/language-filter.d.ts +31 -0
- package/dist/lib/language-filter.d.ts.map +1 -0
- package/dist/lib/language-filter.js +14 -0
- package/dist/lib/language-filter.js.map +1 -0
- package/dist/lib/spinner.d.ts +24 -0
- package/dist/lib/spinner.d.ts.map +1 -1
- package/dist/lib/spinner.js +59 -0
- package/dist/lib/spinner.js.map +1 -1
- package/package.json +3 -3
- package/vendored/app/[[...slug]]/page.tsx +12 -4
- package/vendored/app/layout.tsx +25 -10
- package/vendored/components/mdx/ApiPage.tsx +10 -2
- package/vendored/components/mdx/OpenApiEndpoint.tsx +41 -44
- package/vendored/components/mdx/YouTube.tsx +8 -0
- package/vendored/components/navigation/Sidebar.tsx +32 -17
- package/vendored/components/navigation/TabsNav.tsx +22 -30
- package/vendored/components/ui/CodePanel.tsx +48 -3
- package/vendored/hooks/useIsNavigationSettled.ts +74 -0
- package/vendored/lib/layout-helpers.tsx +27 -0
- package/vendored/lib/middleware-helpers.ts +79 -8
- package/vendored/lib/page-isr-helpers.ts +14 -9
- package/vendored/lib/prefetch-batcher.ts +51 -0
- package/vendored/lib/prefetch-rsc.ts +19 -0
- package/vendored/lib/project-resolver.ts +21 -5
- package/vendored/lib/r2-content.ts +16 -0
- package/vendored/lib/r2-feature-flags.ts +7 -0
- package/vendored/lib/render-doc-page-openapi-helpers.ts +110 -0
- package/vendored/lib/render-doc-page-parallel-helpers.ts +60 -0
- package/vendored/lib/render-doc-page.tsx +101 -52
- package/vendored/lib/sidebar-prefetch-walker.ts +50 -0
- package/vendored/lib/static-artifacts.ts +2 -1
- package/vendored/workspace-package-lock.json +101 -99
|
@@ -58,8 +58,23 @@ export interface ProjectResolutionResult {
|
|
|
58
58
|
* (i.e., a user's own domain pointing at our CNAME), vs via the
|
|
59
59
|
* subdomain branch (slug.jamdesk.app). Used by the branded inactive
|
|
60
60
|
* page to show owner-sign-in hints only to real customers.
|
|
61
|
+
*
|
|
62
|
+
* Renamed from `customDomain` to disambiguate from the new
|
|
63
|
+
* `customDomain?: string` mirror below (which carries the public
|
|
64
|
+
* canonical hostname read from `projectCfg`). The two fields answer
|
|
65
|
+
* different questions:
|
|
66
|
+
* - `viaCustomDomain` = "did this request hit the domain: Redis key?"
|
|
67
|
+
* - `customDomain` = "what is the project's registered public face?"
|
|
61
68
|
*/
|
|
62
|
-
|
|
69
|
+
viaCustomDomain?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Public canonical hostname for this slug, mirrored from `projectCfg`.
|
|
72
|
+
* Set only when the project owner has registered + activated a custom
|
|
73
|
+
* domain. Used by the proxy to override the canonical URL when serving
|
|
74
|
+
* a hostAtDocs project directly via *.jamdesk.app (avoiding duplicate-
|
|
75
|
+
* URL indexing — the upstream subdomain is not the public face).
|
|
76
|
+
*/
|
|
77
|
+
customDomain?: string;
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
/**
|
|
@@ -124,7 +139,11 @@ export async function handleProjectResolution(
|
|
|
124
139
|
slug,
|
|
125
140
|
});
|
|
126
141
|
const config = await getProjectConfig(slug);
|
|
127
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
projectSlug: slug,
|
|
144
|
+
hostAtDocs: config.hostAtDocs,
|
|
145
|
+
customDomain: config.customDomain,
|
|
146
|
+
};
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
log('info', 'Resolving project from hostname', { hostname });
|
|
@@ -166,8 +185,18 @@ export async function handleProjectResolution(
|
|
|
166
185
|
getProjectConfig(projectSlug),
|
|
167
186
|
getProjectInactive(projectSlug),
|
|
168
187
|
]);
|
|
169
|
-
log('info', 'Project resolved from subdomain', {
|
|
170
|
-
|
|
188
|
+
log('info', 'Project resolved from subdomain', {
|
|
189
|
+
hostname,
|
|
190
|
+
projectSlug,
|
|
191
|
+
inactive,
|
|
192
|
+
customDomain: config.customDomain,
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
projectSlug,
|
|
196
|
+
hostAtDocs: config.hostAtDocs,
|
|
197
|
+
inactive,
|
|
198
|
+
customDomain: config.customDomain,
|
|
199
|
+
};
|
|
171
200
|
}
|
|
172
201
|
}
|
|
173
202
|
|
|
@@ -204,7 +233,7 @@ export async function handleProjectResolution(
|
|
|
204
233
|
hostAtDocs: resolution.hostAtDocs,
|
|
205
234
|
domainStatus: resolution.domainStatus,
|
|
206
235
|
inactive,
|
|
207
|
-
|
|
236
|
+
viaCustomDomain: true,
|
|
208
237
|
};
|
|
209
238
|
}
|
|
210
239
|
|
|
@@ -596,15 +625,48 @@ const TRUSTED_PROXY_HEADERS = [
|
|
|
596
625
|
'x-jd-custom-domain',
|
|
597
626
|
'x-jd-project-name',
|
|
598
627
|
'x-jd-project-logo',
|
|
628
|
+
// Canonical override: middleware writes this when serving *.jamdesk.app
|
|
629
|
+
// directly for a hostAtDocs project that has a registered custom
|
|
630
|
+
// domain. The page render path uses it to emit the public-face canonical
|
|
631
|
+
// instead of the upstream subdomain URL.
|
|
632
|
+
'x-jd-canonical-host',
|
|
633
|
+
// Set when *.jamdesk.app serves a hostAtDocs project that has NOT
|
|
634
|
+
// registered a custom domain yet — page emits robots: noindex so the
|
|
635
|
+
// upstream URL doesn't compete with the (yet-to-arrive) public face
|
|
636
|
+
// in search results.
|
|
637
|
+
'x-jd-noindex',
|
|
599
638
|
] as const;
|
|
600
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Optional extras for `buildProjectHeaders`. All fields are middleware-
|
|
642
|
+
* authored; the corresponding header is in `TRUSTED_PROXY_HEADERS` so an
|
|
643
|
+
* inbound spoof is stripped before we set our own value.
|
|
644
|
+
*/
|
|
645
|
+
export interface BuildProjectHeadersOptions {
|
|
646
|
+
/**
|
|
647
|
+
* Public-face canonical host. Set when serving *.jamdesk.app directly
|
|
648
|
+
* for a hostAtDocs project that has a registered custom domain — the
|
|
649
|
+
* page render path emits this host in <link rel="canonical"> instead
|
|
650
|
+
* of the upstream subdomain.
|
|
651
|
+
*/
|
|
652
|
+
canonicalHost?: string;
|
|
653
|
+
/**
|
|
654
|
+
* When true, emit `x-jd-noindex: true` so the page emits a robots
|
|
655
|
+
* noindex tag. Used for *.jamdesk.app subdomains of hostAtDocs
|
|
656
|
+
* projects without a custom domain yet.
|
|
657
|
+
*/
|
|
658
|
+
noindex?: boolean;
|
|
659
|
+
}
|
|
660
|
+
|
|
601
661
|
/**
|
|
602
662
|
* Build response headers with project context.
|
|
603
663
|
*
|
|
604
664
|
* Sets:
|
|
605
|
-
* - x-project-slug
|
|
606
|
-
* - x-host-at-docs
|
|
607
|
-
* - x-jd-language
|
|
665
|
+
* - x-project-slug — resolved project for downstream handlers
|
|
666
|
+
* - x-host-at-docs — whether docs are mounted at /docs
|
|
667
|
+
* - x-jd-language — locale code if the path starts with one (e.g. /fr/...)
|
|
668
|
+
* - x-jd-canonical-host — public-face host (opts.canonicalHost)
|
|
669
|
+
* - x-jd-noindex — "true" when opts.noindex is set
|
|
608
670
|
*
|
|
609
671
|
* Strips any client-supplied copies of those headers from the inbound
|
|
610
672
|
* request to prevent header smuggling.
|
|
@@ -615,6 +677,7 @@ const TRUSTED_PROXY_HEADERS = [
|
|
|
615
677
|
* @param pathname - The request pathname (request.nextUrl.pathname or, for
|
|
616
678
|
* the unlock direct-hit branch, the `from` query param). Used to derive
|
|
617
679
|
* the active locale so the root layout can emit <html lang>.
|
|
680
|
+
* @param opts - Optional extras (canonical host override, noindex flag).
|
|
618
681
|
* @returns New headers with project context added
|
|
619
682
|
*/
|
|
620
683
|
export function buildProjectHeaders(
|
|
@@ -622,6 +685,7 @@ export function buildProjectHeaders(
|
|
|
622
685
|
existingHeaders: Headers,
|
|
623
686
|
hostAtDocs: boolean,
|
|
624
687
|
pathname: string,
|
|
688
|
+
opts: BuildProjectHeadersOptions = {},
|
|
625
689
|
): Headers {
|
|
626
690
|
const newHeaders = new Headers(existingHeaders);
|
|
627
691
|
for (const h of TRUSTED_PROXY_HEADERS) {
|
|
@@ -636,6 +700,13 @@ export function buildProjectHeaders(
|
|
|
636
700
|
newHeaders.set('x-jd-language', language);
|
|
637
701
|
}
|
|
638
702
|
|
|
703
|
+
if (opts.canonicalHost) {
|
|
704
|
+
newHeaders.set('x-jd-canonical-host', opts.canonicalHost);
|
|
705
|
+
}
|
|
706
|
+
if (opts.noindex) {
|
|
707
|
+
newHeaders.set('x-jd-noindex', 'true');
|
|
708
|
+
}
|
|
709
|
+
|
|
639
710
|
return newHeaders;
|
|
640
711
|
}
|
|
641
712
|
|
|
@@ -130,15 +130,20 @@ export function parseCacheKey(cacheKey: string): { projectSlug: string; pagePath
|
|
|
130
130
|
/**
|
|
131
131
|
* Get base URL for SEO metadata in ISR mode.
|
|
132
132
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
133
|
+
* Header priority (highest first):
|
|
134
|
+
* 1. x-jd-canonical-host — internal override set by middleware when a
|
|
135
|
+
* hostAtDocs project is served directly via *.jamdesk.app and has a
|
|
136
|
+
* registered customDomain. Forces the canonical to the public face.
|
|
137
|
+
* 2. x-jamdesk-forwarded-host — set by the Cloudflare Worker proxy
|
|
138
|
+
* (e.g. jamdesk.com → forwarded `jamdesk.com`).
|
|
139
|
+
* 3. host header — direct request hostname.
|
|
140
|
+
* 4. Subdomain fallback `<slug>.jamdesk.app` (no headers available).
|
|
137
141
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
142
|
+
* Both override headers are in TRUSTED_PROXY_HEADERS so a client can't
|
|
143
|
+
* spoof them.
|
|
140
144
|
*
|
|
141
|
-
* When hostAtDocs=true, includes /docs path prefix
|
|
145
|
+
* When hostAtDocs=true, includes /docs path prefix (forwarded-host and
|
|
146
|
+
* canonical-host paths only; subdomain fallback always serves at root).
|
|
142
147
|
*
|
|
143
148
|
* @param headers - Request headers object
|
|
144
149
|
* @param projectSlug - Project identifier (fallback for subdomain URL)
|
|
@@ -146,9 +151,9 @@ export function parseCacheKey(cacheKey: string): { projectSlug: string; pagePath
|
|
|
146
151
|
* @returns Base URL (e.g., 'https://jamdesk.com/docs' or 'https://acme.jamdesk.app')
|
|
147
152
|
*/
|
|
148
153
|
export function getBaseUrl(headers: Headers, projectSlug: string, hostAtDocs = false): string {
|
|
149
|
-
|
|
154
|
+
const canonicalHost = headers.get('x-jd-canonical-host');
|
|
150
155
|
const forwardedHost = headers.get('x-jamdesk-forwarded-host');
|
|
151
|
-
const host = forwardedHost || headers.get('host');
|
|
156
|
+
const host = canonicalHost || forwardedHost || headers.get('host');
|
|
152
157
|
|
|
153
158
|
if (host) {
|
|
154
159
|
const hostname = host.split(':')[0];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side prefetch batcher for sidebar links.
|
|
3
|
+
*
|
|
4
|
+
* Fires up to PARALLEL_LIMIT prefetches in parallel synchronously. The
|
|
5
|
+
* caller is expected to gate calls on "the user's current navigation
|
|
6
|
+
* has settled" so the batch never races the click that triggered it.
|
|
7
|
+
*
|
|
8
|
+
* `seen` is module-lifetime (cleared only on hard navigation/reload).
|
|
9
|
+
* For a 5,000-page docs site this is ~5,000 short strings ≈ tens of KB.
|
|
10
|
+
*
|
|
11
|
+
* In-flight fetches are not cancellable. The bound is the safety story:
|
|
12
|
+
* at most PARALLEL_LIMIT orphaned fetches outlive a re-click.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const PARALLEL_LIMIT = 5;
|
|
16
|
+
|
|
17
|
+
const seen = new Set<string>();
|
|
18
|
+
|
|
19
|
+
function safeInvoke(cb: (href: string) => void, href: string): void {
|
|
20
|
+
try {
|
|
21
|
+
cb(href);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
24
|
+
console.warn('[prefetch-batcher] callback threw for', href, err);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const prefetchBatcher = {
|
|
30
|
+
fire(items: string[], cb: (href: string) => void, limit = PARALLEL_LIMIT): void {
|
|
31
|
+
// SSR guard: module resolves on the server during initial render of
|
|
32
|
+
// client components. seen would leak across requests.
|
|
33
|
+
if (typeof window === 'undefined') return;
|
|
34
|
+
if (items.length === 0) return;
|
|
35
|
+
|
|
36
|
+
const batch = items.slice(0, limit);
|
|
37
|
+
// Mark seen BEFORE invoking — a thrown callback retires the href
|
|
38
|
+
// permanently. Intentional: a route that throws once likely throws
|
|
39
|
+
// again, and prefetches are best-effort. Hard reload clears `seen`.
|
|
40
|
+
for (const href of batch) seen.add(href);
|
|
41
|
+
for (const href of batch) safeInvoke(cb, href);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
hasSeen(href: string): boolean {
|
|
45
|
+
return seen.has(href);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
resetForTesting(): void {
|
|
49
|
+
seen.clear();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Issues an RSC GET (without `Next-Router-Prefetch`) so the route's full
|
|
2
|
+
// server render runs and the regional Vercel Data Cache is populated.
|
|
3
|
+
|
|
4
|
+
export function prefetchRsc(href: string): void {
|
|
5
|
+
if (typeof window === 'undefined') return;
|
|
6
|
+
const sep = href.includes('?') ? '&' : '?';
|
|
7
|
+
const url = `${href}${sep}_rsc=${Date.now().toString(36)}`;
|
|
8
|
+
fetch(url, {
|
|
9
|
+
method: 'GET',
|
|
10
|
+
headers: { RSC: '1', Accept: 'text/x-component' },
|
|
11
|
+
cache: 'no-store',
|
|
12
|
+
credentials: 'same-origin',
|
|
13
|
+
priority: 'low',
|
|
14
|
+
})
|
|
15
|
+
// Consume the body so the response buffer is released. Letting it
|
|
16
|
+
// sit unread holds ~100KB per paced fetch in memory until GC.
|
|
17
|
+
.then((r) => r.arrayBuffer())
|
|
18
|
+
.catch(() => {});
|
|
19
|
+
}
|
|
@@ -97,6 +97,17 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Resolved project configuration mirrored from Redis `projectCfg:<slug>`.
|
|
102
|
+
*
|
|
103
|
+
* `customDomain` is set on activation by the domain.ts helper when a custom
|
|
104
|
+
* domain is TXT-verified. Used by middleware to emit canonical-host headers.
|
|
105
|
+
*/
|
|
106
|
+
export interface ProjectCfg {
|
|
107
|
+
hostAtDocs: boolean;
|
|
108
|
+
customDomain?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
/**
|
|
101
112
|
* Get project configuration for a subdomain (silent-fallback variant).
|
|
102
113
|
* Used for hostAtDocs setting on *.jamdesk.app domains.
|
|
@@ -111,7 +122,7 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
|
|
|
111
122
|
* would memoize a wrong `false` for the cache TTL, the exact failure
|
|
112
123
|
* mode that produced the jamdesk.com/docs/* 404 incident.
|
|
113
124
|
*/
|
|
114
|
-
export async function getProjectConfig(projectSlug: string): Promise<
|
|
125
|
+
export async function getProjectConfig(projectSlug: string): Promise<ProjectCfg> {
|
|
115
126
|
if (!redis) {
|
|
116
127
|
return { hostAtDocs: false };
|
|
117
128
|
}
|
|
@@ -119,8 +130,10 @@ export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDoc
|
|
|
119
130
|
try {
|
|
120
131
|
const cfgRaw = await redis.get(`projectCfg:${projectSlug}`);
|
|
121
132
|
const cfg = parseRedisConfig(cfgRaw);
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
return {
|
|
134
|
+
hostAtDocs: cfg?.hostAtDocs === true,
|
|
135
|
+
customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
|
|
136
|
+
};
|
|
124
137
|
} catch {
|
|
125
138
|
return { hostAtDocs: false };
|
|
126
139
|
}
|
|
@@ -136,14 +149,17 @@ export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDoc
|
|
|
136
149
|
* A missing `projectCfg:<slug>` key is NOT an error — that's the legitimate
|
|
137
150
|
* "project hasn't opted into hostAtDocs" case and is safe to cache.
|
|
138
151
|
*/
|
|
139
|
-
export async function getProjectConfigStrict(projectSlug: string): Promise<
|
|
152
|
+
export async function getProjectConfigStrict(projectSlug: string): Promise<ProjectCfg> {
|
|
140
153
|
if (!redis) {
|
|
141
154
|
return { hostAtDocs: false };
|
|
142
155
|
}
|
|
143
156
|
|
|
144
157
|
const cfgRaw = await redis.get(`projectCfg:${projectSlug}`);
|
|
145
158
|
const cfg = parseRedisConfig(cfgRaw);
|
|
146
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
hostAtDocs: cfg?.hostAtDocs === true,
|
|
161
|
+
customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
|
|
162
|
+
};
|
|
147
163
|
}
|
|
148
164
|
|
|
149
165
|
/**
|
|
@@ -61,3 +61,19 @@ export async function fetchStaticFile(_projectSlug: string, _filename: string):
|
|
|
61
61
|
export async function fetchCustomCss(_projectSlug: string): Promise<string | null> { return null; }
|
|
62
62
|
export async function fetchCustomJs(_projectSlug: string): Promise<string | null> { return null; }
|
|
63
63
|
export function setR2Client(_client: unknown): void {}
|
|
64
|
+
|
|
65
|
+
// Stubs for the perf observability + race-fetch additions. CLI dev mode
|
|
66
|
+
// reads from the filesystem so these all no-op.
|
|
67
|
+
export const R2_CLIENT_CONFIG = {} as Record<string, unknown>;
|
|
68
|
+
export const R2_OPENAPI_CLIENT_CONFIG = {} as Record<string, unknown>;
|
|
69
|
+
export type R2TimingPhase =
|
|
70
|
+
| 'layout-metadata'
|
|
71
|
+
| 'layout-render'
|
|
72
|
+
| 'page-metadata'
|
|
73
|
+
| 'page-render';
|
|
74
|
+
export function incrementR2Op(_ms: number): void {}
|
|
75
|
+
export function getR2OpsSnapshot(): { ops: number; ms: number } { return { ops: 0, ms: 0 }; }
|
|
76
|
+
export function withR2OpsContext<T>(fn: () => T): T { return fn(); }
|
|
77
|
+
export function enterR2OpsContextForTest<T>(fn: () => T): T { return fn(); }
|
|
78
|
+
export function emitR2OpsSummary(_phase: R2TimingPhase, _projectSlug?: string | null): void {}
|
|
79
|
+
export async function withTimeout<T>(promise: Promise<T>, _operation: string): Promise<T> { return promise; }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `?.trim()` is defensive against trailing whitespace in Vercel UI / CLI
|
|
2
|
+
// stdin — see ISR_MODE incident in builder/CLAUDE.md. Function (not const)
|
|
3
|
+
// so tests can flip the env var via `vi.stubEnv` without a module reset,
|
|
4
|
+
// matching the existing `isIsrMode()` pattern in page-isr-helpers.ts.
|
|
5
|
+
export function isR2ParallelCounterEnabled(): boolean {
|
|
6
|
+
return process.env.R2_PARALLEL_COUNTER?.trim() === 'true';
|
|
7
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Try OpenAPI spec candidates concurrently and return the first by INDEX
|
|
2
|
+
// (not by resolution order) whose parse succeeds. Multilingual docs sites
|
|
3
|
+
// (e.g. 10 languages × 3 spec extensions = 30 candidates) previously paid
|
|
4
|
+
// up to 30 sequential R2 RTTs on a cache miss. We fire all candidate
|
|
5
|
+
// fetches at once but await them in index order, so latency on the common
|
|
6
|
+
// case (primary succeeds) is bounded by the primary fetch — not by the
|
|
7
|
+
// slowest of all candidates as it would be with Promise.allSettled.
|
|
8
|
+
import type { OpenApiEndpointData, parseEndpoint } from '@/lib/openapi';
|
|
9
|
+
|
|
10
|
+
type TryCandidate<T> = (specPath: string) => Promise<{ endpoint: T; specPath: string }>;
|
|
11
|
+
|
|
12
|
+
export type CandidateResult<T> =
|
|
13
|
+
| { kind: 'success'; endpoint: T; specPath: string }
|
|
14
|
+
| { kind: 'failure'; lastError: unknown; specPath: string };
|
|
15
|
+
|
|
16
|
+
type Settled<T> =
|
|
17
|
+
| { ok: true; endpoint: T; specPath: string }
|
|
18
|
+
| { ok: false; error: unknown };
|
|
19
|
+
|
|
20
|
+
export async function tryOpenApiCandidatesInParallel<T = OpenApiEndpointData>(
|
|
21
|
+
candidates: string[],
|
|
22
|
+
tryCandidate: TryCandidate<T>,
|
|
23
|
+
): Promise<CandidateResult<T>> {
|
|
24
|
+
if (candidates.length === 0) {
|
|
25
|
+
return { kind: 'failure', lastError: new Error('no candidates'), specPath: '' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Eager catch handlers prevent unhandled-rejection logs when a slow loser
|
|
29
|
+
// rejects after the primary has already won. Loser GETs continue running
|
|
30
|
+
// in the background until R2 responds; `maxAttempts: 1` on the OpenAPI
|
|
31
|
+
// client bounds them to one round-trip each.
|
|
32
|
+
const inflight: Promise<Settled<T>>[] = candidates.map((c) =>
|
|
33
|
+
tryCandidate(c).then(
|
|
34
|
+
({ endpoint, specPath }) => ({ ok: true as const, endpoint, specPath }),
|
|
35
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// On all-reject, surface the primary candidate's error so logs are
|
|
40
|
+
// attributed to the intended spec — fallback errors are typically "404".
|
|
41
|
+
let primaryError: unknown | undefined;
|
|
42
|
+
for (let i = 0; i < inflight.length; i++) {
|
|
43
|
+
const r = await inflight[i];
|
|
44
|
+
if (r.ok) {
|
|
45
|
+
return { kind: 'success', endpoint: r.endpoint, specPath: r.specPath };
|
|
46
|
+
}
|
|
47
|
+
if (primaryError === undefined) primaryError = r.error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
kind: 'failure',
|
|
52
|
+
lastError: primaryError ?? new Error('unknown'),
|
|
53
|
+
specPath: candidates[0],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface TryOpenApiSpecOpts {
|
|
58
|
+
projectSlug: string | null;
|
|
59
|
+
isIsr: boolean;
|
|
60
|
+
parsedMethod: string;
|
|
61
|
+
parsedPath: string;
|
|
62
|
+
resolveIsrSpec: ((slug: string, specPath: string) => Promise<unknown>) | null;
|
|
63
|
+
getStaticSpec: ((specPath: string, contentDir: string) => Promise<{ api: unknown }>) | null;
|
|
64
|
+
contentDir: string | null;
|
|
65
|
+
parseEndpointFn: typeof parseEndpoint;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function makeTryOpenApiSpec(opts: TryOpenApiSpecOpts) {
|
|
69
|
+
return async (specPath: string): Promise<{ endpoint: OpenApiEndpointData; specPath: string }> => {
|
|
70
|
+
const useIsr = opts.isIsr && !!opts.projectSlug && !!opts.resolveIsrSpec;
|
|
71
|
+
if (useIsr && opts.resolveIsrSpec && opts.projectSlug) {
|
|
72
|
+
const spec = await opts.resolveIsrSpec(opts.projectSlug, specPath);
|
|
73
|
+
const endpoint = opts.parseEndpointFn(
|
|
74
|
+
spec as Parameters<typeof parseEndpoint>[0],
|
|
75
|
+
opts.parsedMethod as never,
|
|
76
|
+
opts.parsedPath,
|
|
77
|
+
specPath,
|
|
78
|
+
);
|
|
79
|
+
return { endpoint, specPath };
|
|
80
|
+
}
|
|
81
|
+
if (!opts.getStaticSpec || !opts.contentDir) {
|
|
82
|
+
throw new Error('static spec branch requires getStaticSpec + contentDir');
|
|
83
|
+
}
|
|
84
|
+
const { api } = await opts.getStaticSpec(specPath, opts.contentDir);
|
|
85
|
+
const endpoint = opts.parseEndpointFn(
|
|
86
|
+
api as Parameters<typeof parseEndpoint>[0],
|
|
87
|
+
opts.parsedMethod as never,
|
|
88
|
+
opts.parsedPath,
|
|
89
|
+
specPath,
|
|
90
|
+
);
|
|
91
|
+
return { endpoint, specPath };
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Aggregated on-call signal for misconfigured projects: replaces the
|
|
96
|
+
// per-failure spam log from the old sequential for-loop. Returns null
|
|
97
|
+
// when there's no fallback (single candidate, or primary won). Format
|
|
98
|
+
// is grep-stable — on-call dashboards key off the "[openapi] primary spec"
|
|
99
|
+
// prefix and the "resolved via fallback" delimiter.
|
|
100
|
+
export function formatFallbackWarning(
|
|
101
|
+
candidates: string[],
|
|
102
|
+
winnerSpecPath: string,
|
|
103
|
+
method: string,
|
|
104
|
+
path: string,
|
|
105
|
+
): string | null {
|
|
106
|
+
if (candidates.length <= 1) return null;
|
|
107
|
+
const primary = candidates[0];
|
|
108
|
+
if (winnerSpecPath === primary) return null;
|
|
109
|
+
return `[openapi] primary spec "${primary}" did not contain ${method} ${path}; resolved via fallback "${winnerSpecPath}"`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Snippets (R2 I/O) and inline-component extraction (Babel/remark CPU)
|
|
2
|
+
// have no data dependency once raw + preprocessed MDX are available, so
|
|
3
|
+
// they run as Promise.all. The JS event loop interleaves Babel work with
|
|
4
|
+
// R2 await microtasks, giving wall-clock = max(R2, CPU) instead of sum.
|
|
5
|
+
import type React from 'react';
|
|
6
|
+
import type { ExtractedParam } from './remark-extract-param-fields';
|
|
7
|
+
|
|
8
|
+
// AnyComponent matches the project-wide convention used in
|
|
9
|
+
// process-mdx-with-exports.ts and snippet-loader-isr.ts. ComponentType<unknown>
|
|
10
|
+
// is too strict — MDXComponents includes typed React components like
|
|
11
|
+
// MemoExoticComponent<({ title }: CardProps) => JSX.Element>, which are NOT
|
|
12
|
+
// assignable to ComponentType<unknown> (props variance).
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
type AnyComponent = React.ComponentType<any>;
|
|
15
|
+
|
|
16
|
+
interface ParallelInput {
|
|
17
|
+
isIsr: boolean;
|
|
18
|
+
projectSlug: string | null;
|
|
19
|
+
rawContent: string;
|
|
20
|
+
preprocessedContent: string;
|
|
21
|
+
mdxComponents: Record<string, AnyComponent>;
|
|
22
|
+
snippetComponents: Record<string, AnyComponent>;
|
|
23
|
+
loadSnippets: (
|
|
24
|
+
projectSlug: string,
|
|
25
|
+
rawContent: string,
|
|
26
|
+
builtIns: Record<string, AnyComponent>,
|
|
27
|
+
) => Promise<Record<string, AnyComponent>>;
|
|
28
|
+
buildAlias: (
|
|
29
|
+
rawContent: string,
|
|
30
|
+
snippetComponents: Record<string, AnyComponent>,
|
|
31
|
+
) => Record<string, AnyComponent>;
|
|
32
|
+
extractInline: (
|
|
33
|
+
content: string,
|
|
34
|
+
components: Record<string, AnyComponent>,
|
|
35
|
+
) => Promise<{ inlineComponents: Record<string, AnyComponent>; paramFields: ExtractedParam[] }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ParallelOutput {
|
|
39
|
+
snippetAliases: Record<string, AnyComponent>;
|
|
40
|
+
inlineComponents: Record<string, AnyComponent>;
|
|
41
|
+
paramFields: ExtractedParam[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function loadSnippetsAndInlineComponents(
|
|
45
|
+
input: ParallelInput,
|
|
46
|
+
): Promise<ParallelOutput> {
|
|
47
|
+
const snippetsP = input.isIsr && input.projectSlug
|
|
48
|
+
? input.loadSnippets(input.projectSlug, input.rawContent, input.mdxComponents)
|
|
49
|
+
: Promise.resolve(input.buildAlias(input.rawContent, input.snippetComponents));
|
|
50
|
+
|
|
51
|
+
const inlineP = input.extractInline(input.preprocessedContent, input.mdxComponents);
|
|
52
|
+
|
|
53
|
+
const [snippetAliases, inline] = await Promise.all([snippetsP, inlineP]);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
snippetAliases,
|
|
57
|
+
inlineComponents: inline.inlineComponents,
|
|
58
|
+
paramFields: inline.paramFields,
|
|
59
|
+
};
|
|
60
|
+
}
|