Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/domain-redirects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/cli": patch
"@cloudhal/astro-renderer": patch
---

`cloudhal domain set <host> --redirect www.<host>`: 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.
5 changes: 4 additions & 1 deletion packages/astro-renderer/src/cloudflare-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions packages/astro-renderer/src/host-redirect.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
29 changes: 29 additions & 0 deletions packages/astro-renderer/test/host-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[email protected]", "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.

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
16 changes: 10 additions & 6 deletions packages/cli/src/cli/commands-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hostname> [--studio <hostname>]");
const redirects = (values.redirect ?? []).flatMap((v) => v.split(","));
if (!arg && !values.studio && redirects.length === 0)
throw new Error(
"usage: cloudhal domain set <hostname> [--studio <hostname>] [--redirect <hostname>]…",
);
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 });
Expand All @@ -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])
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hostname>] [--studio <hostname>] | remove [site|studio|all]
cloudhal domain [set <hostname>] [--studio <hostname>] [--redirect <hostname>]… | remove [site|studio|redirects|all]
custom domains for the next deploy
cloudhal push [--replace] [--cancel-schedules] [--admin-url <url>]
local site → deployment (refused while releases are
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/cloudflare/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand Down Expand Up @@ -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-<id>`.
cache: { enabled: true },
observability: observabilitySettings(n.observability),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/deploy/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 32 additions & 9 deletions packages/cli/src/commands/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand All @@ -32,21 +39,37 @@ export function checkHostname(input: string): string {

export async function domain(options: DomainOptions): Promise<ProjectConfig["domains"]> {
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 <hostname>");
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). */
Expand Down
35 changes: 24 additions & 11 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, unknown>;
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;
Expand Down Expand Up @@ -297,7 +317,7 @@ export async function readProjectConfig(projectDir: string): Promise<ProjectConf
}
const cfg = JSON.parse(raw) as Partial<ProjectConfig>;
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);
Expand All @@ -314,14 +334,7 @@ export async function readProjectConfig(projectDir: string): Promise<ProjectConf
? { installation: cfg.installation }
: {}),
dataDir: cfg.dataDir ?? ".cloudhal",
...(domains && (domains.site || domains.studio)
? {
domains: {
...(typeof domains.site === "string" ? { site: domains.site } : {}),
...(typeof domains.studio === "string" ? { studio: domains.studio } : {}),
},
}
: {}),
...(domains ? { domains } : {}),
...(analytics ? { analytics } : {}),
...(comments ? { comments } : {}),
...(schedules ? { schedules } : {}),
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/test/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,13 +502,31 @@ describe("cloudhal deploy", () => {
});
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
Expand Down
Loading