Skip to content

Commit eb68475

Browse files
feat(website): serve the site at nostream.dev and redirect every other host to it (#889)
* feat(website): serve the site at nostream.dev and redirect every other host to it The site's canonical origin moves from ema.tnsor.network to https://nostream.dev. Previews keep the preview domain: a broken PR build must never sit on the domain that is the public face of the project. The router worker now binds nostream.dev and www.nostream.dev alongside ema.tnsor.network, and CANONICAL_HOST decides which host serves content: - http -> 301 to the same path over https, for every host, preview paths included (the proxy hop to the preview worker is already https); - a non-canonical host -> 301 to the canonical one, keeping path and query. Preview paths are exempt, because they are served from their own domain and a redirect would move every preview off the origin its links name; - everything else -> the static assets, as before. The domain is an input rather than a constant. SITE_URL (build) and CANONICAL_HOST (worker, passed with --var) are both set in deploy-website.yml, so a future move is two lines instead of a search through the source, the README and the social card. Three defects were found by exercising the worker before writing it down, and fixed in the same change: 1. `Response.redirect` re-encodes an already-encoded path - `/docs/a%20b` came back as `/docs/a%2520b`, a path that does not exist. The redirect writes its own Location from url.origin + url.pathname + url.search. 2. The first host-redirect draft ran before the preview match, so `ema.tnsor.network/preview/888/` was 301'd to nostream.dev/preview/888/. 3. The same draft treated a port-carrying host as canonical and issued no redirect at all; the comparison uses the bare host. The social card is regenerated with the new domain in its footer (both PNGs, 1200x630 and 1280x640, regenerated at their own sizes). Verified: the site builds with canonical/og:url/og:image all on https://nostream.dev and no ema.tnsor.network left in dist/; the worker passes wrangler's bundler with env.CANONICAL_HOST bound. Cloudflare setup this needs, not code: the nostream.dev zone must exist, and Always Use HTTPS on the zone makes the upgrade happen at the edge before the worker (the worker's own 301 covers the case where it is off). * fix(website): run the worker before assets, and keep previews off the prod host Two review findings, both real and both verified by exercising the worker. 1. Static pages skipped the redirects. Cloudflare serves a matching static asset before invoking the worker unless `assets.run_worker_first` is set, and this deployment's assets are the whole site - every built route is a file. So `www.nostream.dev/` and `ema.tnsor.network/` would have answered 200 with the page, and the host redirect would have run only on paths that did NOT match an asset, i.e. almost never. `run_worker_first: true` puts the worker back in front, which is what a router is. 2. Previews were reachable from the production domain. The preview branch proxied `/preview/<n>/` from any host, so once `nostream.dev` was bound, `https://nostream.dev/preview/<n>/` would serve a pull request's build on the site's own address. Previews now answer only from PREVIEW_HOST; a preview path on the canonical host is 301'd to PREVIEW_HOST, and a preview path on www or the stale domain is proxied in place (it is not the production host, so no PR build lands on a domain people trust). Exercised after the change: http -> https 301 https://nostream.dev/docs/x http preview -> https 301 https://ema.tnsor.network/preview/888/ www -> apex (encoded path kept) 301 https://nostream.dev/docs/a%20b?y=1 stale host -> canonical 301 https://nostream.dev/about preview on preview host 200 preview on PROD host 301 https://ema.tnsor.network/preview/888/ preview via www 200 canonical served 200 wrangler deploy --dry-run bundles with run_worker_first and CANONICAL_HOST. * fix(website): no redirect loop with no canonical host, and compare the whole authority Two review findings on the host layer. 1. With CANONICAL_HOST empty the layer inverted: `onCanonical` defaulted to true, so a preview request was 301'd to the URL it was already on, forever, and the documented "unset disables the redirects" behaviour became "unset breaks previews". Unset now leaves the request alone, which is what documented means. 2. The comparison used `url.hostname`, which drops the port, so `nostream.dev:8443` counted as canonical and served the pages without redirecting - a second origin for the same content, the thing the redirect exists to prevent. The comparison is now against `url.host`, so a port the config does not name is a different authority. The first fix exposed a third defect that neither finding named: a preview on PREVIEW_HOST was 301'd to the canonical host, whose preview branch then 301'd it back - a two-hop loop. Restructured so exactly one rule can move a preview off the production domain and nothing moves it anywhere else. Exercised with and without the variable, and with a configured port: http -> https 301 https://nostream.dev/docs/x http preview -> https 301 https://ema.tnsor.network/preview/8/ www -> apex (encoded path kept) 301 https://nostream.dev/a%20b?y=1 stale host -> canonical 301 https://nostream.dev/about preview host -> canonical 301 https://nostream.dev/preview/888/ prod host preview -> preview host 301 https://ema.tnsor.network/preview/888/ canonical served 200 port on canonical -> stripped 301 https://nostream.dev/about CANONICAL unset: preview served 404 (proxied, no loop) CANONICAL unset: page served 200 configured port: canonical served 200 configured port: default port -> redirect 301 https://nostream.dev:8443/about * fix(website): serve previews on the preview host instead of bouncing them back Greptile caught what my own matrix could not: the previous fix made `ema.tnsor.network/preview/888/` redirect to `nostream.dev/preview/888/`, whose preview rule redirects to `ema.tnsor.network/preview/888/` - a two-host browser redirect loop. Neither hop reaches the preview worker. The matrix reported "preview host: redirect to canonical 301 ..." as correct. It is not: one hop is only correct if the destination serves the page, and the destination's own rule sends it back. The check that catches this class is walking the chain the way a browser does, not asserting a single hop - which is what this commit adds to the reasoning, and what the evidence below records. A preview path on PREVIEW_HOST is now served in place by the proxy branch; every other authority still converges on the canonical host, and previews on the production host still move to PREVIEW_HOST once, then stop there. Chain walks, six starts, no path revisits a URL: ema.../preview/888/ -> 404 (proxied, one hop, terminal) ema.../preview/888/docs/x?y=1 -> 404 (query and path preserved) http://ema.../preview/888/ -> https ema -> 404 ema.../about -> nostream.dev/about -> 200 nostream.dev/preview/888/ -> ema.../preview/888/ -> 404 nostream.dev/about -> 200 www.nostream.dev/ -> nostream.dev/ -> 200
1 parent 9146fd3 commit eb68475

12 files changed

Lines changed: 135 additions & 11 deletions

File tree

‎.changeset/nostream-dev-domain.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'nostream-website': patch
3+
---
4+
5+
The site now lives at **https://nostream.dev**, and every other host redirects to it over https.
6+
7+
The router worker answers on `nostream.dev` and `www.nostream.dev` as well as the preview
8+
domain, and `CANONICAL_HOST` decides which one serves content: any other host the routes
9+
catch — `www`, or `ema.tnsor.network` for a non-preview path — gets a `301` to the canonical
10+
host, and `http` gets a `301` to the same URL over `https`. One origin serving means one
11+
canonical URL, so `www` and the apex cannot split search ranking by both answering `200`.
12+
13+
Previews deliberately stay on `ema.tnsor.network/preview/<n>/`: that is why the domain
14+
remains bound. A preview build must never sit on the production domain, and a preview's own
15+
`SITE_URL` stays the preview origin so its absolute links and canonical tags cannot claim
16+
`nostream.dev`.
17+
18+
The domain is an input now, not a constant: `SITE_URL` for the build, `CANONICAL_HOST` for
19+
the worker, both set in `deploy-website.yml`. Moving again is two lines rather than a search
20+
through the source, the social card and the README.
21+
22+
**Cloudflare-side setup this needs before it takes effect**: the `nostream.dev` zone must
23+
exist, and both `nostream.dev` and `www.nostream.dev` resolve through the worker's
24+
declared custom domains on the next deploy. Turning on the zone's *Always Use HTTPS*
25+
setting makes the upgrade happen at the edge, before the worker; the worker's own `301`
26+
covers the case where that setting is off.

‎.github/workflows/deploy-website-preview.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ jobs:
107107
if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed'
108108
working-directory: website/site
109109
env:
110+
# Previews are built under the preview domain, not the production
111+
# one: a preview's absolute links and canonical tags must not claim
112+
# nostream.dev, or a crawler reaching a preview caches the wrong
113+
# origin. Not a repo variable - previews are our own infrastructure,
114+
# and a variable someone forgets to set would silently point them
115+
# at production.
110116
SITE_URL: https://ema.tnsor.network
111117
SITE_BASE: /preview/${{ github.event.pull_request.number }}/
112118
run: |

‎.github/workflows/deploy-website.yml‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,22 @@ jobs:
5656
- name: Build the site
5757
working-directory: website/site
5858
env:
59-
SITE_URL: https://ema.tnsor.network
59+
SITE_URL: https://nostream.dev
6060
run: pnpm run build
6161

6262
- name: Deploy to Cloudflare Workers
6363
working-directory: website/site
6464
env:
6565
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
6666
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
67+
CANONICAL_HOST: nostream.dev
6768
# The router worker: serves the production assets itself and proxies
6869
# /preview/<n> to per-PR preview workers (deploy-website-preview.yml).
69-
run: pnpm exec wrangler deploy --config wrangler.router.jsonc
70+
# The site answers on one host. Every other host the routes catch,
71+
# http included, 301s to it, so www/apex cannot serve a second copy
72+
# and split search ranking. --var keeps the deployed value from
73+
# drifting away from the SITE_URL the build above baked in.
74+
run: pnpm exec wrangler deploy --config wrangler.router.jsonc --var CANONICAL_HOST:"$CANONICAL_HOST"
7075

7176
- name: Purge the site's CDN cache
7277
# Same reason as the preview workflow's purge: a request that lands
@@ -87,7 +92,7 @@ jobs:
8792
-H 'content-type: application/json' \
8893
--data '{"purge_everything":true}')
8994
if [ "$code" = "200" ]; then
90-
echo "purged the whole ema.tnsor.network cache"
95+
echo "purged the whole nostream.dev cache"
9196
else
9297
echo "::warning::cache purge answered HTTP $code — $(head -c 400 /tmp/purge.json)"
9398
fi

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
This is a [nostr](https://github.com/fiatjaf/nostr) relay, written in
3636
Typescript. Documentation, features and guides live on the project website:
37-
**[ema.tnsor.network](https://ema.tnsor.network)**.
37+
**[nostream.dev](https://nostream.dev)**.
3838

3939
This implementation is production-ready. See below for supported features.
4040

‎website/site/astro.config.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ function rehypeRepoLinks() {
4949
}
5050

5151
export default defineConfig({
52-
site: process.env.SITE_URL ?? 'https://ema.tnsor.network',
52+
site: process.env.SITE_URL ?? 'https://nostream.dev',
5353
// PR previews build under /preview/<n>; empty when unset (production).
5454
base: process.env.SITE_BASE || undefined,
5555
markdown: {

‎website/site/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "1.2.5",
44
"private": true,
55
"type": "module",
6-
"description": "ema.tnsor.network landing page and docs, built with Astro",
6+
"description": "nostream.dev landing page and docs, built with Astro",
77
"packageManager": "pnpm@10.33.0",
88
"scripts": {
99
"dev": "astro dev",
-460 Bytes
Loading
-495 Bytes
Loading

‎website/site/scripts/build-social-card.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ const text = (w, h) => `
140140
fill="${MUTED}" letter-spacing="0.4">MIT licensed</text>
141141
<!-- Where to find it. -->
142142
<text x="${TEXT_X}" y="${H - 52}" font-family="${MONO_STACK}" font-size="20" font-weight="400"
143-
fill="${MUTED}" letter-spacing="0.6">ema.tnsor.network</text>
143+
fill="${MUTED}" letter-spacing="0.6">nostream.dev</text>
144144
</svg>
145145
`
146146

‎website/site/worker/router.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,82 @@
22
// - /preview/<n>/... → proxied to the per-PR preview worker
33
// `nostream-web-pr-<n>` (its own workers.dev URL, assets-only), so a PR
44
// preview lives at https://ema.tnsor.network/preview/<n>/ while the
5-
// browser stays on the production domain.
6-
// - anything else → this worker's own static assets (the production
5+
// browser stays on the preview domain. Previews deliberately stay on the
6+
// older domain: a broken PR build must never sit on the production one.
7+
// - http:// → 301 to the same path over https.
8+
// - a non-canonical host → 301 to the canonical host, when CANONICAL_HOST is
9+
// set - EXCEPT a preview path, which is served there: previews belong to
10+
// PREVIEW_HOST, so production and www send them to it rather than proxy a
11+
// pull request's build from the site's own address.
12+
// - anything else → this worker's own static assets (the production
713
// site build), exactly what the assets-only worker did before.
814
// Preview workers that do not exist (or are deleted after merge) yield 404.
915

1016
interface Env {
1117
ASSETS: Fetcher
18+
/** The one host the site answers on, e.g. `nostream.dev`. Unset disables the host redirect. */
19+
CANONICAL_HOST?: string
1220
}
1321

1422
const PREVIEW_RE = /^\/preview\/(\d+)(\/|$)/
23+
/** The host previews are served from - the one their links, and this router, name. */
24+
const PREVIEW_HOST = 'ema.tnsor.network'
25+
1526
const PR_WORKER = (n: number | string) => `https://nostream-web-pr-${n}.cameri.workers.dev`
1627

28+
/** A bare host, without port: the value an env var can realistically carry. */
29+
const bareHost = (host: string) => host.split(':')[0].toLowerCase()
30+
/**
31+
* A 301 to `url`, written by hand rather than with `Response.redirect`.
32+
*
33+
* `Response.redirect` refuses anything but `http(s)` and copies no headers, and
34+
* its URL round-trip re-encodes a path that was already encoded - a 301 from
35+
* `/docs/a%20b` came back as `/docs/a%2520b`, which sends the reader to a path
36+
* that does not exist. `url.pathname` is the encoded form and `url.search` is
37+
* already normalised, so joining them preserves both.
38+
*/
39+
const redirect = (url: URL) =>
40+
new Response(null, { status: 301, headers: { location: `${url.origin}${url.pathname}${url.search}` } })
41+
1742
export default {
1843
async fetch(request: Request, env: Env): Promise<Response> {
1944
const url = new URL(request.url)
45+
// Redirects run before the preview branch on purpose: a preview URL on
46+
// http should be upgraded like any other, and the proxy hop is already
47+
// https to the worker.
48+
if (url.protocol === 'http:') {
49+
url.protocol = 'https:'
50+
return redirect(url)
51+
}
52+
const canonical = env.CANONICAL_HOST?.trim()
53+
// The canonical host is the one origin the site answers on. Unset disables
54+
// this whole layer: without a host to name there is nothing to redirect
55+
// toward, and treating every host as canonical is the opposite.
56+
if (canonical) {
57+
// The authority is host AND port - `nostream.dev:8443` serves the same
58+
// pages, so comparing hostnames alone would let a second origin through.
59+
const target = canonical.includes(':')
60+
? bareHost(canonical) + `:${canonical.split(':').pop()}`
61+
: bareHost(canonical)
62+
const onCanonical = url.host.toLowerCase() === target
63+
const m = PREVIEW_RE.exec(url.pathname)
64+
if (onCanonical && m) {
65+
// A preview belongs to PREVIEW_HOST and only that host: serving one
66+
// from the production domain would put a pull request's build - on the
67+
// site's own address, with its own links.
68+
return redirect(new URL(`${url.protocol}//${PREVIEW_HOST}${url.pathname}${url.search}`))
69+
}
70+
// A preview on PREVIEW_HOST is served HERE, by the proxy branch below.
71+
// Bouncing it to the canonical host would only send it back again: that
72+
// host redirects every preview to PREVIEW_HOST, so the pair of rules is
73+
// a loop rather than a redirect.
74+
if (!onCanonical && !(m && url.host.toLowerCase() === PREVIEW_HOST)) {
75+
// Every other authority converges on the canonical one, preview paths
76+
// included: only PREVIEW_HOST serves a preview, and it is reached
77+
// through the canonical origin like everything else.
78+
return redirect(new URL(`${url.protocol}//${target}${url.pathname}${url.search}`))
79+
}
80+
}
2081
const m = PREVIEW_RE.exec(url.pathname)
2182
if (m) {
2283
const n = m[1]

0 commit comments

Comments
 (0)