ServerlessIRCd Docs

Docs home

Web Client Guide

End-to-end contributor/operator doc for the ServerlessIRCd web client: a vendored Kiwi IRC SPA served at /webclient/ and a static project landing page served at / by the Cloudflare Worker. The browser opens a native wss:// WebSocket straight to the Worker's existing IRC-over-WebSocket endpoint — no proxy, gateway, or transport adaptation in the hot path.

Cross-reference: PLAN.md §6.1 (CF mapping), docs/PlanWebClient.md (design), docs/Cloudflare-Deployment-Guide.md (Worker deploy), apps/cf-worker/wrangler.toml, apps/web/.

AWS counterpart. This doc covers the Cloudflare web-client path, where the Worker serves both the SPA and the WebSocket from one origin. The AWS deployment cannot unify them — the SPA lives on CloudFront (S3 + OAC) and the WebSocket endpoint on API Gateway, so the baked config carries the API Gateway wss URL as a literal and the deploy is a separate two-phase pipeline (stack → bake → s3 sync → invalidate). For the AWS web client — construct, build, deploy pipeline, the WEB_ORIGINS CSWSH defence, and custom domain — see docs/AWS-Deployment.md §16 (Web client).

Acceptance criterion (TICKET-139): a new contributor can git submodule update --init, pnpm install, pnpm --filter web build, and wrangler deploy following only this doc.


1. How the pieces fit

   ┌──────────────────────────────────────────────────────────────┐
   │  Browser                                                     │
   │    GET /            → landing page (project front door)      │
   │    GET /webclient/    → Kiwi IRC SPA (index.html + JS bundle)  │
   │    wss://.../       → one IRC message per WS text frame      │
   └──────────────────────────────────────────────────────────────┘
           │                                │
           │ HTTPS (static assets)          │ wss (IRC-over-WebSocket)
           ▼                                ▼
   ┌──────────────────────────────────────────────────────────────┐
   │  apps/cf-worker  (Cloudflare Worker)                         │
   │    fetch(request, env):                                      │
   │      Upgrade: websocket → Origin check → ConnectionDO       │
   │      GET /health         → plaintext liveness string         │
   │      else                → env.ASSETS (apps/web/dist)        │
   └──────────────────────────────────────────────────────────────┘

The Worker is the single origin for the landing page, the SPA, and the WebSocket. Same-origin by default — which is what makes the CSWSH defense in §5 almost zero-config. A Cloudflare Pages deployment of the same dist/ is a documented alternative for prod (see §7).

Path layout on the Worker:

Path Served by Content
/ [assets] binding dist/index.html (landing page)
/webclient/ [assets] binding dist/webclient/index.html (Kiwi SPA)
/webclient/static/... [assets] binding Kiwi JS/CSS/themes + baked config
/health worker.ts handler Plaintext liveness string
/ (WS upgrade) worker.ts → ConnectionDO IRC session

The WS endpoint and the landing page share path /. The Worker runs first for every request (run_worker_first = true in wrangler.toml) so the Upgrade: websocket check fires before the asset platform serves / — otherwise an upgrade would get a 200 OK landing page instead of a 101 switch.


2. Prerequisites

Requirement Version / detail
Node.js ≥ 24 (matches CI; engines.node in root package.json)
pnpm 11.x (packageManager pin in root package.json)
yarn Required only to build the SPA. Kiwi ships a yarn.lock; corepack enable provides yarn on any Node.
wrangler CLI Comes from apps/cf-worker/devDependencies; no global install needed.
Cloudflare account For deploy only (see docs/Cloudflare-Deployment-Guide.md). Local dev needs nothing.

Confirm the local environment:

node --version          # v24.x or newer
corepack enable         # lets the pinned pnpm + yarn run
pnpm --version          # 11.x

3. Quickstart (clean checkout → deployed SPA)

# 1. Pull submodules (docs wiki + Kiwi IRC upstream sources).
git submodule update --init

# 2. Install workspace deps.
pnpm install

# 3. Build the SPA + landing page (default config = dev target).
pnpm --filter web build
#    Equivalent: pnpm --filter @serverless-ircd/web build
#    → apps/web/dist/index.html        (landing page)
#    → apps/web/dist/webclient/index.html    (Kiwi SPA)
#    → apps/web/dist/webclient/static/config.json  (baked, env-specific)

# 4. Build the rest of the workspace (Worker imports compiled dist/).
pnpm build

