jamdesk 1.1.167 → 1.1.169
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/vendored-sync.test.js +4 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/package.json +1 -1
- package/vendored/components/navigation/Sidebar.tsx +247 -141
- package/vendored/lib/middleware-helpers.ts +265 -2
- package/vendored/lib/project-resolver.ts +10 -0
- package/vendored/lib/screenshot-capture.ts +7 -1
- package/vendored/workspace-package-lock.json +27 -111
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +0 -2
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +0 -1
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +0 -112
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +0 -1
- package/dist/__tests__/unit/language-filter.test.d.ts +0 -2
- package/dist/__tests__/unit/language-filter.test.d.ts.map +0 -1
- package/dist/__tests__/unit/language-filter.test.js +0 -166
- package/dist/__tests__/unit/language-filter.test.js.map +0 -1
- package/dist/lib/language-filter.d.ts +0 -31
- package/dist/lib/language-filter.d.ts.map +0 -1
- package/dist/lib/language-filter.js +0 -14
- package/dist/lib/language-filter.js.map +0 -1
- package/vendored/lib/sidebar-prefetch-walker.ts +0 -50
|
@@ -15,11 +15,12 @@ import {
|
|
|
15
15
|
isSubdomain,
|
|
16
16
|
} from './project-resolver';
|
|
17
17
|
import { redis } from './redis';
|
|
18
|
-
import { getDomainMapping, getDomainStatus, getProjectInactive } from './cached-redis';
|
|
19
|
-
import { getForwardedHosts, isJamdeskDomain } from './domain-helpers';
|
|
18
|
+
import { getDomainConfig, getDomainMapping, getDomainStatus, getProjectInactive } from './cached-redis';
|
|
19
|
+
import { getForwardedHosts, isJamdeskDomain, normalizeForwardedHost, parseRedisConfig } from './domain-helpers';
|
|
20
20
|
import { getRedirects, matchRedirect, mergeQueryStrings, isInvalidDestination } from './redirect-matcher';
|
|
21
21
|
import { ASSET_PREFIX } from './docs-types';
|
|
22
22
|
import { extractLanguageFromPath } from './language-utils';
|
|
23
|
+
import { NextResponse } from 'next/server';
|
|
23
24
|
import type { NextRequest } from 'next/server';
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -77,6 +78,13 @@ export interface ProjectResolutionResult {
|
|
|
77
78
|
* URL indexing — the upstream subdomain is not the public face).
|
|
78
79
|
*/
|
|
79
80
|
customDomain?: string;
|
|
81
|
+
// NOTE: there is deliberately no `customDomainOnly` here. The gate
|
|
82
|
+
// (`customDomainOnlyBlock`) re-derives it from `getProjectConfig(slug)` on
|
|
83
|
+
// purpose — one of its call sites has only a slug parsed out of the URL, and
|
|
84
|
+
// a gate that trusts a caller-supplied resolution is a gate that diverges
|
|
85
|
+
// from the one the other call site enforces. Do not add the field back "to
|
|
86
|
+
// save a Redis read": the read is TtlCached, and the field would be an
|
|
87
|
+
// invitation to trust it.
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
/**
|
|
@@ -880,3 +888,258 @@ export function getAssetsApiPath(pathname: string): string {
|
|
|
880
888
|
.replace(/^public\//, '');
|
|
881
889
|
return `/api/assets/${assetPath}`;
|
|
882
890
|
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Domain-side validation for the canonical override.
|
|
894
|
+
*
|
|
895
|
+
* `resolution.customDomain` is a projectCfg-side MIRROR: registerDomain
|
|
896
|
+
* writes it PRE-verification, and clearCustomDomainOnRemove's best-effort
|
|
897
|
+
* Redis delete can fail — so the mirror alone can be premature (domain
|
|
898
|
+
* never activated) or stale (domain removed). The DOMAIN-side records
|
|
899
|
+
* (`domain:<host>`, `domainStatus:<host>`, `domainCfg:<host>`) are written
|
|
900
|
+
* only at activation and deleted on removal, so they are the source of
|
|
901
|
+
* truth:
|
|
902
|
+
* - the mapping must point back at this project, and
|
|
903
|
+
* - domainStatus must be 'active' (or 'cached', mirroring how
|
|
904
|
+
* handleProjectResolution treats domainStatus).
|
|
905
|
+
*
|
|
906
|
+
* Returns null when validation fails — stale/premature mirror, or a
|
|
907
|
+
* transient Redis error (never break the request over a canonical tag).
|
|
908
|
+
* On success returns the domain's OWN serving mode from domainCfg
|
|
909
|
+
* (hostAtDocs defaults true when the field is absent, mirroring
|
|
910
|
+
* resolveCustomDomain), or `{ hostAtDocs: undefined }` when the domainCfg
|
|
911
|
+
* record is missing entirely (legacy domains) so the caller preserves the
|
|
912
|
+
* projectCfg-derived URL shape.
|
|
913
|
+
*
|
|
914
|
+
* Hot-path note: all three reads go through the TtlCache-fronted
|
|
915
|
+
* cached-redis helpers (60s positive TTL, single-flight), batched into one
|
|
916
|
+
* parallel round-trip — at most one Redis fetch per domain per instance
|
|
917
|
+
* per TTL window.
|
|
918
|
+
*
|
|
919
|
+
* Lives here (not proxy.ts) because `customDomainOnlyBlock` below needs it
|
|
920
|
+
* too — a helper used from two modules belongs in the shared one.
|
|
921
|
+
* `proxy.ts` re-exports it so existing imports keep resolving.
|
|
922
|
+
*/
|
|
923
|
+
export async function validateCanonicalDomain(
|
|
924
|
+
customDomain: string,
|
|
925
|
+
projectSlug: string | null,
|
|
926
|
+
): Promise<{ hostAtDocs: boolean | undefined } | null> {
|
|
927
|
+
try {
|
|
928
|
+
const [mapping, status, cfgRaw] = await Promise.all([
|
|
929
|
+
getDomainMapping(customDomain),
|
|
930
|
+
getDomainStatus(customDomain),
|
|
931
|
+
getDomainConfig(customDomain),
|
|
932
|
+
]);
|
|
933
|
+
if (!projectSlug || mapping !== projectSlug) {
|
|
934
|
+
log('warn', '[canonical-domain-stale]', { customDomain, projectSlug, mapping });
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
if (status !== 'active' && status !== 'cached') {
|
|
938
|
+
log('warn', '[canonical-domain-inactive]', { customDomain, projectSlug, status });
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
const cfg = parseRedisConfig(cfgRaw);
|
|
942
|
+
if (!cfg) return { hostAtDocs: undefined };
|
|
943
|
+
return { hostAtDocs: cfg.hostAtDocs !== false };
|
|
944
|
+
} catch (e) {
|
|
945
|
+
log('warn', '[canonical-domain-check-failed]', { customDomain, error: (e as Error).message });
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
951
|
+
// "Custom domain only" mode
|
|
952
|
+
//
|
|
953
|
+
// When a project turns this on, a direct hit on <slug>.jamdesk.app 404s unless
|
|
954
|
+
// the request looks like it came through the customer's reverse proxy.
|
|
955
|
+
//
|
|
956
|
+
// This is VISIBILITY control, not ACCESS control: the marker below is a
|
|
957
|
+
// public, documented constant that anyone can append. It keeps crawlers,
|
|
958
|
+
// slug-guessers and idle clicks off the bare subdomain. Customers who need
|
|
959
|
+
// real access control use password protection — which the code below must
|
|
960
|
+
// never, ever bypass.
|
|
961
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The public query marker a reverse proxy appends when it cannot set a request
|
|
965
|
+
* header. Vercel `rewrites` — the shape the dashboard hands out, and the shape
|
|
966
|
+
* most Next.js customers use — can carry a query param in the destination but
|
|
967
|
+
* CANNOT set an upstream header. This exists for them.
|
|
968
|
+
*
|
|
969
|
+
* Namespaced on purpose: a bare `proxy` would collide with a customer's own
|
|
970
|
+
* query params. DOCUMENTED AND PUBLIC — changing this string breaks every
|
|
971
|
+
* deployed customer rewrite.
|
|
972
|
+
*/
|
|
973
|
+
export const PROXY_MARKER_PARAM = 'jd_proxy';
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Read the marker and strip it from the URL in one shot, at a single
|
|
977
|
+
* chokepoint, before anything downstream reads the query string.
|
|
978
|
+
*
|
|
979
|
+
* Stripping matters because `request.nextUrl` feeds redirect Locations
|
|
980
|
+
* (trailing-slash, hostAtDocs) and the auth gate's `redirectFrom`. Left in, the
|
|
981
|
+
* marker bounces back to the browser on the customer's own domain, their proxy
|
|
982
|
+
* re-appends it, and it accumulates. It is not a secret — this is hygiene.
|
|
983
|
+
*
|
|
984
|
+
* CAVEAT: this mutates `request.nextUrl`, which NextResponse.next() does not
|
|
985
|
+
* propagate as a URL rewrite. Middleware-internal consumers see the stripped
|
|
986
|
+
* URL; the rendered page's own `searchParams` may still contain the marker.
|
|
987
|
+
* See the Test Strategy's manual-verification list.
|
|
988
|
+
*
|
|
989
|
+
* A present-but-wrong value (`?jd_proxy=yes`) is NOT proxied, but is still
|
|
990
|
+
* stripped.
|
|
991
|
+
*/
|
|
992
|
+
export function consumeProxyMarker(request: NextRequest): boolean {
|
|
993
|
+
const raw = request.nextUrl.searchParams.get(PROXY_MARKER_PARAM);
|
|
994
|
+
if (raw === null) return false;
|
|
995
|
+
request.nextUrl.searchParams.delete(PROXY_MARKER_PARAM);
|
|
996
|
+
return raw === '1';
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Extensions that carry no documentation content and are fetched cross-origin
|
|
1001
|
+
* from <slug>.jamdesk.app by things that cannot attach a marker: the
|
|
1002
|
+
* dashboard's site-preview <img>, and the editor preview's <base href> image
|
|
1003
|
+
* loads (a root-relative `/images/x.png` ignores any query on the base href, so
|
|
1004
|
+
* there is no marker to give it).
|
|
1005
|
+
*
|
|
1006
|
+
* `.pdf` is DELIBERATELY ABSENT. isAssetRequest() includes it, but a PDF in a
|
|
1007
|
+
* customer's repo is documentation content — exempting it would leave a
|
|
1008
|
+
* content-shaped hole in the gate. Do not reuse isAssetRequest() here.
|
|
1009
|
+
* Derived from ASSET_EXTENSIONS so a future asset type can't silently be in
|
|
1010
|
+
* one list and not the other — only the .pdf carve-out may differ.
|
|
1011
|
+
*/
|
|
1012
|
+
const PRESENTATION_EXTENSIONS = ASSET_EXTENSIONS.filter((ext) => ext !== '.pdf');
|
|
1013
|
+
|
|
1014
|
+
export function isPresentationAsset(pathname: string): boolean {
|
|
1015
|
+
// Lowercase first: /_jd/images/HERO.JPG is served today (isAssetRequest matches
|
|
1016
|
+
// by prefix, not extension), so the gate must not 404 it.
|
|
1017
|
+
const p = pathname.toLowerCase();
|
|
1018
|
+
return PRESENTATION_EXTENSIONS.some((ext) => p.endsWith(ext));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Should this request be 404'd because the project serves from its custom
|
|
1023
|
+
* domain only? Returns the response to send, or null to continue.
|
|
1024
|
+
*
|
|
1025
|
+
* Called from THREE places in proxy.ts, because the middleware early-exits
|
|
1026
|
+
* before its main body for the content-bearing internal API routes:
|
|
1027
|
+
*
|
|
1028
|
+
* 1. the internal-API block — /api/{chat,mcp,r2}/{slug}, which skip
|
|
1029
|
+
* shouldRunMiddleware and carry raw MDX, MCP, and RAG chat. A gate that
|
|
1030
|
+
* misses these does nothing at all. (/api/docs-search is intentionally
|
|
1031
|
+
* NOT covered here — it's an API-key-authenticated REST endpoint, not a
|
|
1032
|
+
* cookie-gated one; see the internalAuthMatch comment in proxy.ts.)
|
|
1033
|
+
* 2. the /api/assets branch — same early-exit, but the slug comes from the
|
|
1034
|
+
* HOST, not the path (the route otherwise trusts a client-supplied
|
|
1035
|
+
* x-project-slug). Presentation assets self-exempt below; `.pdf` does
|
|
1036
|
+
* not, which is the whole point of that branch.
|
|
1037
|
+
* 3. the main body — every page and content artifact.
|
|
1038
|
+
*
|
|
1039
|
+
* The helper is async and self-contained ON PURPOSE: it re-derives the custom
|
|
1040
|
+
* domain from Redis rather than trusting a caller-supplied resolution. The
|
|
1041
|
+
* call sites have very different context (one has only a slug parsed out of the
|
|
1042
|
+
* URL), and a helper that trusts its caller is a helper that diverges. The
|
|
1043
|
+
* reads are TtlCached. This is also why ProjectResolutionResult carries no
|
|
1044
|
+
* customDomainOnly field — see the note there.
|
|
1045
|
+
*
|
|
1046
|
+
* FAIL OPEN: blocks only when validateCanonicalDomain() confirms, live from
|
|
1047
|
+
* Redis, that the custom domain maps back to this slug and is active/cached.
|
|
1048
|
+
* Broken DNS, a removed domain, or a Redis outage all fail that check and the
|
|
1049
|
+
* subdomain keeps serving. A customer can never lock themselves out.
|
|
1050
|
+
*/
|
|
1051
|
+
// Body for the custom-domain-only 404 when a human lands on the bare
|
|
1052
|
+
// subdomain. Deliberately Jamdesk-branded, never tenant-branded — the
|
|
1053
|
+
// project's name or logo here would leak exactly the association the
|
|
1054
|
+
// feature exists to hide. Palette mirrors marketing/app/globals.css.
|
|
1055
|
+
const CUSTOM_DOMAIN_ONLY_404_HTML = `<!doctype html>
|
|
1056
|
+
<html lang="en">
|
|
1057
|
+
<head>
|
|
1058
|
+
<meta charset="utf-8">
|
|
1059
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1060
|
+
<meta name="robots" content="noindex, nofollow">
|
|
1061
|
+
<title>Page not found</title>
|
|
1062
|
+
<style>
|
|
1063
|
+
:root { --bg:#faf8f5; --text:#1b3139; --muted:#5a6f77; --accent:#ff3621; --accent-hover:#eb1600; }
|
|
1064
|
+
@media (prefers-color-scheme: dark) {
|
|
1065
|
+
:root { --bg:#141b1e; --text:#e8eef1; --muted:#8fa8b3; }
|
|
1066
|
+
}
|
|
1067
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
1068
|
+
body { min-height:100vh; display:flex; align-items:center; justify-content:center; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; text-align:center; padding:24px; }
|
|
1069
|
+
main { max-width:26rem; }
|
|
1070
|
+
.mark { font-size:1.1rem; font-weight:700; letter-spacing:-0.01em; margin-bottom:2.5rem; }
|
|
1071
|
+
h1 { font-size:1.5rem; font-weight:600; letter-spacing:-0.02em; margin-bottom:0.75rem; }
|
|
1072
|
+
p { color:var(--muted); line-height:1.6; margin-bottom:2rem; }
|
|
1073
|
+
a.home { display:inline-block; background:var(--accent); color:#fff; text-decoration:none; font-weight:600; font-size:0.95rem; padding:0.65rem 1.4rem; border-radius:8px; }
|
|
1074
|
+
a.home:hover { background:var(--accent-hover); }
|
|
1075
|
+
</style>
|
|
1076
|
+
</head>
|
|
1077
|
+
<body>
|
|
1078
|
+
<main>
|
|
1079
|
+
<div class="mark">Jamdesk</div>
|
|
1080
|
+
<h1>Page not found</h1>
|
|
1081
|
+
<p>There’s nothing at this address.</p>
|
|
1082
|
+
<a class="home" href="https://www.jamdesk.com">Go to jamdesk.com</a>
|
|
1083
|
+
</main>
|
|
1084
|
+
</body>
|
|
1085
|
+
</html>
|
|
1086
|
+
`;
|
|
1087
|
+
|
|
1088
|
+
export async function customDomainOnlyBlock(args: {
|
|
1089
|
+
request: NextRequest;
|
|
1090
|
+
slug: string;
|
|
1091
|
+
pathname: string;
|
|
1092
|
+
hasProxyMarker: boolean;
|
|
1093
|
+
}): Promise<NextResponse | null> {
|
|
1094
|
+
const { request, slug, pathname, hasProxyMarker } = args;
|
|
1095
|
+
|
|
1096
|
+
// Platform kill switch — a per-project Redis write is not an incident
|
|
1097
|
+
// response. Tolerant of any truthy value except '0'/'false' (operators
|
|
1098
|
+
// shouldn't have to remember a magic string mid-incident) and trimmed
|
|
1099
|
+
// (Vercel env values can pick up trailing newlines — see CLAUDE.md
|
|
1100
|
+
// ISR_MODE incident). Mirrors isCacheDisabled() in lib/cached-redis.ts;
|
|
1101
|
+
// inlined rather than imported to avoid dragging that module's AWS SDK
|
|
1102
|
+
// deps into the edge bundle.
|
|
1103
|
+
const disabled = process.env.JD_CUSTOM_DOMAIN_ONLY_DISABLED?.trim().toLowerCase();
|
|
1104
|
+
if (disabled && disabled !== '0' && disabled !== 'false') return null;
|
|
1105
|
+
|
|
1106
|
+
if (hasProxyMarker) return null;
|
|
1107
|
+
if (isPresentationAsset(pathname)) return null;
|
|
1108
|
+
|
|
1109
|
+
// Not a bare-subdomain hit: the request arrived on the custom domain itself.
|
|
1110
|
+
const hostname = request.headers.get('host') || '';
|
|
1111
|
+
if (!isJamdeskDomain(hostname)) return null;
|
|
1112
|
+
|
|
1113
|
+
// Proxied by an edge that can set headers (CF worker, nginx, Apache, Caddy,
|
|
1114
|
+
// HAProxy). Exactly as strong as the public marker — the header is spoofable
|
|
1115
|
+
// and is NOT an auth boundary. Do not "harden" this; see the plan.
|
|
1116
|
+
// Normalized the same way every other consumer in this file reads this
|
|
1117
|
+
// header (lowercase, port stripped, first-of-comma-chain) — a raw read
|
|
1118
|
+
// would let `acme.jamdesk.app:443`/`ACME.JAMDESK.APP` fail isJamdeskDomain
|
|
1119
|
+
// and open the gate.
|
|
1120
|
+
const forwardedHost = normalizeForwardedHost(request.headers.get('x-jamdesk-forwarded-host'));
|
|
1121
|
+
if (forwardedHost && !isJamdeskDomain(forwardedHost)) return null;
|
|
1122
|
+
|
|
1123
|
+
const cfg = await getProjectConfig(slug);
|
|
1124
|
+
if (cfg.customDomainOnly !== true) return null;
|
|
1125
|
+
if (!cfg.customDomain) return null;
|
|
1126
|
+
|
|
1127
|
+
const domain = await validateCanonicalDomain(cfg.customDomain, slug);
|
|
1128
|
+
if (!domain) return null; // FAIL OPEN
|
|
1129
|
+
|
|
1130
|
+
// log() emits single-line JSON, satisfying the monorepo edge-logging rule.
|
|
1131
|
+
log('info', '[custom-domain-only]', { slug, pathname, host: hostname });
|
|
1132
|
+
|
|
1133
|
+
// Humans get the branded page; API consumers of this same gate (MCP,
|
|
1134
|
+
// chat, /api/assets) keep the terse text body so nothing has to parse
|
|
1135
|
+
// HTML. Status and the noindex/no-store headers are identical either way.
|
|
1136
|
+
const wantsHtml = (request.headers.get('accept') || '').includes('text/html');
|
|
1137
|
+
return new NextResponse(wantsHtml ? CUSTOM_DOMAIN_ONLY_404_HTML : 'Not Found', {
|
|
1138
|
+
status: 404,
|
|
1139
|
+
headers: {
|
|
1140
|
+
'X-Robots-Tag': 'noindex, nofollow',
|
|
1141
|
+
'Cache-Control': 'no-store',
|
|
1142
|
+
...(wantsHtml ? { 'Content-Type': 'text/html; charset=utf-8' } : {}),
|
|
1143
|
+
},
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
@@ -107,6 +107,14 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
|
|
|
107
107
|
export interface ProjectCfg {
|
|
108
108
|
hostAtDocs: boolean;
|
|
109
109
|
customDomain?: string;
|
|
110
|
+
/**
|
|
111
|
+
* "Custom domain only" — when true, a direct hit on <slug>.jamdesk.app is
|
|
112
|
+
* 404'd unless it looks proxied. Visibility control, NOT access control: the
|
|
113
|
+
* ?jd_proxy=1 marker that satisfies it is a public constant. Only ever
|
|
114
|
+
* enforced when the project's custom domain passes live Redis validation, so
|
|
115
|
+
* a broken domain fails OPEN.
|
|
116
|
+
*/
|
|
117
|
+
customDomainOnly?: boolean;
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
/**
|
|
@@ -134,6 +142,7 @@ export async function getProjectConfig(projectSlug: string): Promise<ProjectCfg>
|
|
|
134
142
|
return {
|
|
135
143
|
hostAtDocs: cfg?.hostAtDocs === true,
|
|
136
144
|
customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
|
|
145
|
+
customDomainOnly: cfg?.customDomainOnly === true,
|
|
137
146
|
};
|
|
138
147
|
} catch {
|
|
139
148
|
return { hostAtDocs: false };
|
|
@@ -160,6 +169,7 @@ export async function getProjectConfigStrict(projectSlug: string): Promise<Proje
|
|
|
160
169
|
return {
|
|
161
170
|
hostAtDocs: cfg?.hostAtDocs === true,
|
|
162
171
|
customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
|
|
172
|
+
customDomainOnly: cfg?.customDomainOnly === true,
|
|
163
173
|
};
|
|
164
174
|
}
|
|
165
175
|
|
|
@@ -148,7 +148,13 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
|
|
|
148
148
|
const { slug, buildId, siteUrl, hostAtDocs, firstPagePath, r2Client, r2Bucket } = options;
|
|
149
149
|
|
|
150
150
|
// For hostAtDocs sites, homepage is at /docs/{firstPagePath}; otherwise /{firstPagePath}
|
|
151
|
-
|
|
151
|
+
//
|
|
152
|
+
// The bare subdomain 404s for projects in custom-domain-only mode, so the
|
|
153
|
+
// capture must look proxied. Subresources (CSS/JS/fonts/images) carry no
|
|
154
|
+
// marker but are exempt or middleware-skipped — see the subresource test in
|
|
155
|
+
// __tests__/proxy-custom-domain-only.test.ts.
|
|
156
|
+
const homepagePath = hostAtDocs ? `/docs/${firstPagePath}` : `/${firstPagePath}`;
|
|
157
|
+
const homepageUrl = `${siteUrl}${homepagePath}?jd_proxy=1`;
|
|
152
158
|
|
|
153
159
|
let browser: Browser | null = null;
|
|
154
160
|
let timeoutId: NodeJS.Timeout | null = null;
|