diff --git a/.changeset/domain-redirects.md b/.changeset/domain-redirects.md new file mode 100644 index 00000000..90ef8e20 --- /dev/null +++ b/.changeset/domain-redirects.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/cli": patch +"@cloudhal/astro-renderer": patch +--- + +`cloudhal domain set --redirect www.`: hosts that answer with a 301 to the site's domain (path and query kept), routed to the site Worker as Custom Domains on the next deploy (#49). `cloudhal domain remove redirects` drops them. diff --git a/packages/astro-renderer/src/cloudflare-entry.ts b/packages/astro-renderer/src/cloudflare-entry.ts index ea57219e..94c351d1 100644 --- a/packages/astro-renderer/src/cloudflare-entry.ts +++ b/packages/astro-renderer/src/cloudflare-entry.ts @@ -57,11 +57,12 @@ import { isCloudhalCacheTag } from "./cache-tags.ts"; import { type CommentsRateLimit, isCommentsPath, SiteComments } from "./comments.ts"; import { SITE_CSP } from "./csp.ts"; import { EMBED_HEADER, withEmbedFrames } from "./embed-client.ts"; +import { hostRedirect, type RedirectEnv } from "./host-redirect.ts"; import { PREVIEW_PARAM } from "./resolver.ts"; import { SearchLimiter, searchCost } from "./search-limit.ts"; import { SiteDispatch, unknownHost, withNoindex } from "./site-dispatch.ts"; -interface Env { +interface Env extends RedirectEnv { CLOUDHAL_SITE_ID?: string; /** * RFC-0034 §4: the sites a multi-site Worker serves (comma-separated); the hostname picks one @@ -533,6 +534,8 @@ export function createSiteWorker(options: { siteId?: string } = {}) { const url = new URL(request.url); if (url.pathname === "/__cloudhal/purge") return purge(request, env, options.siteId); if (url.pathname === "/__cloudhal/features") return features(request, env); + const moved = hostRedirect(request, env); + if (moved) return moved; // RFC-0034 §4: the hostname picks the site on a multi-site Worker; everything the request // runs (Astro's render included) then sees that site. const choice = await siteDispatch(env, options.siteId).choose(request); diff --git a/packages/astro-renderer/src/host-redirect.ts b/packages/astro-renderer/src/host-redirect.ts new file mode 100644 index 00000000..c8383c8c --- /dev/null +++ b/packages/astro-renderer/src/host-redirect.ts @@ -0,0 +1,32 @@ +/** + * Hosts that only redirect (#49): `www.example.com` → `https://example.com`, 301, path and query + * kept. `cloudhal deploy` routes them to the site Worker as Custom Domains (so they get a DNS + * record and certificate) and names them in `CLOUDHAL_REDIRECT_HOSTS`, the target in + * `CLOUDHAL_REDIRECT_TO`. Runs first, before any site is picked. + * + * Worker-safe: no imports. + */ +export interface RedirectEnv { + /** Comma- or space-separated hostnames that answer with a redirect. */ + readonly CLOUDHAL_REDIRECT_HOSTS?: string; + /** The hostname they redirect to (the site's custom domain). */ + readonly CLOUDHAL_REDIRECT_TO?: string; +} + +/** The 301 for a request on a redirect-only host, or undefined. */ +export function hostRedirect(request: Request, env: RedirectEnv): Response | undefined { + const to = env.CLOUDHAL_REDIRECT_TO?.trim().toLowerCase(); + if (!to || !env.CLOUDHAL_REDIRECT_HOSTS) return undefined; + const url = new URL(request.url); + const host = url.hostname.toLowerCase().replace(/\.$/, ""); + if (host === to) return undefined; + const hosts = env.CLOUDHAL_REDIRECT_HOSTS.toLowerCase().split(/[\s,]+/); + if (!hosts.includes(host)) return undefined; + return new Response(null, { + status: 301, + headers: { + location: `https://${to}${url.pathname}${url.search}`, + "cache-control": "public, max-age=86400", + }, + }); +} diff --git a/packages/astro-renderer/test/host-redirect.test.ts b/packages/astro-renderer/test/host-redirect.test.ts new file mode 100644 index 00000000..cabb586d --- /dev/null +++ b/packages/astro-renderer/test/host-redirect.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { hostRedirect } from "../src/host-redirect.ts"; + +const env = { + CLOUDHAL_REDIRECT_HOSTS: "www.example.com, old.example.org", + CLOUDHAL_REDIRECT_TO: "example.com", +}; + +describe("redirect-only hosts (#49)", () => { + it("answers 301 to the site domain, path and query kept", () => { + const r = hostRedirect(new Request("https://WWW.example.com/blog/post?ref=x"), env); + expect(r?.status).toBe(301); + expect(r?.headers.get("location")).toBe("https://example.com/blog/post?ref=x"); + expect(hostRedirect(new Request("http://old.example.org/"), env)?.headers.get("location")).toBe( + "https://example.com/", + ); + }); + + it("leaves the site's own host and other hosts alone", () => { + expect(hostRedirect(new Request("https://example.com/"), env)).toBeUndefined(); + expect(hostRedirect(new Request("https://site.acme.workers.dev/"), env)).toBeUndefined(); + expect(hostRedirect(new Request("https://www.example.com/"), {})).toBeUndefined(); + expect( + hostRedirect(new Request("https://www.example.com/"), { + CLOUDHAL_REDIRECT_HOSTS: "www.example.com", + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index d3209224..90f6d9ce 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -163,7 +163,7 @@ Locally, `cloudhal studio` reads the project's SQLite (`analytics_events`), roll **From Git (Workers Builds).** With `WORKERS_CI=1`, which Cloudflare sets in [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), `cloudhal deploy` works from a fresh clone. It takes the names from the committed `wrangler.jsonc`, seeds the starter site when the checkout has none, keeps the secrets on the Workers and does not print the setup link. This is what the Deploy to Cloudflare button runs: see [docs/guides/deploy-button.md](../../docs/guides/deploy-button.md). -**Custom domain.** `cloudhal domain set news.example.com --studio studio.example.com` records the hostnames in `cloudhal.json`; the next `cloudhal deploy` routes them to the Workers as [Cloudflare Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) (the zone must be in the same Cloudflare account — Cloudflare creates the DNS record and certificate) and makes the site's one its primary hostname on the deployment. `domain set` makes it primary in the local store right away, so `cloudhal dev`, `render` and `build` write it into canonical links, the sitemap and feeds before any deploy. The workers.dev addresses keep working. `cloudhal domain remove` and a deploy undo it. +**Custom domain.** `cloudhal domain set news.example.com --studio studio.example.com` records the hostnames in `cloudhal.json`; the next `cloudhal deploy` routes them to the Workers as [Cloudflare Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) (the zone must be in the same Cloudflare account — Cloudflare creates the DNS record and certificate) and makes the site's one its primary hostname on the deployment. `domain set` makes it primary in the local store right away, so `cloudhal dev`, `render` and `build` write it into canonical links, the sitemap and feeds before any deploy. The workers.dev addresses keep working. `cloudhal domain remove` and a deploy undo it. `--redirect www.news.example.com` (repeatable) adds hosts that answer with a 301 to the site's domain, path and query kept: the next deploy routes them to the site Worker as Custom Domains too, so Cloudflare creates their DNS records and certificates. `cloudhal domain remove redirects` drops them. **Email (RFC-0011).** Add `"mail": { "from": "studio@example.com", "fromName": "Example Studio" }` to `cloudhal.json` and the next `cloudhal deploy` gives the admin Worker a [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) `send_email` binding locked to that address. Invites can then be emailed, and staff can reset a forgotten password from the sign-in screen. Onboard the address's domain in Email Service first. Sending to any recipient needs Workers Paid (3,000 emails a month included); on Free, Email Service delivers only to verified destination addresses of your account. Locally, `cloudhal studio` prints these emails in the terminal instead of sending them. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ca166b5e..36c9a54e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -26,6 +26,7 @@ export const OPTIONS = { replace: { type: "boolean" }, "admin-url": { type: "string" }, studio: { type: "string" }, + redirect: { type: "string", multiple: true }, revert: { type: "string" }, to: { type: "string" }, "include-markdown": { type: "boolean" }, diff --git a/packages/cli/src/cli/commands-remote.ts b/packages/cli/src/cli/commands-remote.ts index dfd9ac92..ca59b0dc 100644 --- a/packages/cli/src/cli/commands-remote.ts +++ b/packages/cli/src/cli/commands-remote.ts @@ -60,23 +60,27 @@ const deployCommand: CliCommand = async (_rest, values, { projectDir }) => { function changeDomain(rest: string[], values: CliValues, projectDir: string) { const [action, arg] = rest; if (action === "set") { - if (!arg && !values.studio) - throw new Error("usage: cloudhal domain set [--studio ]"); + const redirects = (values.redirect ?? []).flatMap((v) => v.split(",")); + if (!arg && !values.studio && redirects.length === 0) + throw new Error( + "usage: cloudhal domain set [--studio ] [--redirect ]…", + ); return domain({ projectDir, set: { ...(arg ? { site: arg } : {}), ...(values.studio ? { studio: values.studio } : {}), + ...(redirects.length > 0 ? { redirects } : {}), }, }); } if (action === "remove") { const which = arg ?? "all"; - if (!["site", "studio", "all"].includes(which)) - throw new Error("usage: cloudhal domain remove [site|studio|all]"); + if (!["site", "studio", "redirects", "all"].includes(which)) + throw new Error("usage: cloudhal domain remove [site|studio|redirects|all]"); return domain({ projectDir, - remove: which === "all" ? ["site", "studio"] : [which as DomainTarget], + remove: which === "all" ? ["site", "studio", "redirects"] : [which as DomainTarget], }); } if (action === undefined) return domain({ projectDir }); @@ -87,7 +91,7 @@ const domainCommand: CliCommand = async (rest, values, { projectDir }) => { const result = await changeDomain(rest, values, projectDir); console.log( result - ? `site: ${result.site ? `https://${result.site}` : "(workers.dev)"}\nstudio: ${result.studio ? `https://${result.studio}` : "(workers.dev)"}` + ? `site: ${result.site ? `https://${result.site}` : "(workers.dev)"}\nstudio: ${result.studio ? `https://${result.studio}` : "(workers.dev)"}${result.redirects ? `\n301: ${result.redirects.join(", ")} → https://${result.site}` : ""}` : "no custom domains; the Workers answer on workers.dev", ); if (rest[0]) diff --git a/packages/cli/src/cli/help.ts b/packages/cli/src/cli/help.ts index 026087b8..1f6f77c3 100644 --- a/packages/cli/src/cli/help.ts +++ b/packages/cli/src/cli/help.ts @@ -21,7 +21,7 @@ Usage: open releases and time gates; from the monorepo a stale apps/studio/dist is rebuilt first (--no-build-studio: fail instead) - cloudhal domain [set ] [--studio ] | remove [site|studio|all] + cloudhal domain [set ] [--studio ] [--redirect ]… | remove [site|studio|redirects|all] custom domains for the next deploy cloudhal push [--replace] [--cancel-schedules] [--admin-url ] local site → deployment (refused while releases are diff --git a/packages/cli/src/cloudflare/config.ts b/packages/cli/src/cloudflare/config.ts index 218b3139..114884df 100644 --- a/packages/cli/src/cloudflare/config.ts +++ b/packages/cli/src/cloudflare/config.ts @@ -180,6 +180,8 @@ export function siteWorkerConfig( domain?: string; /** RFC-0034 §5: every other site's hostnames, one Custom Domain route each. */ domains?: readonly string[]; + /** Hosts that answer with a 301 to `domain` (#49), each routed as a Custom Domain too. */ + redirects?: readonly string[]; analytics?: AnalyticsDeployment; comments?: CommentsDeployment; /** Extra hostnames the site answers on (custom domains): analytics and comments check them. */ @@ -191,6 +193,7 @@ export function siteWorkerConfig( const a = n.analytics; const c = n.comments?.available ? n.comments : undefined; const hosts = [...new Set([...(n.siteHosts ?? []), ...(a?.siteHosts ?? [])])]; + const redirects = n.domain ? (n.redirects ?? []) : []; const ratelimits = [ // Optional cross-isolate beacon limit (RFC-0006 §5.5): one key per client (an HMAC, never the IP). ...(a?.rateLimit @@ -237,8 +240,11 @@ export function siteWorkerConfig( : {}), // RFC-0006 §5.2 / RFC-0007 §6.2 step 2: the same-origin checks accept these hosts too. ...((a || c) && hosts.length > 0 ? { CLOUDHAL_SITE_HOSTS: hosts.join(",") } : {}), + ...(n.domain && redirects.length > 0 + ? { CLOUDHAL_REDIRECT_HOSTS: redirects.join(","), CLOUDHAL_REDIRECT_TO: n.domain } + : {}), }, - ...customDomain(n.domain, n.domains), + ...customDomain(n.domain, [...(n.domains ?? []), ...redirects]), // Workers Cache: published pages are cached until a publish purges `cloudhal-site-`. cache: { enabled: true }, observability: observabilitySettings(n.observability), diff --git a/packages/cli/src/commands/deploy/prepare.ts b/packages/cli/src/commands/deploy/prepare.ts index 0747bd13..803a53f2 100644 --- a/packages/cli/src/commands/deploy/prepare.ts +++ b/packages/cli/src/commands/deploy/prepare.ts @@ -292,6 +292,7 @@ export async function buildSite( ...(served.siteIds ? { siteIds: served.siteIds } : {}), ...(served.hosts.length > 0 ? { domains: served.hosts } : {}), ...(cfg.domains?.site ? { domain: cfg.domains.site, siteHosts: [cfg.domains.site] } : {}), + ...(cfg.domains?.redirects ? { redirects: cfg.domains.redirects } : {}), ...(input.analytics ? { analytics: input.analytics } : {}), comments: input.comments, observability: input.observability, diff --git a/packages/cli/src/commands/domain.ts b/packages/cli/src/commands/domain.ts index 10ca6701..1f0ba35d 100644 --- a/packages/cli/src/commands/domain.ts +++ b/packages/cli/src/commands/domain.ts @@ -11,12 +11,19 @@ import { isHostname, normalizeHostname, withPrimaryDomain } from "@cloudhal/core import { openLocalStore } from "@cloudhal/core-ts/local"; import { type ProjectConfig, readProjectConfig, writeProjectConfig } from "../config.ts"; -export type DomainTarget = "site" | "studio"; +export type DomainTarget = "site" | "studio" | "redirects"; export interface DomainOptions { readonly projectDir: string; - /** Hostnames to set (others stay as they are). */ - readonly set?: { readonly site?: string; readonly studio?: string }; + /** + * Hostnames to set (others stay as they are). `redirects` replaces the list of hosts that + * answer with a 301 to the site's domain (#49). + */ + readonly set?: { + readonly site?: string; + readonly studio?: string; + readonly redirects?: readonly string[]; + }; /** Domains to forget. */ readonly remove?: readonly DomainTarget[]; } @@ -32,21 +39,37 @@ export function checkHostname(input: string): string { export async function domain(options: DomainOptions): Promise { const cfg = await readProjectConfig(options.projectDir); - const next: { site?: string; studio?: string } = { ...cfg.domains }; + const next: { site?: string; studio?: string; redirects?: readonly string[] } = { + ...cfg.domains, + }; for (const target of options.remove ?? []) delete next[target]; + // redirects go to the site's domain: without it they have nowhere to go + if (options.remove?.includes("site")) delete next.redirects; if (options.set?.site) next.site = checkHostname(options.set.site); if (options.set?.studio) next.studio = checkHostname(options.set.studio); + if (options.set?.redirects) + next.redirects = [...new Set(options.set.redirects.map(checkHostname))]; if (next.site && next.site === next.studio) throw new Error("the site and Studio need different hostnames"); + checkRedirects(next); + if (!next.redirects?.length) delete next.redirects; + const any = next.site || next.studio || next.redirects; if (options.set || options.remove) { const { domains: _old, ...rest } = cfg; - await writeProjectConfig(options.projectDir, { - ...rest, - ...(next.site || next.studio ? { domains: next } : {}), - }); + await writeProjectConfig(options.projectDir, { ...rest, ...(any ? { domains: next } : {}) }); } if (next.site && options.set?.site) await makePrimary(options.projectDir, cfg, next.site); - return next.site || next.studio ? next : undefined; + return any ? next : undefined; +} + +/** Redirect hosts need a site domain to go to, and are neither it nor Studio's. */ +function checkRedirects(d: { site?: string; studio?: string; redirects?: readonly string[] }) { + if (!d.redirects?.length) return; + if (!d.site) + throw new Error("redirects need the site's own domain: cloudhal domain set "); + for (const host of d.redirects) + if (host === d.site || host === d.studio) + throw new Error(`${host} is already the site's or Studio's domain; it cannot redirect`); } /** `hostname` becomes the local site's primary domain (other domains stay, as secondary). */ diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 3b8a829a..eee11815 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -117,9 +117,14 @@ export interface ProjectConfig { readonly dataDir: string; /** * Custom domains `cloudhal deploy` routes to the Workers (Cloudflare Custom Domains; the zone - * must be in the same account). The workers.dev addresses keep working. + * must be in the same account). The workers.dev addresses keep working. `redirects` answer with + * a 301 to `site` (#49), e.g. `www.example.com`. */ - readonly domains?: { readonly site?: string; readonly studio?: string }; + readonly domains?: { + readonly site?: string; + readonly studio?: string; + readonly redirects?: readonly string[]; + }; /** Site analytics (RFC-0006); absent = off. */ readonly analytics?: AnalyticsConfig; /** Reader comments (RFC-0007); absent = available, off per site. */ @@ -167,6 +172,21 @@ const OWN_KEYS = new Set([ const VITALS = [0, 1, 2, 5, 10, 20]; +/** `domains`, leniently: values of the wrong type are left out. */ +function parseDomains(input: unknown): ProjectConfig["domains"] { + if (!input || typeof input !== "object") return undefined; + const d = input as Record; + const redirects = Array.isArray(d.redirects) + ? d.redirects.filter((h): h is string => typeof h === "string" && h !== "") + : []; + const out = { + ...(typeof d.site === "string" && d.site ? { site: d.site } : {}), + ...(typeof d.studio === "string" && d.studio ? { studio: d.studio } : {}), + ...(redirects.length > 0 ? { redirects } : {}), + }; + return Object.keys(out).length > 0 ? out : undefined; +} + /** Leniently parsed, like `domains`: values of the wrong type are left out. */ export function parseAnalyticsConfig(input: unknown): AnalyticsConfig | undefined { if (!input || typeof input !== "object" || Array.isArray(input)) return undefined; @@ -297,7 +317,7 @@ export async function readProjectConfig(projectDir: string): Promise; if (typeof cfg.siteId !== "string") throw new Error(`${CONFIG_FILE}: missing siteId`); - const domains = cfg.domains && typeof cfg.domains === "object" ? cfg.domains : undefined; + const domains = parseDomains(cfg.domains); const analytics = parseAnalyticsConfig(cfg.analytics); const comments = parseCommentsConfig(cfg.comments); const schedules = parseSchedulesConfig(cfg.schedules); @@ -314,14 +334,7 @@ export async function readProjectConfig(projectDir: string): Promise { }); await before.close(); + // #49: hosts that answer with a 301 to the site's domain + await expect(domain({ projectDir, set: { redirects: ["news.example.com"] } })).rejects.toThrow( + /cannot redirect/, + ); + expect( + await domain({ + projectDir, + set: { redirects: ["WWW.news.example.com", "www.news.example.com"] }, + }), + ).toMatchObject({ redirects: ["www.news.example.com"] }); + const r = await run(); expect(r).toMatchObject({ customSiteUrl: "https://news.example.com", customStudioUrl: "https://studio.example.com", }); const siteCfg = await jsonc(path.join(projectDir, "wrangler.jsonc")); - expect(siteCfg.routes).toEqual([{ pattern: "news.example.com", custom_domain: true }]); + expect(siteCfg.routes).toEqual([ + { pattern: "news.example.com", custom_domain: true }, + { pattern: "www.news.example.com", custom_domain: true }, + ]); + expect(siteCfg.vars).toMatchObject({ + CLOUDHAL_REDIRECT_HOSTS: "www.news.example.com", + CLOUDHAL_REDIRECT_TO: "news.example.com", + }); const adminCfg = await jsonc(r.adminConfig); expect(adminCfg.routes).toEqual([{ pattern: "studio.example.com", custom_domain: true }]); // the custom domain is primary on the deployment and locally; workers.dev stays secondary