# 5. Serve everything locally (Worker + SPA + landing page).
pnpm --filter @serverless-ircd/cf-worker dev
#    → http://localhost:8787/      landing page
#    → http://localhost:8787/webclient/  Kiwi SPA
#    → ws://localhost:8787/        IRC-over-WebSocket (same endpoint)

# 6. Deploy (Worker + assets in one command).
pnpm deploy:cf
#    → wrangler deploy

Open http://localhost:8787/webclient/ in a browser — Kiwi boots, reads /webclient/static/config.json, and opens a wss:// (or ws:// in local dev) connection to the same origin.

Forgot the submodule? apps/web/scripts/build.mjs fails fast: Kiwi upstream missing at .../upstream. Run 'git submodule update --init apps/web/upstream' first.


4. The build pipeline (apps/web/scripts/build.mjs)

The build is an orchestrated layering over the upstream Kiwi build — we own no IRC logic, only config + asset placement.

4.1 Per-environment config matrix

pnpm ... command Reads Bakes into dist/webclient/static/config.json
--filter web build static/config.json dev default config
--filter web build:prod static/config.prod.json prod config

--env is parsed by src/build-env.ts; an unknown value (including the retired staging) aborts the build with a readable error. The selected file is validated against the Zod schema (src/config-schema.ts) before it is baked, so a malformed config fails the build rather than shipping a broken SPA.

