clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

host: a reserved name is ours too so its redirect gets a certificate, the config check reads trailing commas and pins the lease figures to the bindings, and the guide leans on celld's own pages for what will change

Dami committed 72e50c6
commit
72e50c67ba7919a705a10c3900420b30d9b0c20a
parent
ce48db48534dd2f84ed9c04edef7c8a99bca0304
author
Dami <dami.etoile@gmail.com>
5 files changed, +26 -21
Mdocs/12-develop-extend.md2--
Mdocs/16-hosting-without-cloudflare.md14++++----------
Mscripts/check-celld.mjs17++++++++++++++---
Msrc/edge.ts11++++++-----
Mtest/selfhost.test.ts3++-
M docs/12-develop-extend.md +0 -2
M docs/16-hosting-without-cloudflare.md +4 -10
@@ -27,7 +27,7 @@ What does not carry over: the console's custom domain tab says the feature is no
2727 ## What you need2828 2929 - A Linux box, x86-64 or ARM64. Two for celld's default durability posture, which acknowledges a write once a second node holds it; one node works with `CELLD_DURABILITY=bucket`, where every write waits for the bucket instead. Apple Silicon works for development.30-- An S3-compatible bucket with conditional writes. celld acquires each cell with `If-None-Match: *` and updates it with `If-Match`, and is not correct on a store without them. celld qualifies Amazon S3, Tigris, Google Cloud Storage and Azure Blob Storage for production, and says MinIO's community edition passes its storage test without being qualified. Backblaze B2, Hetzner Object Storage and DigitalOcean Spaces lack the conditional writes. `celld diagnose` tests a bucket before you trust it. The steps below use MinIO, the simplest thing to run on the same box; use `RELEASE.2025-09-07T16-13-09Z` or later, the release before it answers the conditional create wrongly.30+- An S3-compatible bucket with conditional writes. celld acquires each cell with `If-None-Match: *` and updates it with `If-Match`, and is not correct on a store without them. Which providers celld qualifies, and which lack the writes, is on its [guarantees page](https://celld.dev/docs/guarantees); `celld diagnose` tests a bucket before you trust it. The steps below use MinIO, the simplest thing to run on the same box, which passes celld's storage test without being qualified for production; use `RELEASE.2025-09-07T16-13-09Z` or later, the release before it answers the conditional create wrongly.3131 - A domain, with the apex and a wildcard pointed at your proxy.3232 - A reverse proxy for TLS: Caddy is shown, nginx works the same way.3333 - Node 22 and the repo's `node_modules`, which carry the esbuild that `celld deploy` needs.
@@ -81,7 +81,7 @@ CELLD_DURABILITY=bucket celld --listen 127.0.0.1:8080 --trust-forwarded-headers
8181 8282 `--trust-forwarded-headers` lets the proxy's `X-Forwarded-Host` and `X-Forwarded-Proto` set the request's host and scheme, which the relay prints in every URL it hands out; set it only behind your proxy. A second node makes the fleet, and the default durability posture, work: give each a private address peers can reach, `--internal-listen 10.0.0.2:8081 --advertise 10.0.0.2:8081`, on a network of your own or an overlay such as WireGuard, since peer traffic has no TLS of its own. The [celld docs](https://celld.dev/docs) cover the fleet: `celld diagnose --peer`, draining, memory limits, the health path at `/.well-known/celld/health`.8383 84-Run it under systemd or whatever keeps your daemons up. Idle relays hibernate in celld as on Cloudflare; a node keeps about 4 MB per resident cell.84+Run it under systemd or whatever keeps your daemons up. Idle relays hibernate in celld as on Cloudflare; `CELLD_MAX_RESIDENT_CELLS` caps how many a node keeps in memory at once.8585 8686 ## TLS and names: the proxy8787
@@ -135,12 +135,6 @@ That serves `http://127.0.0.1:9876` as the relay named `DEV_RELAY`, with state i
135135 CLAIM=1 RELAY_URL=ws://127.0.0.1:9876 npm run test:conformance136136 ```137137 138-## What is checked, and the promise138+## The promise139139 140-Three things keep this path working rather than merely described:141-142-- CI's `celld` job installs a pinned celld, starts `celld dev` on `wrangler.celld.jsonc` and runs the conformance suite against it on every push: the protocol, and files through the R2 binding (`test/conformance/media.test.ts`).143-- `npm run typecheck` runs `scripts/check-celld.mjs`, so a binding, migration or price changed in one config and not the other fails the build.144-- `test/selfhost.test.ts` covers what the Worker does without Cloudflare's bindings: the address header, the lease door's own count, the hostname question.145-146-Cloudflare is where bind.ws runs and what gets first attention; a celld release that breaks something here may take a little while to be caught up with, and the pinned version in CI says which one is known good. The path is not dropped quietly: removing support for any host takes a notice at the top of this page and in the README first, and the commit that removes it names the last commit that had it, so anyone on that host can stay there.140+CI runs the conformance suite against `celld dev` on every push, and `npm run typecheck` fails when the two configs drift, so this path is checked rather than merely described. Cloudflare is where bind.ws runs and what gets first attention; a celld release that breaks something here may take a little while to be caught up with, and the pinned version in CI says which one is known good. The path is not dropped quietly: removing support for any host takes a notice at the top of this page and in the README first, and the commit that removes it names the last commit that had it, so anyone on that host can stay there.
M scripts/check-celld.mjs +14 -3
@@ -5,7 +5,8 @@
55 // forgets the other fails the build instead of quietly dropping the host.66 import { readFileSync } from "node:fs";77 8-// stripComments removes // and /* */ comments outside strings.8+// stripComments removes // and /* */ comments outside strings, and the9+// trailing commas JSONC allows, so JSON.parse takes what wrangler takes.910 function stripComments(text) {1011 let out = "";1112 let inString = false;
@@ -22,10 +23,12 @@ function stripComments(text) {
2223 while (i < text.length && text[i] !== "\n") i++;2324 out += "\n";2425 } else if (c === "/" && text[i + 1] === "*") {25- i = text.indexOf("*/", i + 2) + 1;26+ const end = text.indexOf("*/", i + 2);27+ if (end === -1) throw new Error("unterminated comment");28+ i = end + 1;2629 } else out += c;2730 }28- return out;31+ return out.replace(/,(\s*[}\]])/g, "$1");2932 }3033 const load = (path) => JSON.parse(stripComments(readFileSync(new URL("../" + path, import.meta.url), "utf8")));3134
@@ -64,6 +67,14 @@ for (const k of Object.keys(cf.vars)) {
6467 for (const k of Object.keys(celld.vars)) if (!(k in cf.vars) && !CELLD_ONLY.has(k)) problems.push(`vars.${k} is in wrangler.celld.jsonc but not in wrangler.jsonc`);6568 for (const k of CELLD_ONLY) if (!(k in celld.vars)) problems.push(`vars.${k} is missing from wrangler.celld.jsonc`);6669 70+// The lease door's in-memory limits (src/edge.ts) restate the rate limit71+// bindings, which celld does not have; the figures must agree.72+const edge = readFileSync(new URL("../src/edge.ts", import.meta.url), "utf8");73+const constant = (name) => Number(edge.match(new RegExp(`export const ${name} = (\\d+);`))?.[1]);74+const binding = (name) => cf.ratelimits.find((r) => r.name === name)?.simple.limit;75+same("leases per address per minute", binding("LEASE_LIMIT_IP"), constant("LEASES_PER_IP_MINUTE"));76+same("leases per minute", binding("LEASE_LIMIT_ALL"), constant("LEASES_PER_MINUTE"));77+6778 if (problems.length) {6879 console.error("wrangler.celld.jsonc is out of step with wrangler.jsonc:\n " + problems.join("\n "));6980 process.exit(1);
M src/edge.ts +6 -5
@@ -5,7 +5,7 @@
55 // docs/16-hosting-without-cloudflare.md.66 import type { Env } from "./relay.ts";77 import { Bucket } from "./ratelimit.ts";8-import { validName } from "./names.ts";8+import { RESERVED, validName } from "./names.ts";99 1010 // Custom domains map to relay names through KV. Looked up once a minute per1111 // hostname per isolate; a miss is cached too, so an unknown host does not
@@ -68,16 +68,17 @@ export async function leaseAllowed(env: Pick<Env, "LEASE_LIMIT_IP" | "LEASE_LIMI
6868 }6969 7070 // hostnameKnown says whether a hostname is one of ours: the apex, a valid71-// name under it, or a custom hostname in the map. A proxy that issues72-// certificates on demand asks this before it asks a certificate authority,73-// so a stranger cannot make it request certificates for junk names.71+// or reserved name under it (a reserved one redirects to the apex, which72+// needs a certificate too), or a custom hostname in the map. A proxy that73+// issues certificates on demand asks this before it asks a certificate74+// authority, so a stranger cannot make it request certificates for junk.7475 export async function hostnameKnown(env: Pick<Env, "DOMAIN" | "HOSTS">, raw: string): Promise<boolean> {7576 const host = raw.toLowerCase().replace(/\.$/, "");7677 const domain = env.DOMAIN.toLowerCase();7778 if (host === domain || host === "www." + domain) return true;7879 if (host.endsWith("." + domain)) {7980 const name = host.slice(0, -(domain.length + 1));80- return validName(name) && !name.includes(".");81+ return validName(name) || RESERVED.has(name);8182 }8283 return host !== "" && (await customHost(env, host)) !== null;8384 }
M test/selfhost.test.ts +2 -1