Fix Cloudflare Pages custom domain lookup (#958)

* Support Cloudflare Pages custom domains without hiding pages.dev fallback

Keep the default Pages preview as the first public link while optional owned-zone binding provisions DNS and Pages custom-domain state in parallel.

Constraint: Cloudflare deploys must use the existing direct-upload API path with no Wrangler dependency.

Constraint: pages.dev must stay visible even while custom-domain verification is pending.

Rejected: Vercel custom-domain support | outside requested Cloudflare-only scope.

Rejected: overwriting arbitrary CNAME records | risks taking over user-managed DNS.

Confidence: high

Scope-risk: moderate

Directive: Do not expose providerMetadata through public deploy contracts; keep custom-domain DNS ownership checks conservative.

Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts

Tested: pnpm --filter @open-design/contracts build && pnpm --filter @open-design/contracts typecheck && pnpm --filter @open-design/contracts test

Tested: pnpm --filter @open-design/web typecheck && pnpm --filter @open-design/web test -- providers/registry.test.ts components/FileViewer.test.tsx i18n/locales.test.ts

Tested: pnpm i18n:check && pnpm guard && pnpm typecheck

Tested: pnpm --filter @open-design/daemon build && pnpm --filter @open-design/web build && git diff --check

Not-tested: real Cloudflare account/token/domain smoke test

* Preserve Cloudflare fallback correctness under large accounts and races

Constraint: Cloudflare Pages keeps pages.dev as the primary usable fallback while custom domains remain optional typed metadata.
Rejected: Treating custom-domain DNS or binding failure as a top-level deployment failure | pages.dev can still be ready and usable.
Confidence: high
Scope-risk: moderate
Directive: Keep custom-domain finality tied to Cloudflare Pages API active status plus URL reachability; do not expose providerMetadata.
Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts; pnpm --filter @open-design/web test -- components/FileViewer.test.tsx i18n/locales.test.ts providers/registry.test.ts; pnpm --filter @open-design/daemon typecheck; pnpm --filter @open-design/web typecheck; pnpm i18n:check; git diff --check; pnpm guard; pnpm typecheck; pnpm --filter @open-design/daemon build; pnpm --filter @open-design/web build
Not-tested: Real Cloudflare token/account/zone smoke test.

* Keep impeccable design notes local

Constraint: .impeccable.md is local assistant/design context and should not be part of the PR diff.
Rejected: Keeping the file tracked while adding it to .gitignore | tracked files are not ignored by Git.
Confidence: high
Scope-risk: narrow
Directive: Keep .impeccable.md untracked and ignored; do not rely on it for required project documentation.
Tested: git check-ignore -v .impeccable.md; git diff --check
Not-tested: Full workspace tests not rerun for ignore-only metadata change.

* Use direct Pages domain lookup for custom bindings

Cloudflare rejects list-style options on Pages custom-domain lookup in some accounts, so the deploy path now reads the selected hostname directly before creating a binding. This keeps pages.dev deployment success intact while avoiding a failed custom-domain branch caused by page/per_page query parameters.

Constraint: Cloudflare Pages custom-domain lookup must not send unsupported page/per_page list options

Rejected: Continue paginating /domains | Cloudflare returns invalid list options before the binding can be created

Confidence: high

Scope-risk: narrow

Directive: Keep pages.dev as the primary deployment URL and treat custom-domain setup as a recoverable secondary branch

Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts; pnpm --filter @open-design/daemon typecheck; pnpm guard; pnpm typecheck; git diff --check; pnpm --filter @open-design/daemon build

Not-tested: Live Cloudflare deployment was not retriggered from the browser
This commit is contained in:
kami 2026-05-08 21:20:48 +08:00 committed by GitHub
parent 2340d38d9d
commit 0f586c410d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 64 additions and 47 deletions

View file

@ -859,18 +859,18 @@ async function ensureCloudflarePagesDomain(config: DeployConfig, hostname: strin
}
async function findCloudflarePagesDomain(config: DeployConfig, hostname: string) {
const domains = await fetchCloudflarePaginatedResult(
config,
(page, perPage) => {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
return `${cloudflarePagesProjectUrl(config, 'domains')}?${params.toString()}`;
},
'Cloudflare Pages custom domain lookup failed.',
);
return domains.find((domain) => normalizeHostname(domain?.name) === normalizeHostname(hostname)) || null;
const normalizedHostname = normalizeHostname(hostname);
if (!normalizedHostname) return null;
const resp = await fetch(cloudflarePagesProjectDomainUrl(config, normalizedHostname), {
headers: cloudflareHeaders(config),
});
const json = await readCloudflareJson(resp);
if (resp.status === 404) return null;
if (!resp.ok || json?.success === false) {
throw cloudflareError(json, resp.status, 'Cloudflare Pages custom domain lookup failed.');
}
const domain = json?.result ?? json;
return normalizeHostname(domain?.name) === normalizedHostname ? domain : null;
}
export async function readCloudflarePagesDomain(config: DeployConfig, hostname: string) {
@ -1769,6 +1769,10 @@ function cloudflarePagesProjectUrl(config: DeployConfig, suffix = '') {
return suffix ? `${base}/${suffix}` : base;
}
function cloudflarePagesProjectDomainUrl(config: DeployConfig, hostname: string) {
return `${cloudflarePagesProjectUrl(config, 'domains')}/${encodeURIComponent(hostname)}`;
}
function cloudflarePagesProductionUrl(config: DeployConfig) {
return config?.projectName ? `https://${config.projectName}.pages.dev` : '';
}

View file

@ -549,17 +549,23 @@ describe('deploy provider routes', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url.includes(`/pages/projects/${expectedPagesProject}/domains?`) && method === 'GET') {
if (url.endsWith(`/pages/projects/${expectedPagesProject}/domains/demo.example.com`) && method === 'GET') {
domainListCount += 1;
const result =
domainListCount === 1
? []
: [{
name: 'demo.example.com',
status: domainListCount === 2 ? 'pending' : 'active',
validation_data: { txt_name: '_cf-custom-hostname.demo.example.com' },
verification_data: { cname: `${expectedPagesProject}.pages.dev` },
}];
if (domainListCount === 1) {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
const result = {
name: 'demo.example.com',
status: domainListCount === 2 ? 'pending' : 'active',
validation_data: { txt_name: '_cf-custom-hostname.demo.example.com' },
verification_data: { cname: `${expectedPagesProject}.pages.dev` },
};
return new Response(JSON.stringify({ success: true, result }), {
status: 200,
headers: { 'content-type': 'application/json' },

View file

@ -870,7 +870,6 @@ describe('cloudflare pages deploys', () => {
dnsCreateAlreadyExists?: boolean;
dnsCreateRejectsComment?: boolean;
pagesDomains?: Array<Record<string, unknown>>;
pagesDomainPages?: Array<Array<Record<string, unknown>>>;
customHeadStatus?: number;
} = {}) {
const indexHash = cloudflarePagesAssetHash({
@ -974,19 +973,21 @@ describe('cloudflare pages deploys', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
const requestUrl = new URL(url);
const page = Number(requestUrl.searchParams.get('page') || '1');
const domainPages = options.pagesDomainPages;
const result = domainPages ? domainPages[page - 1] ?? [] : options.pagesDomains ?? [];
if (url.endsWith('/pages/projects/demo-pages/domains/demo.example.com') && method === 'GET') {
const result = (options.pagesDomains ?? [])
.find((domain) => domain.name === 'demo.example.com');
if (!result) {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({
success: true,
result,
result_info: {
page,
per_page: 100,
total_pages: domainPages?.length || 1,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
@ -1637,9 +1638,12 @@ describe('cloudflare pages deploys', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
if (url.endsWith('/pages/projects/demo-pages/domains/demo.example.com') && method === 'GET') {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
@ -1776,12 +1780,9 @@ describe('cloudflare pages deploys', () => {
expect(calls.filter((call) => call.url.endsWith('/zones/zone-1/dns_records') && call.method === 'POST')).toHaveLength(1);
});
it('finds existing Cloudflare Pages custom domains beyond the first page', async () => {
it('reads an existing Cloudflare Pages custom domain without unsupported list pagination', async () => {
const { calls, fetchMock } = createCustomDomainDeployMock({
pagesDomainPages: [
[{ name: 'other.example.com', status: 'active' }],
[{ name: 'demo.example.com', status: 'active' }],
],
pagesDomains: [{ name: 'demo.example.com', status: 'active' }],
});
vi.stubGlobal('fetch', fetchMock);
@ -1798,9 +1799,12 @@ describe('cloudflare pages deploys', () => {
},
});
const domainLookupUrls = calls
.filter((call) => call.url.includes('/pages/projects/demo-pages/domains?') && call.method === 'GET')
.map((call) => new URL(call.url).searchParams.get('page'));
expect(domainLookupUrls).toEqual(['1', '2']);
.filter((call) => call.url.includes('/pages/projects/demo-pages/domains/') && call.method === 'GET')
.map((call) => call.url);
expect(domainLookupUrls).toEqual([
'https://api.cloudflare.com/client/v4/accounts/account_123/pages/projects/demo-pages/domains/demo.example.com',
]);
expect(domainLookupUrls.every((url) => !url.includes('?'))).toBe(true);
expect(calls.some((call) => call.url.endsWith('/pages/projects/demo-pages/domains') && call.method === 'POST')).toBe(false);
});
@ -2112,9 +2116,12 @@ describe('cloudflare pages deploys', () => {
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/pages/projects/demo-pages/domains?') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
if (url.endsWith('/pages/projects/demo-pages/domains/demo.example.com') && method === 'GET') {
return new Response(JSON.stringify({
success: false,
errors: [{ message: 'Custom domain not found' }],
}), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}