ServerlessIRCd — Web Client Plan
A static, CDN-served Kiwi IRC web frontend that lets a browser connect
directly to a deployed ServerlessIRCd instance. The SPA opens a native
wss:// WebSocket to the Cloudflare Worker entry and speaks the IRC protocol
one-message-per-text-frame — exactly what the server already emits today. No
proxy in the hot path.
Status: planning. No code has landed. Tickets proposed in §7; once ratified they will be appended to
tickets.md/progress.mdunder a new Phase 8 — Web client heading and supersede the corresponding sections here.
1. Goals & non-goals
Goals
- A drop-in static frontend that boots Kiwi IRC, pre-configured for a given ServerlessIRCd deployment (staging, prod, self-host).
- Browser talks directly to the existing WebSocket entry — no extra proxy, gateway, or transport adaptation in the v1 hot path.
- Per-environment builds with reproducible config (no runtime secrets in the bundle).
- Same TDD discipline as the rest of the monorepo: schema tests, build smoke, headless e2e against a deployed stack.
- One-command
pnpm --filter web build:stagingproduces a deployabledist/(or per-env variant).
Non-goals (v1 of the frontend)
- Server-side rendering / dynamic routing. Kiwi is a Vue SPA; SSR buys us nothing here.
- Custom Kiwi plugins, themes, or heavy branding. Stock chrome is fine for
v1; the upstream build is layered with our
static/config.jsononly. - Server-side account / oper UI beyond what stock Kiwi ships. SASL PLAIN is already part of Kiwi's default flow.
- A second transport (SockJS, Kiwi multi-server transport via
webircgateway). Documented as an optional fallback only. - WEBIRC hostname injection at the gateway layer. ServerlessIRCd will do its own hostmask cloaking in TICKET-046; we don't need a WEBIRC proxy for it.
Design-for-later
- Plugin seam: a clean place under
apps/web/plugins/(mirrors Kiwi's plugin convention) so a future ticket can add SASL UI helpers, chathistory controls (once TICKET-063 lands), etc. - Cloudflare Pages variant of the build output kept valid so prod can move
off the Worker
[assets]binding without rebuilding.
2. Architecture
┌────────────────────────────────────────────────────────────────────┐
│ Browser │
│ loads index.html + JS bundle (Kiwi IRC SPA) │
│ fetches /static/config.json at boot │
└────────────────────────────────────────────────────────────────────┘
│ │
│ HTTPS (static assets) │ wss (one IRC msg / frame)
▼ ▼
┌────────────────────────────────────────────────────────────────────┐
│ apps/cf-worker (existing Cloudflare Worker) │
│ fetch(): │
│ • Upgrade: websocket → ConnectionDO (existing path) │
│ + Origin allowlist check (NEW, TICKET-070) │
│ • otherwise → [assets] binding serves dist/ │
└────────────────────────────────────────────────────────────────────┘
│
▼
(ConnectionDO / RegistryDO / ChannelDO
unchanged — see PLAN §6.1)
The Worker is the single origin for both the SPA and the WebSocket. That
makes CORS trivial (same-origin) and lets one domain serve the whole
product. A Cloudflare Pages deployment of the same dist/ is a documented
alternative for prod if cost / separation of concerns ever favors it.
Why no webircgateway in the hot path? ServerlessIRCd already speaks
the wire format browser Kiwi expects: native WebSocket text frames, one IRC
message per frame, with \r\n-joined-frame tolerance (PLAN §9). The
gateway exists in-tree (webircgateway/) and ships as an optional
sidecar for environments where raw wss is blocked by corporate proxies or
mobile carriers; it provides SockJS + Kiwi multi-server transport fallback
in those cases. v1 of the frontend does not require it.
3. Component placement
3.1 New workspace package: apps/web
apps/web/
├── upstream/ Kiwi IRC source — git submodule, tag-pinned
│ (mirrors existing `docs/` + `webircgateway/`
│ submodule pattern)
├── static/
│ ├── config.json Our environment-specific Kiwi config
│ ├── config.staging.json Per-env override (build matrix)
│ ├── config.prod.json
│ └── assets/ Brand overrides (logo, favicon) — optional
├── src/
│ ├── config-schema.ts Zod schema for config.json (the only TS we own)
│ └── plugins/ Reserved for future Kiwi plugins (empty in v1)
├── scripts/
│ └── build.mjs Orchestrates upstream build + layers our config
├── tests/
│ ├── config-schema.test.ts Zod validation, red/green per field
│ └── build-smoke.test.ts Asserts dist/ shape post-build
├── package.json pnpm workspace package (@serverless-ircd/web)
├── tsconfig.json extends tsconfig.base.json
└── README.md per-package developer notes
The package owns no IRC logic — it's purely a build/config layer over the upstream Kiwi SPA. The only TypeScript we ship is the Zod config schema and any future plugin shims.
3.2 Touchpoints with existing code
| File | Change |
|---|---|
apps/cf-worker/src/worker.ts:44-58 |
Non-WS branch falls through to assets binding; WS branch gains Origin check. |
apps/cf-worker/wrangler.toml |
Add [assets] block pointing at ../../apps/web/dist; add WEB_ORIGINS var per env. |
pnpm-workspace.yaml |
Already globs apps/* — apps/web is picked up automatically. No change. |
turbo.json |
Add apps/web#build to the pipeline with a dependsOn: [] (no internal workspace deps). |
.gitmodules |
Append apps/web/upstream entry pinning the Kiwi release tag. |
packages/irc-core/... |
None. The frontend consumes zero @serverless-ircd/* packages at runtime. |
4. Decisions to confirm
Each row is a proposed default. Override before TICKET-066 lands.
| # | Decision | Proposed default | Alternative |
|---|---|---|---|
| 4.1 | Static hosting | Worker [assets] binding (single-origin staging) |
Cloudflare Pages; separate static host |
| 4.2 | Kiwi distribution | Git submodule at apps/web/upstream/, tag-pinned |
Fork in-tree; npm package (Kiwi isn't published for this) |
| 4.3 | Config injection | static/config.json checked into repo; build matrix emits dist-staging/ / dist-prod/ |
Build-time env vars only; runtime fetch from /config |
| 4.4 | Origin allowlist | New WEB_ORIGINS Worker var; WS Upgrade rejected with 403 if Origin not listed |
Trust same-origin only (breaks Pages variant); no check (CSWSH risk) |
| 4.5 | webircgateway | Out of hot path; documented as optional SockJS fallback | Run as sidecar in v1 (only needed for proxy resilience) |
| 4.6 | Branding | Stock Kiwi chrome with a static/assets/logo.svg swap |
Full theme fork |
| 4.7 | Default channels | One #welcome channel auto-joined on first connect |
Empty; let the user type channels |
4.1 Hosting — Worker assets vs Pages
- Worker
[assets]binding (recommended for staging). Single domain serves SPA + WS. Simplest CORS story, simplest deploy story, one wrangler command. Adds zero runtime cost (assets are served by the Workers runtime, not by JS). - Cloudflare Pages (alternative for prod). Decouples static deploys
from Worker deploys — useful if frontend changes more often than the
IRCd. Requires the Worker to allow the Pages origin in
WEB_ORIGINS.
The build output is identical for both; the choice is a deploy-time knob, not a build-time one.
4.4 Origin allowlist — why it's mandatory
WebSocket upgrades do not follow the same-origin policy. An attacker
page can new WebSocket('wss://irc.example.com/') and, if the user has
previously registered a nick in another tab, race the connection into
issuing commands on their behalf (CSWSH — cross-site WebSocket
hijacking). The Worker MUST reject upgrades whose Origin header is not
in a configured allowlist. This is a defense-in-depth item on top of any
session/nick ownership semantics the IRCd grows later.
5. Kiwi IRC configuration surface
Kiwi reads a JSON config either from window.kiwiConfig or from
/static/config.json fetched at boot (we use the latter — it lets us
swap config per env without rebuilding the JS bundle). The schema we own
mirrors the subset of Kiwi's documented config keys that we care about;
unknown keys are passed through unchanged so the upstream's evolution
doesn't break our build.
// static/config.staging.json (example)
{
"server": "irc-staging.example.com",
"port": 443,
"tls": true,
"path": "/", // WS path on the Worker
"nick": "kiwi-?", // ? = random suffix
"channels": ["#welcome"],
"brand": "ServerlessIRCd (staging)",
"windowTitle": "ServerlessIRCd staging",
"showSendButton": true,
"theme": "Default",
"plugins": []
}
Zod schema (apps/web/src/config-schema.ts):
export const KiwiConfig = z.object({
server: z.string().min(1),
port: z.number().int().min(1).max(65535),
tls: z.boolean(),
path: z.string().startsWith('/').default('/'),
nick: z.string().min(1),
channels: z.array(z.string().startsWith('#')).default([]),
brand: z.string().default('ServerlessIRCd'),
windowTitle: z.string().default('ServerlessIRCd'),
}).passthrough(); // tolerate upstream keys we don't model
Build matrix:
pnpm ... command |
Reads | Writes |
|---|---|---|
--filter web build |
static/config.json |
dist/ |
--filter web build:staging |
static/config.staging.json |
dist/ (CI deploys this) |
--filter web build:prod |
static/config.prod.json |
dist/ |
A malformed config fails the build with a readable Zod error. No silent fall-through.
6. Testing strategy
Strict TDD per the project rules. The frontend is mostly assembled rather than authored, so the testable surface is narrow but real:
| Layer | Tooling | What it asserts |
|---|---|---|
| Config schema | vitest + Zod | Malformed config.json fails fast; well-formed config round-trips. |
| Build smoke | vitest on dist/ |
dist/index.html + dist/static/config.json exist post-build; configured server matches the requested env. |
| Origin check | vitest (unit on apps/cf-worker) |
Disallowed Origin → 403; allowed → upgrade proceeds. |
| E2E | Playwright headless vs staging | SPA boots, Kiwi reaches 001/376, joins #welcome, round-trips a PRIVMSG. |
E2E reuses the scenario vocabulary already in
apps/cf-worker/scripts/smoke.mjs so the WS smoke and the browser smoke
share fixture data.
Determinism: the Zod schema, build smoke, and Origin check tests are
all pure. The Playwright e2e is gated behind a --e2e flag (off by
default in pnpm test, on in CI's nightly job) so it can't make the unit
suite flaky.
7. Proposed tickets
Seven new tickets under a new Phase 8 — Web client heading in
tickets.md / progress.md. Each follows the existing TDD outline shape
(Red → Green → Refactor, acceptance criteria, dependencies).
TICKET-066 — apps/web workspace skeleton
- Phase: 8
- Points: 2
- Dependencies: —
- Description: Create the
apps/webpnpm workspace package with an emptyindex.html,package.json,tsconfig.jsonextendingtsconfig.base.json, and a vitest config. Add thebuildtask toturbo.json. No Kiwi source yet — just the scaffolding so subsequent tickets have a home. - Acceptance Criteria:
pnpm installrecognizes@serverless-ircd/web.pnpm --filter web testruns (no tests yet, exits 0).pnpm --filter web typecheckpasses againsttsconfig.base.json.- Biome lints the new files without errors.
- TDD Outline: Red on a vitest asserting
apps/web/package.jsonname === '@serverless-ircd/web'; green by adding the package.
TICKET-067 — Vendor Kiwi IRC as git submodule; build pipeline
- Phase: 8
- Points: 3
- Dependencies: TICKET-066
- Description: Add Kiwi IRC source as a git submodule at
apps/web/upstream/pinned to a release tag.scripts/build.mjsruns Kiwi's upstream build (yarn && yarn buildinsideupstream/), then copiesupstream/dist/intoapps/web/dist/and layers ourstatic/config.jsonover the upstream default.pnpm --filter web buildproduces a working SPA bundle. - Acceptance Criteria:
pnpm --filter web buildfrom a clean checkout producesapps/web/dist/index.html,apps/web/dist/static/config.json, and the JS bundle.- The submodule is pinned to a specific upstream tag (not
main). .gitmodulesentry added;git submodule update --initis in the contributor quickstart.
- TDD Outline: Red on a vitest build-smoke test asserting
dist/index.htmlexists post-build; green by wiringbuild.mjs.
TICKET-068 — static/config.json Zod schema + per-env build matrix
- Phase: 8
- Points: 2
- Dependencies: TICKET-067
- Description: Ship
src/config-schema.ts(Zod, see §5) withstatic/config.json,static/config.staging.json, andstatic/config.prod.json.build.mjsaccepts a--envflag selecting which config to bake intodist/. Default config targets the staging Worker hostname. - Acceptance Criteria:
- Malformed config (missing
server, out-of-rangeport, non-#channel) fails the build with a readable Zod error. pnpm --filter web build:stagingbakesconfig.staging.json;:prodbakesconfig.prod.json.- A vitest assertion confirms the configured
servermatches the requested env (catches cross-env bake mistakes in CI).
- Malformed config (missing
- TDD Outline: Red on Zod rejecting
{}; green. Red on a config withport: 99999; green. Red onchannels: ["foo"]; green.
TICKET-069 — Serve static assets from apps/cf-worker via [assets]
- Phase: 8
- Points: 2
- Dependencies: TICKET-067
- Description: Add a Wrangler
[assets]binding toapps/cf-worker/wrangler.tomlpointing at../../apps/web/dist. The non-WS branch ofworker.tsfetch()falls through to assets (the binding handles this automatically; the existing plaintext health response moves to a/healthpath). Per-env ([env.staging.assets]) blocks for staging. - Acceptance Criteria:
wrangler devservesdist/index.htmlat/and the JS bundle at its hashed asset path./healthreturns the previous plaintext response.- WS upgrade still routes to
ConnectionDO. - Staging deploys both Worker and assets in one
wrangler deploy --env staging.
- TDD Outline: Red on a
vitest-pool-workerstest assertingGET /returns the SPA HTML; green by adding the binding. Red onGET /healthreturning the plaintext string; green.
TICKET-070 — WS Origin allowlist in Worker (CSWSH defense)
- Phase: 8
- Points: 3
- Dependencies: TICKET-069
- Description: Worker validates the
Originheader on every WS upgrade against a config-drivenWEB_ORIGINSvar (comma-separated, per-env). Disallowed origins get403 Forbidden. Documentation indocs/web-client.mdmakes clear this is mandatory, not optional. - Acceptance Criteria:
- Upgrade with
OrigininWEB_ORIGINS→ proceeds (existing path). - Upgrade with
Originnot in list →403. - Upgrade with missing
Origin(non-browser client — curl, thetcp-ws-forwarder, WeeChat) → proceeds (preserves existing non-browser tooling). - Empty/missing
WEB_ORIGINSvar → fail-closed (build error onwrangler deploy) so a deploy can never silently disable the check.
- Upgrade with
- TDD Outline: Red on disallowed Origin receiving 403 (currently
proceeds); green by adding the check. Red on missing Origin with
empty
WEB_ORIGINSproceeding; green by failing closed.
TICKET-071 — E2E: headless browser vs deployed staging frontend
- Phase: 8
- Points: 5
- Dependencies: TICKET-068, TICKET-069, TICKET-070
- Description: Playwright e2e in
apps/web/tests/e2e/that opens the deployed staging SPA, asserts Kiwi reaches registered state (001/376), joins#welcome, sends aPRIVMSG, and sees the echo on its own buffer. Reuses the scenario fixture data already living inapps/cf-worker/scripts/smoke.mjs. Gated behind--e2e; wired into the nightly CI job, not the per-push suite. - Acceptance Criteria:
- Single test passes against the staging Worker + frontend.
- The test fails clearly when the SPA fails to connect (timeout on registration).
- Adding a new scenario is a one-file append (data-driven).
- TDD Outline: Red on a stub test asserting
document.titlematches the configuredwindowTitle; green once the SPA loads from staging. Then incrementally add the register/join/privmsg assertions.
TICKET-072 — Docs: docs/web-client.md
- Phase: 8
- Points: 1
- Dependencies: TICKET-071
- Description: End-to-end contributor/operator doc covering: how to
bootstrap the submodule, build per-env, serve locally, deploy via
wrangler; the CSWSH rationale and the mandatory
WEB_ORIGINSvar; when and how to deploy the optionalwebircgatewaysidecar for SockJS fallback; how to swap to Cloudflare Pages if prod favors it. - Acceptance Criteria:
- A new contributor can
git submodule update --init,pnpm install,pnpm --filter web build:staging, andwrangler deploy --env stagingfollowing only this doc. WEB_ORIGINSis called out as mandatory, with examples.webircgatewayis clearly labeled optional with the triggering use case (proxy-blocked wss).
- A new contributor can
- TDD Outline: N/A (documentation). Reviewed by following the steps from scratch in a clean checkout.
8. Risks
| Risk | Mitigation |
|---|---|
| CSWSH — attacker page opens WS to IRCd riding a user's session | Origin allowlist (TICKET-070); WS does not follow SOP. |
| Bundle staleness in submodule (Kiwi release drift) | Pin a tag, not main; weekly CI check-for-updates job (low priority, follow-up). |
| Corporate-proxy networks blocking raw wss | Document webircgateway SockJS fallback (already vendored); not v1-blocking. |
| Dev/prod config leakage (internal hostnames baked into a prod bundle) | Per-env build matrix; prod build only reads config.prod.json; CI asserts server matches env. |
Kiwi assumes IRC server reachable at / on plain ws/wss |
Worker already accepts WS upgrade at /; no Kiwi custom-transport plugin required. |
Worker [assets] size cap / deploy time |
Kiwi bundle is a few hundred KB gzipped; well under Wrangler's assets-directory cap. Monitor as plugins land. |
| Submodule init forgotten on fresh clone | docs/web-client.md quickstart + a pnpm postinstall hook that warns if apps/web/upstream/ is empty. |
9. Out of scope for v1 of the frontend
- Custom Kiwi plugins (SASL UI helpers, chathistory controls once
TICKET-063 lands, etc.). The
src/plugins/directory is reserved. - Custom themes / heavy branding. Stock Kiwi chrome with a logo swap.
- Server-side rendering or any server-side runtime.
- A second transport (SockJS / Kiwi multi-server transport via
webircgateway). Documented as optional only. - Account / oper login UI beyond stock Kiwi.
- Internationalization overrides beyond what Kiwi ships by default.
- Mobile-specific build variants (PWA manifest, etc.). The SPA is responsive out of the box; deferred to a follow-up if measured mobile usage justifies it.
10. Deliverables checklist (definition of done for v1 web client)
-
apps/webworkspace builds under turbo with no internal workspace runtime deps. -
pnpm --filter web build:stagingproduces adist/that, served by the staging Worker, reaches registered state in a real browser. - Worker
[assets]binding serves the SPA in staging;/healthpreserves the plaintext response. - WS
Originallowlist enforced; missingWEB_ORIGINSfails the deploy. - Playwright e2e green against staging on the nightly CI job.
-
docs/web-client.mdwalkable from a clean checkout. - All new tickets green in
progress.md; PLAN §3 / README repo layout updated to mentionapps/web.
11. Open questions
- Should the frontend live on the same domain as the Worker (current
proposal) or a subdomain (
web.example.comvsirc.example.com)? Same-domain is simpler; subdomain is more conventional for IRC networks. Decide before TICKET-069. - Do we want a
Makefile-style convenience target (pnpm web:dev) that boots bothwrangler devand a vite preview of the SPA in parallel, or keep them as separate commands? Lean toward convenience target. - Should the Origin allowlist support wildcards (
*.example.com) or exact-match only? Lean toward exact-match for v1; revisit if multi- tenant deployment ever demands it.