4.2 What the build does, in order

  1. Verify the submodule exists (upstream/), or fail with the hint quoted above.
  2. yarn install --frozen-lockfile inside upstream/ (only if upstream/node_modules is missing). COREPACK_ROOT is set so yarn classic doesn't refuse on the pnpm-managed workspace root. On Node ≥ 17 the OpenSSL legacy provider is enabled for webpack 5's md4 hashing.
  3. Patch upstream/vue.config.js publicPath: '' → publicPath: '/webclient/', run yarn build, then restore the file in finally so the submodule stays clean. This is why every hashed asset URL comes out /webclient/static/js/app.<hash>.js instead of static/js/..., which would 404 at root.
  4. Copy upstream/dist/ → apps/web/dist/webclient/.
  5. Rewrite bare static/... refs in the baked index.html (e.g. the favicon, which webpack doesn't run through publicPath) to /webclient/static/....
  6. Validate + bake the per-env config: read static/config.<env>.json, run parseKiwiConfig, re-serialise (stable key order), write to dist/webclient/static/config.json.
  7. Copy the landing page landing/index.html → dist/index.html.

Output is reproducible: same checkout + same --env → byte-identical dist/ (modulo upstream webpack hash stability).

4.3 The config schema (src/config-schema.ts)

The schema models only the fields ServerlessIRCd cares about; the rest of Kiwi's surface is passed through untouched (.passthrough()).

// static/config.prod.json
{
  "windowTitle": "ServerlessIRCd",
  "startupOptions": {
    "server": "{{hostname}}",   // resolved to window.location.hostname at boot
    "direct": true,             // MUST be true — direct WS, no kiwiServer gateway
    "tls": true,                // MUST be true — wss:// (page is HTTPS)
    "port": 443,
    "channel": "#welcome",
    "nick": "Guest"
  }
}

Field notes (see the schema docstrings for the full rationale):

A malformed config aborts the build with an aggregated error, e.g.:

config.prod.json: Invalid Kiwi web config:
  - startupOptions.port: port must be a valid TCP port (1-65535)
  - startupOptions.channel: must begin with '#'

4.4 Tests

pnpm --filter web test         # schema + build-env + workspace tests
pnpm --filter web typecheck

The schema and build-env tests are pure (no Kiwi build needed). The build-smoke tests (tests/build-smoke.test.ts) assert the landing page source unconditionally and the dist/ output only when a build is present (describe.skipIf(!built)) — the Kiwi build is too heavy for the unit suite, so run pnpm --filter web build first to exercise those assertions.


5. CSWSH defense and the WEB_ORIGINS var

WebSocket upgrades do not follow the same-origin policy. A malicious page can new WebSocket('wss://irc.example.com/') from the victim's browser and drive an IRC session with the victim's credentials (Cross-Site WebSocket Hijacking, CSWSH). The Worker rejects upgrades whose Origin is not allowed — see apps/cf-worker/src/origin-allowlist.ts.

5.1 Two enforcement modes (evaluated in order)

  1. Explicit allowlist — when WEB_ORIGINS is set, only listed origins proceed. Use this for cross-origin deployments (SPA on a different domain than the Worker).
  2. Auto-derive (same-origin) — when WEB_ORIGINS is unset/empty (the default), the Worker compares the browser's Origin against the request's own origin (${protocol}//${host}). If they match, the page opening the WS is the same origin as the server → safe.

Non-browser clients (curl, WeeChat, the tcp-ws-forwarder, scripted test harnesses) never send Origin; their upgrades pass through unchanged in both modes, so existing tooling keeps working.

5.2 WEB_ORIGINS is optional

Because mode 2 (same-origin auto-derive) is the default and requires zero per-env configuration, WEB_ORIGINS is optional. Most deployments never need to set it: the SPA and the Worker are on the same origin, so the auto-derive check passes for every browser and rejects every cross-origin attempt. It works for *.workers.dev, custom domains, and preview URLs alike.

Set WEB_ORIGINS only when the SPA is served from a different origin than the Worker (e.g. a Cloudflare Pages deployment — see §7 — or a separate static host). It is a comma-separated list of origins, lowercased and whitespace-trimmed:

# apps/cf-worker/wrangler.toml — cross-origin example
[vars]
WEB_ORIGINS = "https://irc.example.com,https://web.example.com"
# or per-deploy via the secret store / dashboard env vars:
wrangler secret put WEB_ORIGINS

5.3 Responses

Scenario Response
Origin allowed (allowlist or same-origin) 101 Switching Protocols → ConnectionDO
Origin header absent (non-browser client) 101 (unchanged)
Origin present but not allowed 403 Forbidden (WebSocket upgrade refused: the request Origin is not allowed.)
No allowlist AND Host undeterminable 403 Forbidden (defensive fail-closed; does not fire on Cloudflare, which always sets Host)

The 403 path is unit-tested in apps/cf-worker/tests/ws-origin.test.ts and the pure policy in origin-allowlist.test.ts. There is no fail-open path.


6. Local development

6.1 Serve the SPA from the Worker (wrangler dev)

This is the canonical local loop — same-origin, real workerd, real Durable Objects:

pnpm --filter web build          # build the SPA + landing page
pnpm build                       # build the rest of the workspace
pnpm --filter @serverless-ircd/cf-worker dev

Then:

Smoke the WS path directly (no browser needed):

node apps/cf-worker/scripts/smoke.mjs
# → replays CONNECT/NICK/USER/JOIN/PRIVMSG/QUIT, asserts 001/376/353/366

6.2 Iterate on the SPA config without rebuilding Kiwi

The Kiwi upstream build is the slow step. For a config-only change, edit static/config.prod.json, then re-run pnpm --filter web build:prod — step 2 of the build reuses upstream/node_modules and skips straight to re-baking the config (steps 4–6 are fast).

6.3 Iterate on the landing page

landing/index.html is pure static HTML + inline CSS, no JavaScript, no build step. Edit it and re-run the build (it is copied verbatim to dist/index.html); or just preview it directly in a browser — it has no runtime dependencies.


7. Optional: Cloudflare Pages instead of Worker assets

The build output (apps/web/dist/) is identical for both hosting options; the choice is a deploy-time knob, not a build-time one. Use Pages when you want to decouple static deploys from Worker deploys (e.g. the frontend changes more often than the IRCd), or when prod favors a dedicated static edge.

7.1 Set up Pages

  1. Push the repo (or just apps/web/dist/) to a Pages-connected branch, or use wrangler pages deploy apps/web/dist.
  2. Set the Pages project's output directory to apps/web/dist and the build command to pnpm --filter web build:prod (plus pnpm build if the Worker is deployed from the same repo).
  3. The Pages project is reachable at https://<project>.pages.dev (or a custom domain).

7.2 Allow the Pages origin on the Worker

Because the SPA is now on a different origin than the Worker, the same-origin auto-derive (§5.1 mode 2) will reject the browser's WS upgrade. Set WEB_ORIGINS on the Worker to the Pages origin:

# apps/cf-worker/wrangler.toml
[vars]
WEB_ORIGINS = "https://<project>.pages.dev"

The SPA config's startupOptions.server still resolves to the Worker's hostname (where the WS endpoint lives), not the Pages hostname — so set it to a literal if you've pinned it, or keep {{hostname}} only if Pages and the Worker share a domain.


8. Optional: webircgateway SockJS fallback

ServerlessIRCd already speaks the wire format browser Kiwi expects: native WebSocket text frames, one IRC message per frame, with \r\n-joined-frame tolerance. The Worker's WS endpoint at / is all Kiwi needs. You do not need webircgateway for normal operation.

When is it useful? Corporate proxies and some mobile carriers block raw wss:// (or upgrade it in ways that break long-lived frames). webircgateway provides a SockJS + Kiwi multi-server transport that falls back to HTTP polling when raw WS is unavailable. If your users report "can't connect from the office Wi-Fi", that's the trigger.

The gateway is vendored separately (see the webircgateway/ submodule if present in your checkout). Running it as a sidecar is a deployment concern outside this doc's scope; the key point is that it is strictly opt-in. The default SPA build does not reference it, and no kiwiServer / gateway URL is baked into the config.

To wire it up when needed, point Kiwi's startupOptions.kiwiServer at the gateway and set startupOptions.direct: false — the opposite of the ServerlessIRCd default. Document this only for the affected deployment; don't change the default config.


9. Customising the landing page

The landing page (apps/web/landing/index.html) is the project's front door: a visitor hitting / learns what ServerlessIRCd is and clicks through to /webclient/ to launch the client. It is intentionally framework-free (pure HTML + inline CSS, no JS) so it renders without JavaScript and costs nothing to serve.

To customise:

Re-run pnpm --filter web build (any --env) after edits; the build test suite will fail loudly if you accidentally remove the /webclient/ link, the docs link, or the viewport tag.


10. Troubleshooting

10.1 Kiwi upstream missing at .../upstream

The submodule wasn't initialised. Run:

git submodule update --init apps/web/upstream

The build fails fast with this hint rather than a confusing webpack error.

10.2 SPA loads but never connects ("connection refused" / spinning)

Almost always a config issue. Open the browser devtools Network tab and check the WS upgrade:

10.3 Asset 404s at /static/... (instead of /webclient/static/...)

The publicPath patch in step 3 of the build didn't take. Confirm upstream/vue.config.js still contains a publicPath: '' literal to patch against (the build restores it after building). If upstream changed the phrasing, the patch regex in scripts/build.mjs needs an update.

10.4 200 OK instead of 101 Switching Protocols on WS upgrade

The asset platform served the landing page for the upgrade request. This means run_worker_first = true is missing or false in the [assets] block of wrangler.toml. The WS endpoint and the landing page share path /, so the Worker MUST run first for everything and fall through to assets only for non-WS requests.

10.5 Cannot find module '@serverless-ircd/cf-adapter'

The workspace packages weren't built. Run pnpm build from the repo root before pnpm --filter web build or wrangler dev — the build script and the Worker entry import compiled dist/ from the workspace packages.

10.6 Build-smoke tests fail in CI but pass locally

The dist/-dependent assertions in tests/build-smoke.test.ts are gated on existsSync(dist/webclient/index.html) and are skipped when the SPA hasn't been built. CI runs pnpm --filter web build (or pnpm build) before pnpm --filter web test, so the assertions fire there. If you run pnpm --filter web test locally without building, only the landing-page source assertions run — that's expected, not a regression.


11. Quick reference

# One-time / per-clone
corepack enable
git submodule update --init          # docs + apps/web/upstream (Kiwi)
pnpm install

# Build the SPA + landing page (pick the env)
pnpm --filter web build              # default config (dev target)
pnpm --filter web build:prod         # static/config.prod.json

# Local dev (Worker serves SPA + landing page + WS on one origin)
pnpm build                           # build the rest of the workspace
pnpm --filter @serverless-ircd/cf-worker dev   # http://localhost:8787
node apps/cf-worker/scripts/smoke.mjs          # WS smoke

# Deploy (Worker + assets in one command)
pnpm deploy:cf                       # wrangler deploy

# Tests / lint
pnpm --filter web test
pnpm --filter web typecheck
pnpm lint

Key files:

Path What
apps/web/scripts/build.mjs Orchestrates the upstream Kiwi build + layers our config.
apps/web/src/config-schema.ts Zod schema for static/config.*.json (the only TS we own).
apps/web/src/build-env.ts --env → config-file resolution (unit-tested).
apps/web/static/config.{,prod}.json Kiwi configs (validated at build time).
apps/web/landing/index.html Static landing page (project front door), copied to dist/index.html.
apps/web/tests/build-smoke.test.ts Landing-page + build-output assertions.
apps/cf-worker/src/worker.ts Edge handler: WS → ConnectionDO, /health, else env.ASSETS.
apps/cf-worker/src/origin-allowlist.ts CSWSH Origin policy (pure, unit-tested).
apps/cf-worker/wrangler.toml [assets] binding + per-env blocks + WEB_ORIGINS var.
apps/web/upstream/ Kiwi IRC source (git submodule, tag-pinned).