Skip to content

Instantly share code, notes, and snippets.

@progrium
Last active May 11, 2026 02:55
Show Gist options
  • Select an option

  • Save progrium/b3dd0ba2814eb2f744655dff5e0432bd to your computer and use it in GitHub Desktop.

Select an option

Save progrium/b3dd0ba2814eb2f744655dff5e0432bd to your computer and use it in GitHub Desktop.
wanix.site design document and sketch implementation from claude. origin: https://claude.ai/share/1458e67f-3d39-4fdf-b7f4-00f681e7d27b

wanix.site — Design Document

Overview

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.

Architecture

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.

Domain model

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_SIZE env 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.

Storage

R2 layout:

_root              # home site (apex)
{xid}              # individual sites

Each object's customMetadata may contain:

  • owner — owner username, if any
  • parent — id of the parent site, if any
  • claim — 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.

Worker behavior

The Worker has three responsibilities: serve sites, accept writes, and serve a small set of platform-reserved paths.

Read path

For host {id}.wanix.site (or wanix.site itself, which resolves to _root):

  1. Fetch the object from R2.
  2. If absent, return 404.
  3. 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>
  4. Return as text/html with cache headers (public, max-age=3600) and x-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.

Write path

POST / on any subdomain creates a new site:

  1. Reject early if Content-Length exceeds MAX_SIZE * 2 (slop for form encoding overhead).
  2. Parse form data, reject if content is missing or exceeds MAX_SIZE.
  3. Call the auth hook getUser(req, env). The hook can return a username string, null for anonymous, or a Response to short-circuit (e.g. login redirect).
  4. Generate a new xid.
  5. Write to R2 with appropriate customMetadata: owner if authenticated, parent set to the current host's site id (or _root if writing from apex), claim token if anonymous.
  6. 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).

Reserved paths

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 to site.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.

Service worker and cache layering

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:

  1. If local has an entry, serve it. Trigger background refresh of published.
  2. Else if published has an entry, serve it. Trigger background refresh.
  3. 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.

Platform JavaScript API

/_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 and ownership

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 anonymous fork → claim flow

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.

Publishing

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).

Editing model

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:

  • /_parent loads the parent's HTML, with site.content set to the current site's content and site.id/site.owner reflecting the current site. Calls to site.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 /_parent is a markdown editor.
  • /_root is 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.

Forking and lineage

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.

Operational concerns

Abuse

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.site must 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-Policy is set by default (it would break the use case). Page authors can include their own.

Cost

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.

Recovery from broken home

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.

Service worker versioning

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.

What's deferred

Out of scope for v1, likely valuable later:

  • Indexed views — list-by-owner, list-of-forks. Requires D1 + write-path indexing.
  • Custom domainsmysite.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.site as a custom-friendly subdomain) — would require either reserved-keyword logic in the Worker or a separate DNS/routing layer.

Repository shape

/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.

Open questions

  • Global name. site vs wanix vs _site. Currently site. 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.js so site.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.
