ServerlessIRCd Docs

Docs home

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.md under a new Phase 8 — Web client heading and supersede the corresponding sections here.


1. Goals & non-goals

Goals

Non-goals (v1 of the frontend)

Design-for-later


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

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

TICKET-067 — Vendor Kiwi IRC as git submodule; build pipeline

TICKET-068 — static/config.json Zod schema + per-env build matrix

TICKET-069 — Serve static assets from apps/cf-worker via [assets]

TICKET-070 — WS Origin allowlist in Worker (CSWSH defense)

TICKET-071 — E2E: headless browser vs deployed staging frontend

TICKET-072 — Docs: docs/web-client.md


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


10. Deliverables checklist (definition of done for v1 web client)


11. Open questions