mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
2340d38d9d
commit
0f586c410d
3 changed files with 64 additions and 47 deletions
|
|
@ -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` : '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue