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
26 changes: 26 additions & 0 deletions .changeset/nostream-dev-domain.md
Original file line number Diff line number Diff line change
@@ -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/<n>/`: 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.
6 changes: 6 additions & 0 deletions .github/workflows/deploy-website-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,22 @@ 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
working-directory: website/site
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/<n> 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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion website/site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>; empty when unset (production).
base: process.env.SITE_BASE || undefined,
markdown: {
Expand Down
2 changes: 1 addition & 1 deletion website/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file modified website/site/public/media/github-social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified website/site/public/media/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion website/site/scripts/build-social-card.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ const text = (w, h) => `
fill="${MUTED}" letter-spacing="0.4">MIT licensed</text>
<!-- Where to find it. -->
<text x="${TEXT_X}" y="${H - 52}" font-family="${MONO_STACK}" font-size="20" font-weight="400"
fill="${MUTED}" letter-spacing="0.6">ema.tnsor.network</text>
fill="${MUTED}" letter-spacing="0.6">nostream.dev</text>
</svg>
`

Expand Down
65 changes: 63 additions & 2 deletions website/site/worker/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,82 @@
// - /preview/<n>/... → proxied to the per-PR preview worker
// `nostream-web-pr-<n>` (its own workers.dev URL, assets-only), so a PR
// preview lives at https://ema.tnsor.network/preview/<n>/ 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<Response> {
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]
Expand Down
2 changes: 1 addition & 1 deletion website/site/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"compatibility_date": "2026-10-01",
"routes": [
{
"pattern": "ema.tnsor.network",
"pattern": "nostream.dev",
"custom_domain": true
}
],
Expand Down
28 changes: 27 additions & 1 deletion website/site/wrangler.router.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
chappie-daemon marked this conversation as resolved.
},
// 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
Expand All @@ -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
Expand Down
Loading