// Authentication hook. Replace this implementation with your auth integration.
//
// Returns one of:
// string - authenticated; this is the owner's username/identifier
// null - request is anonymous (no auth credentials, or invalid)
// Response - short-circuit the request (e.g. 401, 302 redirect to login)
//
// The default implementation always returns null — every request is treated as
// anonymous. Sites created without auth get a claim token instead of an owner;
// users who later log in can claim ownership via site.claim().
//
// Examples of what to put here:
//
// // JWT in Authorization header:
// const auth = req.headers.get('authorization');
// if (!auth?.startsWith('Bearer ')) return null;
// const payload = await verifyJWT(auth.slice(7), env.JWT_SECRET);
// return payload?.sub ?? null;
//
// // Session cookie verified against a user service:
// const cookie = req.headers.get('cookie');
// const sid = parseCookie(cookie)?.session;
// if (!sid) return null;
// const r = await fetch(env.AUTH_API + '/session/' + sid);
// if (!r.ok) return null;
// return (await r.json()).username;
//
// // Force login on POST routes:
// if (req.method === 'POST' && !cookie) {
// return Response.redirect(env.LOGIN_URL + '?next=' + encodeURIComponent(req.url), 302);
// }
export async function getOwner(req, env) {
return null;
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>wanix.site</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
* { box-sizing: border-box; }
body {
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif;
max-width: 48rem;
margin: 0 auto;
padding: 2rem 1rem;
color: #1a1a1a;
background: #fafafa;
}
h1 { margin-top: 0; font-size: 1.5rem; }
.muted { color: #666; font-size: .9em; }
textarea {
width: 100%;
height: 28rem;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
padding: .75rem;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
resize: vertical;
}
.toolbar { margin: 1rem 0; display: flex; gap: .5rem; flex-wrap: wrap; }
button {
padding: .5rem 1rem;
font: inherit;
cursor: pointer;
background: #fff;
border: 1px solid #999;
border-radius: 4px;
}
button.primary { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
button:disabled { opacity: .5; cursor: not-allowed; }
#status { min-height: 1.5em; font-size: .9em; color: #666; }
details { margin-top: 2rem; }
details summary { cursor: pointer; }
pre { background: #f0f0f0; padding: .75rem; border-radius: 4px; overflow-x: auto; font-size: 12px; }
</style>
</head>
<body>
<h1>wanix.site</h1>
<p class="muted">A page-publishing platform. Each page lives at its own subdomain and ships its own editor. Edit below, then <em>fork</em> to make a new page (you don't need to log in).</p>
<textarea id="editor" spellcheck="false"></textarea>
<div class="toolbar">
<button id="fork" class="primary">fork</button>
<button id="save">save (local)</button>
<button id="publish">publish</button>
<button id="discard">discard local</button>
<button id="claim">claim</button>
</div>
<div id="status"></div>
<details>
<summary>How this works</summary>
<p>Every page on wanix.site is a self-editor. The page you're looking at <em>is</em> its own editor — view source, you'll see this textarea.</p>
<p>Calls available to page JS:</p>
<pre>site.id // current page id
site.owner // username, or null
site.parent // parent page id, or null
site.content // current saved HTML
site.save(html) // store locally (offline-friendly)
site.publish(html) // write to server (requires ownership)
site.fork(html?) // create a new page from this one
site.claim() // claim an anonymous page after logging in
site.discard() // drop local changes</pre>
<p>If your editor breaks, navigate to <code>/_parent</code> or <code>/_root</code> to recover.</p>
</details>
<script>
document.addEventListener('DOMContentLoaded', () => {
const ed = document.getElementById('editor');
const status = document.getElementById('status');
const log = (m, ok) => { status.textContent = m; status.style.color = ok ? '#080' : (ok === false ? '#a00' : '#666'); };
ed.value = site.content || '';
document.getElementById('fork').onclick = async () => {
try { await site.fork(ed.value); } catch (e) { log(e.message, false); }
};
document.getElementById('save').onclick = async () => {
try { await site.save(ed.value); log('saved locally', true); } catch (e) { log(e.message, false); }
};
document.getElementById('publish').onclick = async () => {
try { await site.publish(ed.value); log('published', true); } catch (e) { log(e.message, false); }
};
document.getElementById('discard').onclick = async () => {
if (!confirm('Discard local changes?')) return;
try { await site.discard(); } catch (e) { log(e.message, false); }
};
document.getElementById('claim').onclick = async () => {
try { const r = await site.claim(); log('claimed by ' + r.owner, true); } catch (e) { log(e.message, false); }
};
// Hide buttons that don't apply.
if (site.owner) document.getElementById('claim').style.display = 'none';
site.onPublishedUpdate(() => log('a newer version of this page is available — discard local to load it'));
});
</script>
</body>
</html>
// Served at /_site.js on every site. Exports the source as a default string
// so the Worker can include it directly without any build step.
export default `(function () {
'use strict';
const meta = (n) => {
const el = document.querySelector('meta[name="' + n + '"]');
return el ? el.content : '';
};
const id = meta('site-id');
const owner = meta('site-owner') || null;
const parent = meta('site-parent') || null;
const contentEl = document.getElementById('__site_content');
const content = contentEl ? JSON.parse(contentEl.textContent) : '';
const STORAGE_CLAIM = 'site:claim:';
const publishedListeners = [];
// Pick up claim token passed via fragment after a fork redirect.
if (location.hash.indexOf('#__claim=') === 0) {
try {
const tok = decodeURIComponent(location.hash.slice('#__claim='.length));
localStorage.setItem(STORAGE_CLAIM + id, tok);
history.replaceState(null, '', location.pathname + location.search);
} catch (e) {}
}
// Register service worker.
let swReady = null;
if ('serviceWorker' in navigator) {
swReady = navigator.serviceWorker.register('/_sw.js').catch((e) => {
console.warn('site: SW registration failed', e);
return null;
});
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data && e.data.type === 'published-updated') {
publishedListeners.forEach((cb) => { try { cb(); } catch (err) {} });
}
});
}
async function postSW(msg) {
if (!swReady) throw new Error('service worker not available');
await swReady;
const reg = await navigator.serviceWorker.ready;
if (!reg.active) throw new Error('no active service worker');
return new Promise((resolve, reject) => {
const ch = new MessageChannel();
ch.port1.onmessage = (e) => {
if (e.data && e.data.error) reject(new Error(e.data.error));
else resolve(e.data);
};
reg.active.postMessage(msg, [ch.port2]);
});
}
window.site = {
id: id,
owner: owner,
parent: parent,
content: content,
async save(html) {
return postSW({
type: 'save',
html: String(html),
meta: { id: id, owner: owner, parent: parent },
});
},
async publish(html) {
const headers = { 'content-type': 'application/json' };
const claim = localStorage.getItem(STORAGE_CLAIM + id);
if (claim) headers['x-site-claim'] = claim;
const r = await fetch('/_publish', {
method: 'POST',
headers: headers,
body: JSON.stringify({ content: String(html) }),
});
if (!r.ok) throw new Error('publish failed: ' + r.status + ' ' + await r.text());
try { await postSW({ type: 'clear-local' }); } catch (e) {}
return true;
},
async fork(html) {
const r = await fetch('/_fork', {
method: 'POST',
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
body: JSON.stringify({ content: html != null ? String(html) : content }),
});
if (!r.ok) throw new Error('fork failed: ' + r.status + ' ' + await r.text());
const data = await r.json();
const target = data.url + (data.claim ? '#__claim=' + encodeURIComponent(data.claim) : '');
location.href = target;
return data.id;
},
async claim() {
const token = localStorage.getItem(STORAGE_CLAIM + id);
if (!token) throw new Error('no claim token for this site');
const r = await fetch('/_claim', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: token }),
});
if (!r.ok) throw new Error('claim failed: ' + r.status + ' ' + await r.text());
const data = await r.json();
localStorage.removeItem(STORAGE_CLAIM + id);
return data;
},
async discard() {
try { await postSW({ type: 'clear-local' }); } catch (e) {}
location.reload();
},
onPublishedUpdate(cb) {
publishedListeners.push(cb);
},
};
})();
`;
// Served at /_sw.js. Same caveats as site.js — exported as a default string
// so the Worker serves it directly. Backslash escapes are doubled here because
// the source itself is a template literal: '\\\\u003c' here becomes '\\u003c'
// in the served JS, which is the 6-char literal \u003c at runtime.
export default `'use strict';
const VERSION = 'v1';
const PUBLISHED = 'site-published-' + VERSION;
const LOCAL = 'site-local-' + VERSION;
const PLATFORM = 'site-platform-' + VERSION;
const attrEsc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
const jsonSafe = (obj) => JSON.stringify(obj)
.replace(/</g, '\\\\u003c')
.replace(/>/g, '\\\\u003e')
.replace(/&/g, '\\\\u0026');
function inject(html, m) {
const tags =
'<meta name="site-id" content="' + attrEsc(m.id) + '">' +
'<meta name="site-owner" content="' + attrEsc(m.owner) + '">' +
'<meta name="site-parent" content="' + attrEsc(m.parent) + '">' +
'<script src="/_site.js" defer></' + 'script>' +
'<script type="application/json" id="__site_content">' +
jsonSafe(m.content == null ? '' : m.content) + '</' + 'script>';
const head = html.match(/<head[^>]*>/i);
if (head) {
const i = head.index + head[0].length;
return html.slice(0, i) + tags + html.slice(i);
}
const dt = html.match(/<!doctype[^>]*>/i);
if (dt) {
const i = dt.index + dt[0].length;
return html.slice(0, i) + tags + html.slice(i);
}
return tags + html;
}
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys
.filter((k) => k.indexOf('site-') === 0 && k.indexOf('-' + VERSION) === -1)
.map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
const url = new URL(e.request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname === '/_site.js' || url.pathname === '/_sw.js') {
e.respondWith(staleWhileRevalidate(PLATFORM, e.request));
return;
}
if (url.pathname === '/') {
e.respondWith(serveSite(e.request));
return;
}
if (url.pathname === '/_parent' || url.pathname === '/_root') {
e.respondWith(staleWhileRevalidate(PUBLISHED, e.request));
return;
}
});
async function serveSite(request) {
const local = await caches.open(LOCAL);
const cached = await local.match(request);
if (cached) {
revalidatePublished(request);
return cached;
}
return staleWhileRevalidate(PUBLISHED, request);
}
async function staleWhileRevalidate(cacheName, request) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const networkPromise = fetch(request).then(async (resp) => {
if (resp && resp.ok) {
try { await cache.put(request, resp.clone()); } catch (e) {}
}
return resp;
}).catch(() => cached || new Response('offline', { status: 503 }));
return cached || networkPromise;
}
async function revalidatePublished(request) {
try {
const cache = await caches.open(PUBLISHED);
const oldResp = await cache.match(request);
const oldText = oldResp ? await oldResp.text() : null;
const resp = await fetch(request);
if (!resp || !resp.ok) return;
const newText = await resp.clone().text();
await cache.put(request, resp);
if (oldText !== null && oldText !== newText) {
const list = await self.clients.matchAll();
list.forEach((c) => c.postMessage({ type: 'published-updated' }));
}
} catch (e) {}
}
self.addEventListener('message', (e) => {
const port = e.ports[0];
const reply = (data) => { if (port) port.postMessage(data); };
if (!e.data || typeof e.data !== 'object') return;
if (e.data.type === 'save') {
e.waitUntil((async () => {
try {
const m = e.data.meta || {};
const html = String(e.data.html == null ? '' : e.data.html);
const injected = inject(html, {
id: m.id, owner: m.owner, parent: m.parent, content: html,
});
const cache = await caches.open(LOCAL);
const url = new URL('/', self.location.origin);
const resp = new Response(injected, {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
await cache.put(url, resp);
reply({ ok: true });
} catch (err) {
reply({ error: String(err && err.message || err) });
}
})());
return;
}
if (e.data.type === 'clear-local') {
e.waitUntil((async () => {
try {
const cache = await caches.open(LOCAL);
const keys = await cache.keys();
await Promise.all(keys.map((k) => cache.delete(k)));
reply({ ok: true });
} catch (err) {
reply({ error: String(err && err.message || err) });
}
})());
return;
}
});
`;
import { getOwner } from './auth.js';
import SITE_JS from './client/site.js';
import SW_JS from './client/sw.js';
const HOME_KEY = '_root';
const A = '0123456789abcdefghijklmnopqrstuv';
function xid() {
const id = new Uint8Array(12);
const t = Math.floor(Date.now() / 1000);
id[0] = t >>> 24; id[1] = t >>> 16; id[2] = t >>> 8; id[3] = t;
crypto.getRandomValues(id.subarray(4));
const d = new Array(20);
d[0]=A[id[0]>>3]; d[1]=A[((id[1]>>6)|(id[0]<<2))&31]; d[2]=A[(id[1]>>1)&31];
d[3]=A[((id[2]>>4)|(id[1]<<4))&31]; d[4]=A[((id[3]>>7)|(id[2]<<1))&31]; d[5]=A[(id[3]>>2)&31];
d[6]=A[((id[4]>>5)|(id[3]<<3))&31]; d[7]=A[id[4]&31]; d[8]=A[id[5]>>3];
d[9]=A[((id[6]>>6)|(id[5]<<2))&31]; d[10]=A[(id[6]>>1)&31]; d[11]=A[((id[7]>>4)|(id[6]<<4))&31];
d[12]=A[((id[8]>>7)|(id[7]<<1))&31]; d[13]=A[(id[8]>>2)&31]; d[14]=A[((id[9]>>5)|(id[8]<<3))&31];
d[15]=A[id[9]&31]; d[16]=A[id[10]>>3]; d[17]=A[((id[11]>>6)|(id[10]<<2))&31];
d[18]=A[(id[11]>>1)&31]; d[19]=A[(id[11]<<4)&31];
return d.join('');
}
const attrEsc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const jsonSafe = (obj) => JSON.stringify(obj)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
function inject(html, { id, owner, parent, content }) {
const tags =
`<meta name="site-id" content="${attrEsc(id)}">` +
`<meta name="site-owner" content="${attrEsc(owner)}">` +
`<meta name="site-parent" content="${attrEsc(parent)}">` +
`<script src="/_site.js" defer></script>` +
`<script type="application/json" id="__site_content">${jsonSafe(content ?? '')}</script>`;
const head = html.match(/<head[^>]*>/i);
if (head) {
const i = head.index + head[0].length;
return html.slice(0, i) + tags + html.slice(i);
}
const dt = html.match(/<!doctype[^>]*>/i);
if (dt) {
const i = dt.index + dt[0].length;
return html.slice(0, i) + tags + html.slice(i);
}
return tags + html;
}
async function hmac(secret, msg) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(msg));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
async function readSite(env, key) {
const obj = await env.BUCKET.get(key);
if (!obj) return null;
return {
body: obj.body,
text: () => obj.text(),
owner: obj.customMetadata?.owner ?? '',
parent: obj.customMetadata?.parent ?? '',
claim: obj.customMetadata?.claim ?? '',
};
}
function siteIdFromHost(host, base) {
if (host === base) return HOME_KEY;
if (host.endsWith('.' + base)) {
const sub = host.slice(0, -(base.length + 1));
if (!sub || sub.includes('.')) return null;
return sub;
}
return null;
}
const FALLBACK_HOME = `<!doctype html>
<html><head><meta charset="utf-8"><title>wanix.site</title>
<style>
body{font:14px/1.5 ui-sans-serif,system-ui;max-width:42rem;margin:3rem auto;padding:0 1rem;color:#222}
textarea{width:100%;height:24rem;font:13px/1.4 ui-monospace,monospace;padding:.5rem;box-sizing:border-box}
button{padding:.5rem 1rem;font:inherit;cursor:pointer;margin-right:.5rem}
.muted{color:#666;font-size:.9em}
</style></head>
<body>
<h1>wanix.site</h1>
<p>A page-publishing platform. Each page lives at its own subdomain and ships its own editor.</p>
<p class="muted">This is the fallback home page. The operator can publish a real one by editing below and clicking publish (auth required).</p>
<textarea id="ed" spellcheck="false"></textarea>
<p><button id="save">save (local)</button> <button id="publish">publish</button> <button id="fork">fork</button> <button id="discard">discard local</button></p>
<pre id="status" class="muted"></pre>
<script>
document.addEventListener('DOMContentLoaded', () => {
const ed = document.getElementById('ed');
const status = document.getElementById('status');
const log = (m) => { status.textContent = m; };
ed.value = site.content || '';
document.getElementById('save').onclick = async () => {
try { await site.save(ed.value); log('saved locally'); } catch (e) { log('error: ' + e.message); }
};
document.getElementById('publish').onclick = async () => {
try { await site.publish(ed.value); log('published'); } catch (e) { log('error: ' + e.message); }
};
document.getElementById('fork').onclick = async () => {
try { await site.fork(ed.value); } catch (e) { log('error: ' + e.message); }
};
document.getElementById('discard').onclick = async () => {
try { await site.discard(); } catch (e) { log('error: ' + e.message); }
};
});
</script>
</body></html>`;
async function readContent(req, env) {
const max = Number(env.MAX_SIZE) || 1_000_000;
const len = Number(req.headers.get('content-length') || 0);
if (len > max * 2) return { error: new Response('too large', { status: 413 }) };
const ct = req.headers.get('content-type') || '';
let content;
if (ct.includes('application/json')) {
try {
const body = await req.json();
content = body.content;
} catch {
return { error: new Response('bad json', { status: 400 }) };
}
} else {
const fd = await req.formData();
content = fd.get('c');
}
if (typeof content !== 'string') return { error: new Response('missing content', { status: 400 }) };
if (content.length > max) return { error: new Response('content too large', { status: 413 }) };
return { content };
}
async function handleFork(req, env, parentId) {
const len = Number(req.headers.get('content-length') || 0);
let content;
if (len === 0) {
const parent = await readSite(env, parentId);
if (!parent) return new Response('parent not found', { status: 404 });
content = await parent.text();
} else {
const r = await readContent(req, env);
if (r.error) return r.error;
content = r.content;
}
const owner = await getOwner(req, env);
if (owner instanceof Response) return owner;
const id = xid();
const meta = { parent: parentId };
if (owner) {
meta.owner = owner;
} else {
if (!env.SECRET) return new Response('server misconfigured: SECRET unset', { status: 500 });
meta.created = String(Date.now());
meta.claim = await hmac(env.SECRET, `${id}.${meta.created}`);
}
await env.BUCKET.put(id, content, { customMetadata: meta });
const base = env.BASE || 'wanix.site';
const newUrl = `https://${id}.${base}/`;
if ((req.headers.get('accept') || '').includes('application/json')) {
return Response.json({
id, url: newUrl,
owner: owner || null,
parent: parentId,
claim: meta.claim || null,
});
}
return Response.redirect(newUrl, 303);
}
async function handlePublish(req, env, id) {
const ct = req.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
return new Response('json required', { status: 415 });
}
const site = await readSite(env, id);
// Bootstrap path: home doesn't exist yet, first authenticated user becomes operator.
if (!site && id === HOME_KEY) {
const owner = await getOwner(req, env);
if (owner instanceof Response) return owner;
if (!owner) return new Response('home bootstrap requires authentication', { status: 401 });
const r = await readContent(req, env);
if (r.error) return r.error;
await env.BUCKET.put(id, r.content, { customMetadata: { owner } });
return new Response('ok', { status: 200 });
}
if (!site) return new Response('not found', { status: 404 });
// Auth: owner OR (anonymous + claim token)
const requester = await getOwner(req, env);
if (requester instanceof Response) return requester;
let authorized = false;
if (site.owner && requester === site.owner) authorized = true;
if (!site.owner && site.claim) {
const provided = req.headers.get('x-site-claim');
if (provided && provided === site.claim) authorized = true;
}
if (!authorized) return new Response('forbidden', { status: 403 });
const r = await readContent(req, env);
if (r.error) return r.error;
const meta = {};
if (site.owner) meta.owner = site.owner;
if (site.parent) meta.parent = site.parent;
if (site.claim) meta.claim = site.claim;
await env.BUCKET.put(id, r.content, { customMetadata: meta });
return new Response('ok', { status: 200 });
}
async function handleClaim(req, env, id) {
const ct = req.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
return new Response('json required', { status: 415 });
}
const site = await readSite(env, id);
if (!site) return new Response('not found', { status: 404 });
if (site.owner) return new Response('already claimed', { status: 409 });
if (!site.claim) return new Response('not claimable', { status: 400 });
const owner = await getOwner(req, env);
if (owner instanceof Response) return owner;
if (!owner) return new Response('unauthorized', { status: 401 });
let body;
try { body = await req.json(); } catch { body = {}; }
if (body.token !== site.claim) return new Response('bad token', { status: 403 });
const meta = { owner };
if (site.parent) meta.parent = site.parent;
await env.BUCKET.put(id, site.body, { customMetadata: meta });
return Response.json({ ok: true, owner });
}
function pageResponse(id, owner, parent, html, content) {
const injected = inject(html, { id, owner, parent, content: content ?? html });
return new Response(injected, {
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'public, max-age=3600',
'x-content-type-options': 'nosniff',
'x-site-owner': owner || '',
'x-site-parent': parent || '',
},
});
}
export default {
async fetch(req, env, ctx) {
const url = new URL(req.url);
const path = url.pathname;
const host = req.headers.get('host') || '';
const base = env.BASE || 'wanix.site';
const id = siteIdFromHost(host, base);
if (id === null) return new Response('bad host', { status: 400 });
// Platform scripts (served identically on every origin)
if (path === '/_site.js') {
return new Response(SITE_JS, {
headers: {
'content-type': 'application/javascript; charset=utf-8',
'cache-control': 'public, max-age=300',
},
});
}
if (path === '/_sw.js') {
return new Response(SW_JS, {
headers: {
'content-type': 'application/javascript; charset=utf-8',
'cache-control': 'public, max-age=300',
'service-worker-allowed': '/',
},
});
}
// Mutating endpoints
if (req.method === 'POST') {
if (path === '/_publish') return handlePublish(req, env, id);
if (path === '/_fork' || path === '/') return handleFork(req, env, id);
if (path === '/_claim') return handleClaim(req, env, id);
}
// Recovery paths: load alternate template, inject current site's content + meta
if (req.method === 'GET' && (path === '/_parent' || path === '/_root')) {
const current = await readSite(env, id);
if (!current) return new Response('not found', { status: 404 });
const tmplKey = path === '/_parent' ? (current.parent || HOME_KEY) : HOME_KEY;
const tmpl = await readSite(env, tmplKey);
if (!tmpl) {
return new Response(path === '/_parent' ? 'parent not found' : 'home not found',
{ status: 404 });
}
return pageResponse(id, current.owner, current.parent,
await tmpl.text(), await current.text());
}
// Normal page load
if (req.method === 'GET' && path === '/') {
const site = await readSite(env, id);
if (!site) {
if (id === HOME_KEY) return pageResponse(id, '', '', FALLBACK_HOME, '');
return new Response('not found', { status: 404 });
}
const text = await site.text();
return pageResponse(id, site.owner, site.parent, text);
}
return new Response('not found', { status: 404 });
},
};
name = "wanix"
main = "worker.js"
compatibility_date = "2025-01-01"
[vars]
BASE = "wanix.site"
MAX_SIZE = "1000000"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "wanix"
[[routes]]
pattern = "wanix.site/*"
zone_name = "wanix.site"
[[routes]]
pattern = "*.wanix.site/*"
zone_name = "wanix.site"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment