wanix.site is a service for publishing small HTML pages, each at its own subdomain ({id}.wanix.site). Each page is intended to be self-editing: pages typically ship their own editor UI (potentially Wanix based), and the platform exposes a small JavaScript API that pages can call to save drafts locally, publish updates to the server, and fork themselves into new pages.
Going to a site the first time caches it locally and then it works offline. Like a clone/checkout of a repo. If the site allows, it can self edit by editing the cache. The edits are only local. If you are the author, you can publish (git push) them back to the object store. If you are not the author, you can fork with your edits creating a new site. If logged in, you are the owner of the new site, if not, the site has no owner until you claim logged in.
Any site can be forked by anybody. The homepage is itself a wanix site that happens to be a self editor. The experience is similar to using a pastebin or Gist. Except it produces a real website.
Important note: A wanix site does not have to actually use Wanix. This is actually a general purpose local-first SPA micro platform. However, Wanix will likely be used for the root site. In the implementation below, it does not use Wanix. In fact, it has no dependencies. And actual code is only ~600 lines of JS. And should (ballpark!) only cost ~$50/mo for a million users.
The project is open source. The hosted instance would run on wanix.site; anyone can deploy their own instance to a different domain.
The full stack is two pieces of Cloudflare infrastructure:
- One Cloudflare Worker that handles all writes, all reads, and the routing logic for both the apex domain and per-site subdomains.
- One R2 bucket that stores page contents as objects keyed by site ID, with ownership and lineage in customMetadata.
There is no database. Object storage is the source of truth; lifecycle rules handle expiration if any. KV, D1, and Durable Objects are intentionally absent — they're not needed for v1, and adding them later is straightforward when use cases emerge.
A wildcard certificate covers *.wanix.site via Cloudflare Universal SSL. DNS records: an apex AAAA record at 100:: (proxied) for wanix.site, and a * AAAA record (proxied) for the wildcard. Worker routes attach to both.
A site is an HTML document at a stable URL ({id}.wanix.site). Sites have:
- An id — a xid-format identifier (20 chars, base32hex, embeds creation timestamp). Used as both the URL subdomain and the R2 object key.
- Content — arbitrary HTML, stored as the R2 object body. Cap configurable per deploy via
MAX_SIZEenv var (default 1MB). - An owner — a username string from the auth provider, or unset for anonymous sites. Stored in R2 customMetadata (
owner). - A parent — the id of the site this one was forked from, or unset for sites created directly (only the home site has no parent). Stored in R2 customMetadata (
parent). Immutable once set.
The home site is a special case stored at the reserved R2 key _root. It serves at the apex (wanix.site) and is the default fork target when someone visits the apex with no other context. It's owned by the operator of the deployment. Functionally, it's a normal site with a reserved key — same edit, publish, and fork machinery applies.
R2 layout:
_root # home site (apex)
{xid} # individual sites
Each object's customMetadata may contain:
owner— owner username, if anyparent— id of the parent site, if anyclaim— HMAC claim token for anonymous sites awaiting ownership claim
There is no index, no list of "all sites by owner," no "all forks of X." If those views are needed later, they can be built with a sidecar D1 table populated by a background queue or by direct writes from the worker. For v1, lookups are by id only.
Lifecycle: configured via R2 dashboard rule, not in code. The default deployment ships without expiration.
The Worker has three responsibilities: serve sites, accept writes, and serve a small set of platform-reserved paths.
For host {id}.wanix.site (or wanix.site itself, which resolves to _root):
- Fetch the object from R2.
- If absent, return 404.
- Inject platform metadata into the HTML:
<meta name="site-id" content="{id}"><meta name="site-owner" content="{owner}"><meta name="site-parent" content="{parent}"><script src="/_site.js" defer></script>
- Return as
text/htmlwith cache headers (public, max-age=3600) andx-content-type-options: nosniff.
The injection is a string splice that prefers inserting after <head>, falls back to after <!doctype>, and prepends as a last resort. Owner and parent values are HTML-attribute-escaped to prevent injection via malicious metadata.
POST / on any subdomain creates a new site:
- Reject early if
Content-LengthexceedsMAX_SIZE * 2(slop for form encoding overhead). - Parse form data, reject if content is missing or exceeds
MAX_SIZE. - Call the auth hook
getUser(req, env). The hook can return a username string,nullfor anonymous, or aResponseto short-circuit (e.g. login redirect). - Generate a new xid.
- Write to R2 with appropriate customMetadata:
ownerif authenticated,parentset to the current host's site id (or_rootif writing from apex),claimtoken if anonymous. - Redirect to
https://{newid}.wanix.site/.
POST to an existing site (publish) is a separate code path that requires the requester to be the site's current owner — verified by re-running getUser and comparing against the object's owner metadata. Anonymous sites can be published-to by anyone holding the claim token (see auth section).
The platform owns several paths under /_*:
/_site.js— the platform script, served by the Worker. Same content for every site. Cached aggressively./_sw.js— the service worker registration target. Same content for every site./_parent— load the parent site's HTML, but inject the current site's metadata so that calls tosite.publish()etc. write to the current site, not the parent. This is a recovery path: if the current site's editor is broken, the parent's editor (which is typically a related self-editor, possibly with richer features) acts as a fallback./_root— same as/_parent, but loads the home site's HTML instead. Guaranteed-working escape hatch since the operator controls the home site./_claim— POST endpoint that exchanges (claim token + auth) for ownership of an anonymous site./_publish— POST endpoint for updating an existing site's content. Wraps the write path with ownership checks./_fork— POST endpoint that creates a new site with the current one as parent.
The reserved namespace is /_*. Page authors should not create paths starting with _ for their own use, since the platform may reserve more in the future.
The platform service worker (/_sw.js) is registered on every site by /_site.js. It implements two named caches per site:
published— last known server version of the site's HTML.local— user's saved-but-not-published draft.
On fetch of the site's own HTML:
- If
localhas an entry, serve it. Trigger background refresh ofpublished. - Else if
publishedhas an entry, serve it. Trigger background refresh. - Else fetch from network, populate
published, serve.
local always wins when present — the user's working draft is never silently overwritten by a server update. When the cached published updates in the background, the SW posts a message to the page ({type: 'published-updated'}) which platform JS surfaces via site.onPublishedUpdate(callback). Pages can use this to prompt the user, or ignore it.
Platform-controlled paths (/_*) are cached separately and never invalidated by save/publish on a site. They update only when the platform code itself ships a new version (cache version bump in _sw.js).
The service worker scope is /, which means page authors cannot register their own service worker. They can still use Web Workers, IndexedDB, localStorage, etc.
/_site.js exposes a global site object on every page:
| Property / method | Description |
|---|---|
site.id |
Current site id (string) |
site.owner |
Current owner username, or null |
site.parent |
Parent site id, or null |
site.content |
The HTML being edited, when this page is being used as an editor template (set on /_parent and /_root loads). On normal loads, equals the rendered page's source. |
site.save(html) |
Write to local cache. Available offline. |
site.publish(html) |
POST to /_publish, clear local cache. Requires ownership. |
site.fork(html?) |
POST to /_fork. Defaults to current content. Returns the new site's id; redirects by default. |
site.claim() |
POST to /_claim with stored claim token + auth. Requires login. Resolves to true on success. |
site.discard() |
Clear local cache, reload to revert to published. |
site.onPublishedUpdate(cb) |
Subscribe to background refresh notifications. |
Page authors choose which of these to wire up to UI. A page with no save button cannot be locally edited regardless of platform support — this is how the home site (and any other "view-only" site) opts out of self-editing without needing a platform feature.
The site global name is short and ergonomic but common; page authors are advised not to shadow it. Renaming it later would be a breaking change for hosted sites, so it's worth being deliberate about now.
Auth is delegated to a third-party service via the getUser(req, env) hook in the Worker source. The hook receives the incoming request, returns one of:
- a username string (authenticated)
null(anonymous)- a
Response(e.g. redirect to login flow)
Forks of self-deployed instances replace this function with their auth integration. The hosted wanix.site deployment will use a specific provider (TBD).
The fork endpoint does not require auth. When an unauthenticated user forks a site, the new site is created with no owner metadata and a claim token written to customMetadata. The token is an HMAC of (site_id, created_at) signed with a server secret — stateless, no DB row needed. The platform script writes the token into localStorage on the new site's subdomain.
Later, the user logs in (via whatever auth flow the hook implements) and the page calls site.claim(). The platform script POSTs the stored token plus the user's auth credential to /_claim. The Worker validates the HMAC, verifies the user is now authenticated, writes owner into customMetadata, and clears claim. The site is now owned.
Failure modes:
- User clears browser data before claiming → site remains anonymous, unclaimable. Acceptable; the alternative (server-side mapping of "anonymous sites this browser created") requires state and adds attack surface.
- User logs in on a different device → can't claim from there, since the token lives in the original browser's localStorage. They'd need to fork again.
site.publish() calls /_publish, which re-runs getUser and compares against the stored owner. Anonymous sites with a claim token can also be published-to by anyone presenting the token (this is what allows iterating on a fork before deciding to claim it).
Every site is potentially a self-editor: it ships its own UI for editing, saving, publishing, and forking, calling into site.*. The platform doesn't impose any editor; it's just an HTML document with whatever interface the author wrote.
This creates a recovery problem: if a site author publishes broken HTML or removes the editor, they lock themselves out. Two escape paths:
/_parentloads the parent's HTML, withsite.contentset to the current site's content andsite.id/site.ownerreflecting the current site. Calls tosite.publish()update the current site. This is the preferred recovery path because the parent typically has more features than home — if you forked from a markdown editor, your/_parentis a markdown editor./_rootis identical but loads the home site. Guaranteed to work since the operator controls home; functions as the universal fallback.
Both paths bypass the current page's own JS by being intercepted at the Worker level (and cached separately by the SW).
For a site to function as an editor template (i.e. be useful when loaded via someone's /_parent), its editor must read content from site.content rather than hardcoding what it edits. Sites that hardcode their content still work fine as self-editors, just not as templates for their descendants. This creates a natural quality gradient: well-written editors propagate down lineage chains; minimal pages are self-only.
site.fork() creates a new site with the current one as its parent. Anonymous if the user isn't logged in; owned if they are. The new site's content defaults to the current site's content (so forks start as exact copies and diverge from there) but can be overridden.
Lineage is a flat parent pointer per site. To walk ancestry, recursively dereference parent. The platform doesn't expose ancestry walking as an API in v1 — sites that want to display ancestry can do it themselves with fetch('https://{parent}.wanix.site/', {redirect: 'manual'}) to read parent metadata from response headers, or by parsing the parent's <meta> tags.
There is no built-in "list of forks of X" or "list of my sites." Those require an index that v1 doesn't maintain. Likely a v2 feature backed by D1.
Since pages serve arbitrary HTML/JS on subdomains, phishing and malware are real risks. Mitigations:
- Cookie isolation: each subdomain is its own cookie scope, so sites can't read each other's storage. Apex-scoped cookies on
wanix.sitemust be avoided for anything sensitive. - Cloudflare's automated phishing scanners will eventually flag bad subdomains and may flag the zone. Have a takedown path — operator-only
DELETE /_admin/{id}endpoint, or just direct R2 deletion via wrangler. - No
Content-Security-Policyis set by default (it would break the use case). Page authors can include their own.
At 1 million users with ~5M new sites/month and ~50M reads/month, expected monthly cost is ~$50: $5 Workers Paid base, ~$15 R2 ops (writes + reads after free tier), ~$0.60 R2 storage, ~$15 Workers requests/CPU beyond included. Hot pages stay free via edge caching. The architecture has no per-user variable cost, so 10× users is roughly 10× cost.
If the operator publishes broken HTML to _root, the entire fallback stack collapses. Operator should keep an out-of-band way to restore home — e.g. a known-good _root.html checked into the repo, restorable via wrangler. The home site's own editor should be conservative: a textarea, a publish button, ideally also a "preview before publish" step. Worth treating home with extra care.
The SW lives at /_sw.js and is shared across every site on the deployment. Changes to it propagate the standard SW way: clients fetch the new version on next navigation, install it, activate on reload. Bumping a CACHE_VERSION constant in the SW invalidates all platform caches at once. Site content caches (local, published) are keyed per-site and survive SW upgrades unless the upgrade explicitly clears them.
Out of scope for v1, likely valuable later:
- Indexed views — list-by-owner, list-of-forks. Requires D1 + write-path indexing.
- Custom domains —
mysite.com→ some site id. Requires Cloudflare for SaaS integration; estimated at $0.10/hostname/month after the first 100. Self-deployers can already do this trivially by deploying their own instance to their own domain. - Versioning — keep N previous versions of each site, allow rollback. Requires either multiple R2 keys per site or R2 versioning (which exists but doubles storage).
- Collaboration — multiple owners per site, comments, etc. Substantially expands scope; out of philosophical alignment with v1's simplicity.
- Diff between sites — useful for forks but requires server-side parsing.
- Apex of subdomain space (e.g.
notes.wanix.siteas a custom-friendly subdomain) — would require either reserved-keyword logic in the Worker or a separate DNS/routing layer.
/src/index.js # the Worker
/src/site.js # served at /_site.js
/src/sw.js # served at /_sw.js
/src/auth.js # the getUser hook (replaceable per deploy)
/wrangler.toml
/README.md
The auth hook is in its own file specifically so deployers replace one file rather than editing inline in index.js. The Worker imports getUser from auth.js.
- Global name.
sitevswanixvs_site. Currentlysite. Worth revisiting before launch since renaming after sites are written breaks them. - Does the home site need a "preview before publish" by default? Probably yes — the cost of breaking home is highest.
- How does the auth hook expose the login URL to the platform script? Likely an env var (
LOGIN_URL) injected into/_site.jssosite.claim()can redirect when called without a session. - Should
site.fork()automatically claim if the user is logged in, or always create anonymous + require explicit claim? Leaning toward "auto-own when logged in" for ergonomics. - Rate limiting. Not in code; configured as Cloudflare zone rules. Need to decide sensible defaults: per-IP fork limits, per-owner publish limits.