diff --git a/.changeset/nostream-dev-domain.md b/.changeset/nostream-dev-domain.md new file mode 100644 index 00000000..b33922c3 --- /dev/null +++ b/.changeset/nostream-dev-domain.md @@ -0,0 +1,26 @@ +--- +'nostream-website': patch +--- + +The site now lives at **https://nostream.dev**, and every other host redirects to it over https. + +The router worker answers on `nostream.dev` and `www.nostream.dev` as well as the preview +domain, and `CANONICAL_HOST` decides which one serves content: any other host the routes +catch — `www`, or `ema.tnsor.network` for a non-preview path — gets a `301` to the canonical +host, and `http` gets a `301` to the same URL over `https`. One origin serving means one +canonical URL, so `www` and the apex cannot split search ranking by both answering `200`. + +Previews deliberately stay on `ema.tnsor.network/preview//`: that is why the domain +remains bound. A preview build must never sit on the production domain, and a preview's own +`SITE_URL` stays the preview origin so its absolute links and canonical tags cannot claim +`nostream.dev`. + +The domain is an input now, not a constant: `SITE_URL` for the build, `CANONICAL_HOST` for +the worker, both set in `deploy-website.yml`. Moving again is two lines rather than a search +through the source, the social card and the README. + +**Cloudflare-side setup this needs before it takes effect**: the `nostream.dev` zone must +exist, and both `nostream.dev` and `www.nostream.dev` resolve through the worker's +declared custom domains on the next deploy. Turning on the zone's *Always Use HTTPS* +setting makes the upgrade happen at the edge, before the worker; the worker's own `301` +covers the case where that setting is off. diff --git a/.github/workflows/deploy-website-preview.yml b/.github/workflows/deploy-website-preview.yml index 9771288d..50c4b097 100644 --- a/.github/workflows/deploy-website-preview.yml +++ b/.github/workflows/deploy-website-preview.yml @@ -107,6 +107,12 @@ jobs: if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' working-directory: website/site env: + # Previews are built under the preview domain, not the production + # one: a preview's absolute links and canonical tags must not claim + # nostream.dev, or a crawler reaching a preview caches the wrong + # origin. Not a repo variable - previews are our own infrastructure, + # and a variable someone forgets to set would silently point them + # at production. SITE_URL: https://ema.tnsor.network SITE_BASE: /preview/${{ github.event.pull_request.number }}/ run: | diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 80261892..573d1227 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -56,7 +56,7 @@ jobs: - name: Build the site working-directory: website/site env: - SITE_URL: https://ema.tnsor.network + SITE_URL: https://nostream.dev run: pnpm run build - name: Deploy to Cloudflare Workers @@ -64,9 +64,14 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CANONICAL_HOST: nostream.dev # The router worker: serves the production assets itself and proxies # /preview/ to per-PR preview workers (deploy-website-preview.yml). - run: pnpm exec wrangler deploy --config wrangler.router.jsonc + # The site answers on one host. Every other host the routes catch, + # http included, 301s to it, so www/apex cannot serve a second copy + # and split search ranking. --var keeps the deployed value from + # drifting away from the SITE_URL the build above baked in. + run: pnpm exec wrangler deploy --config wrangler.router.jsonc --var CANONICAL_HOST:"$CANONICAL_HOST" - name: Purge the site's CDN cache # Same reason as the preview workflow's purge: a request that lands @@ -87,7 +92,7 @@ jobs: -H 'content-type: application/json' \ --data '{"purge_everything":true}') if [ "$code" = "200" ]; then - echo "purged the whole ema.tnsor.network cache" + echo "purged the whole nostream.dev cache" else echo "::warning::cache purge answered HTTP $code — $(head -c 400 /tmp/purge.json)" fi diff --git a/README.md b/README.md index 7bd8d3f1..51afd067 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ This is a [nostr](https://github.com/fiatjaf/nostr) relay, written in Typescript. Documentation, features and guides live on the project website: -**[ema.tnsor.network](https://ema.tnsor.network)**. +**[nostream.dev](https://nostream.dev)**. This implementation is production-ready. See below for supported features. diff --git a/website/site/astro.config.mjs b/website/site/astro.config.mjs index 32b11d57..36d2b48e 100644 --- a/website/site/astro.config.mjs +++ b/website/site/astro.config.mjs @@ -49,7 +49,7 @@ function rehypeRepoLinks() { } export default defineConfig({ - site: process.env.SITE_URL ?? 'https://ema.tnsor.network', + site: process.env.SITE_URL ?? 'https://nostream.dev', // PR previews build under /preview/; empty when unset (production). base: process.env.SITE_BASE || undefined, markdown: { diff --git a/website/site/package.json b/website/site/package.json index 8257aea2..fd49c824 100644 --- a/website/site/package.json +++ b/website/site/package.json @@ -3,7 +3,7 @@ "version": "1.2.5", "private": true, "type": "module", - "description": "ema.tnsor.network landing page and docs, built with Astro", + "description": "nostream.dev landing page and docs, built with Astro", "packageManager": "pnpm@10.33.0", "scripts": { "dev": "astro dev", diff --git a/website/site/public/media/github-social-preview.png b/website/site/public/media/github-social-preview.png index 95264b11..ca3d1d9e 100644 Binary files a/website/site/public/media/github-social-preview.png and b/website/site/public/media/github-social-preview.png differ diff --git a/website/site/public/media/social-card.png b/website/site/public/media/social-card.png index e5bb8a58..7d540733 100644 Binary files a/website/site/public/media/social-card.png and b/website/site/public/media/social-card.png differ diff --git a/website/site/scripts/build-social-card.mjs b/website/site/scripts/build-social-card.mjs index 2bc636af..2647d72a 100644 --- a/website/site/scripts/build-social-card.mjs +++ b/website/site/scripts/build-social-card.mjs @@ -140,7 +140,7 @@ const text = (w, h) => ` fill="${MUTED}" letter-spacing="0.4">MIT licensed ema.tnsor.network + fill="${MUTED}" letter-spacing="0.6">nostream.dev ` diff --git a/website/site/worker/router.ts b/website/site/worker/router.ts index 963a4a6f..c01ec94f 100644 --- a/website/site/worker/router.ts +++ b/website/site/worker/router.ts @@ -2,21 +2,82 @@ // - /preview//... → proxied to the per-PR preview worker // `nostream-web-pr-` (its own workers.dev URL, assets-only), so a PR // preview lives at https://ema.tnsor.network/preview// while the -// browser stays on the production domain. -// - anything else → this worker's own static assets (the production +// browser stays on the preview domain. Previews deliberately stay on the +// older domain: a broken PR build must never sit on the production one. +// - http:// → 301 to the same path over https. +// - a non-canonical host → 301 to the canonical host, when CANONICAL_HOST is +// set - EXCEPT a preview path, which is served there: previews belong to +// PREVIEW_HOST, so production and www send them to it rather than proxy a +// pull request's build from the site's own address. +// - anything else → this worker's own static assets (the production // site build), exactly what the assets-only worker did before. // Preview workers that do not exist (or are deleted after merge) yield 404. interface Env { ASSETS: Fetcher + /** The one host the site answers on, e.g. `nostream.dev`. Unset disables the host redirect. */ + CANONICAL_HOST?: string } const PREVIEW_RE = /^\/preview\/(\d+)(\/|$)/ +/** The host previews are served from - the one their links, and this router, name. */ +const PREVIEW_HOST = 'ema.tnsor.network' + const PR_WORKER = (n: number | string) => `https://nostream-web-pr-${n}.cameri.workers.dev` +/** A bare host, without port: the value an env var can realistically carry. */ +const bareHost = (host: string) => host.split(':')[0].toLowerCase() +/** + * A 301 to `url`, written by hand rather than with `Response.redirect`. + * + * `Response.redirect` refuses anything but `http(s)` and copies no headers, and + * its URL round-trip re-encodes a path that was already encoded - a 301 from + * `/docs/a%20b` came back as `/docs/a%2520b`, which sends the reader to a path + * that does not exist. `url.pathname` is the encoded form and `url.search` is + * already normalised, so joining them preserves both. + */ +const redirect = (url: URL) => + new Response(null, { status: 301, headers: { location: `${url.origin}${url.pathname}${url.search}` } }) + export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url) + // Redirects run before the preview branch on purpose: a preview URL on + // http should be upgraded like any other, and the proxy hop is already + // https to the worker. + if (url.protocol === 'http:') { + url.protocol = 'https:' + return redirect(url) + } + const canonical = env.CANONICAL_HOST?.trim() + // The canonical host is the one origin the site answers on. Unset disables + // this whole layer: without a host to name there is nothing to redirect + // toward, and treating every host as canonical is the opposite. + if (canonical) { + // The authority is host AND port - `nostream.dev:8443` serves the same + // pages, so comparing hostnames alone would let a second origin through. + const target = canonical.includes(':') + ? bareHost(canonical) + `:${canonical.split(':').pop()}` + : bareHost(canonical) + const onCanonical = url.host.toLowerCase() === target + const m = PREVIEW_RE.exec(url.pathname) + if (onCanonical && m) { + // A preview belongs to PREVIEW_HOST and only that host: serving one + // from the production domain would put a pull request's build - on the + // site's own address, with its own links. + return redirect(new URL(`${url.protocol}//${PREVIEW_HOST}${url.pathname}${url.search}`)) + } + // A preview on PREVIEW_HOST is served HERE, by the proxy branch below. + // Bouncing it to the canonical host would only send it back again: that + // host redirects every preview to PREVIEW_HOST, so the pair of rules is + // a loop rather than a redirect. + if (!onCanonical && !(m && url.host.toLowerCase() === PREVIEW_HOST)) { + // Every other authority converges on the canonical one, preview paths + // included: only PREVIEW_HOST serves a preview, and it is reached + // through the canonical origin like everything else. + return redirect(new URL(`${url.protocol}//${target}${url.pathname}${url.search}`)) + } + } const m = PREVIEW_RE.exec(url.pathname) if (m) { const n = m[1] diff --git a/website/site/wrangler.jsonc b/website/site/wrangler.jsonc index cccfb7a1..d3990a54 100644 --- a/website/site/wrangler.jsonc +++ b/website/site/wrangler.jsonc @@ -4,7 +4,7 @@ "compatibility_date": "2026-10-01", "routes": [ { - "pattern": "ema.tnsor.network", + "pattern": "nostream.dev", "custom_domain": true } ], diff --git a/website/site/wrangler.router.jsonc b/website/site/wrangler.router.jsonc index 077e8e3a..800a36dc 100644 --- a/website/site/wrangler.router.jsonc +++ b/website/site/wrangler.router.jsonc @@ -3,7 +3,27 @@ "name": "nostream-website", "main": "worker/router.ts", "compatibility_date": "2026-10-01", + "vars": { + // The one host the site answers on. The worker 301s any other host the + // route catches, so www/apex and a stale domain converge on one origin + // instead of serving the same HTML twice and splitting search ranking. + // Set by the deploy workflow from the site domain; empty disables it. + "CANONICAL_HOST": "nostream.dev" + }, "routes": [ + // The production site's own domains. The Worker answers http on them only + // long enough to 301 to https; Cloudflare's "Always Use HTTPS" is the same + // behaviour one layer earlier, and neither depends on the other. + { + "pattern": "nostream.dev", + "custom_domain": true + }, + { + "pattern": "www.nostream.dev", + "custom_domain": true + }, + // Previews live here and front pages already point at it, so the domain + // stays bound; the redirect above sends its non-preview paths onward. { "pattern": "ema.tnsor.network", "custom_domain": true @@ -13,7 +33,13 @@ "directory": "./dist", "html_handling": "auto-trailing-slash", "not_found_handling": "single-page-application", - "binding": "ASSETS" + "binding": "ASSETS", + // Serving assets first would let a matching page (every built route is one) + // answer before this worker runs, so the http->https and non-canonical-host + // redirects below would never fire on exactly the requests that matter: + // the pages. The worker is the router for a reason - it must see every + // request, then hand it to the assets binding itself. + "run_worker_first": true }, "observability": { "enabled": false