|
2 | 2 | // - /preview/<n>/... → proxied to the per-PR preview worker |
3 | 3 | // `nostream-web-pr-<n>` (its own workers.dev URL, assets-only), so a PR |
4 | 4 | // 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 |
7 | 12 | // site build), exactly what the assets-only worker did before. |
8 | 13 | // Preview workers that do not exist (or are deleted after merge) yield 404. |
9 | 14 |
|
10 | 15 | interface Env { |
11 | 16 | ASSETS: Fetcher |
| 17 | + /** The one host the site answers on, e.g. `nostream.dev`. Unset disables the host redirect. */ |
| 18 | + CANONICAL_HOST?: string |
12 | 19 | } |
13 | 20 |
|
14 | 21 | const PREVIEW_RE = /^\/preview\/(\d+)(\/|$)/ |
15 | 22 | const PR_WORKER = (n: number | string) => `https://nostream-web-pr-${n}.cameri.workers.dev` |
16 | 23 |
|
| 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 | + |
17 | 41 | export default { |
18 | 42 | async fetch(request: Request, env: Env): Promise<Response> { |
19 | 43 | 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 | + } |
20 | 51 | 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 | + } |
21 | 62 | if (m) { |
22 | 63 | const n = m[1] |
23 | 64 | const inner = new URL(request.url) |
|
0 commit comments