Skip to content

Commit c7fba3c

Browse files
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).
1 parent e654852 commit c7fba3c

12 files changed

Lines changed: 107 additions & 9 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
@@ -77,6 +77,12 @@ jobs:
7777
if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed'
7878
working-directory: website/site
7979
env:
80+
# Previews are built under the preview domain, not the production
81+
# one: a preview's absolute links and canonical tags must not claim
82+
# nostream.dev, or a crawler reaching a preview caches the wrong
83+
# origin. Not a repo variable - previews are our own infrastructure,
84+
# and a variable someone forgets to set would silently point them
85+
# at production.
8086
SITE_URL: https://ema.tnsor.network
8187
SITE_BASE: /preview/${{ github.event.pull_request.number }}/
8288
run: |

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,19 @@ 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"

‎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: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,63 @@
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. Preview paths are exempt (above): they are served from their own
10+
// domain, and a redirect would move them off the origin their links name.
11+
// - anything else → this worker's own static assets (the production
712
// site build), exactly what the assets-only worker did before.
813
// Preview workers that do not exist (or are deleted after merge) yield 404.
914

1015
interface Env {
1116
ASSETS: Fetcher
17+
/** The one host the site answers on, e.g. `nostream.dev`. Unset disables the host redirect. */
18+
CANONICAL_HOST?: string
1219
}
1320

1421
const PREVIEW_RE = /^\/preview\/(\d+)(\/|$)/
1522
const PR_WORKER = (n: number | string) => `https://nostream-web-pr-${n}.cameri.workers.dev`
1623

24+
/** A bare host, without port: the value an env var can realistically carry. */
25+
const bareHost = (host: string) => host.split(':')[0].toLowerCase()
26+
/** The origin form the request is being redirected TO. */
27+
const originHost = (host: string) => host.split(':')[0].toLowerCase() + (host.includes(':') ? `:${host.split(':').pop()}` : '')
28+
29+
/**
30+
* A 301 to `url`, written by hand rather than with `Response.redirect`.
31+
*
32+
* `Response.redirect` refuses anything but `http(s)` and copies no headers, and
33+
* its URL round-trip re-encodes a path that was already encoded - a 301 from
34+
* `/docs/a%20b` came back as `/docs/a%2520b`, which sends the reader to a path
35+
* that does not exist. `url.pathname` is the encoded form and `url.search` is
36+
* already normalised, so joining them preserves both.
37+
*/
38+
const redirect = (url: URL) =>
39+
new Response(null, { status: 301, headers: { location: `${url.origin}${url.pathname}${url.search}` } })
40+
1741
export default {
1842
async fetch(request: Request, env: Env): Promise<Response> {
1943
const url = new URL(request.url)
44+
// Redirects run before the preview branch on purpose: a preview URL on
45+
// http should be upgraded like any other, and the proxy hop is already
46+
// https to the worker.
47+
if (url.protocol === 'http:') {
48+
url.protocol = 'https:'
49+
return redirect(url)
50+
}
2051
const m = PREVIEW_RE.exec(url.pathname)
52+
// Preview paths are matched BEFORE the canonical-host redirect: they are
53+
// served from their own domain, so sending them to the production host
54+
// would move every preview off the origin the links point at.
55+
if (!m) {
56+
const canonical = env.CANONICAL_HOST?.trim()
57+
if (canonical && bareHost(url.hostname) !== bareHost(canonical)) {
58+
url.host = originHost(canonical)
59+
return redirect(url)
60+
}
61+
}
2162
if (m) {
2263
const n = m[1]
2364
const inner = new URL(request.url)

0 commit comments

Comments
 (0)