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_ORIGINSCSWSH defence, and custom domain — seedocs/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.mjsfails 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
- Verify the submodule exists (
upstream/), or fail with the hint quoted above. yarn install --frozen-lockfileinsideupstream/(only ifupstream/node_modulesis missing).COREPACK_ROOTis 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.- Patch
upstream/vue.config.jspublicPath: ''→publicPath: '/webclient/', runyarn build, then restore the file infinallyso the submodule stays clean. This is why every hashed asset URL comes out/webclient/static/js/app.<hash>.jsinstead ofstatic/js/..., which would 404 at root. - Copy
upstream/dist/→apps/web/dist/webclient/. - Rewrite bare
static/...refs in the bakedindex.html(e.g. the favicon, which webpack doesn't run throughpublicPath) to/webclient/static/.... - Validate + bake the per-env config: read
static/config.<env>.json, runparseKiwiConfig, re-serialise (stable key order), write todist/webclient/static/config.json. - 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):
startupOptions.server—{{hostname}}(or{{host}}to keep a non-default port) is a Kiwi template resolved by the browser towindow.location.hostnameat load time. This is what lets one build artifact target prod, preview, and PR deploys without a per-env hostname. A literal hostname is also accepted.startupOptions.direct— MUST betrue. ServerlessIRCd is a direct IRC-over-WebSocket server; the Kiwi default (false) routes through akiwiServerWebIRC gateway that nothing serves here.startupOptions.tls— MUST betruein any HTTPS deployment (browsers refusews://on an HTTPS page).startupOptions.direct_path— the WS path. Kiwi readsdirect_path(NOTpath); the Worker exposes its WS upgrade at root, so this MUST be/if supplied. Optional — when omitted, irc-framework buildswss://host:port(no path), which the browser normalises towss://host:port/.startupOptions.channel— must begin with#(matchesCHANTYPES=#); comma-separated lists are accepted.
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)
- Explicit allowlist — when
WEB_ORIGINSis set, only listed origins proceed. Use this for cross-origin deployments (SPA on a different domain than the Worker). - Auto-derive (same-origin) — when
WEB_ORIGINSis unset/empty (the default), the Worker compares the browser'sOriginagainst 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:
http://localhost:8787/— landing page.http://localhost:8787/webclient/— Kiwi SPA (reads/webclient/static/config.json, opensws://localhost:8787/).http://localhost:8787/health— plaintext liveness.
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
- Push the repo (or just
apps/web/dist/) to a Pages-connected branch, or usewrangler pages deploy apps/web/dist. - Set the Pages project's output directory to
apps/web/distand the build command topnpm --filter web build:prod(pluspnpm buildif the Worker is deployed from the same repo). - 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:
- Copy/branding — edit the
<header>,<main>, and<footer>blocks directly. The CSS variables at the top of the<style>block (--bg,--surface,--accent, …) theme the whole page in one place. - Links — the page links to
/webclient/(the SPA),docs/, and the source repo. Update the repo URLs and the docs path to match your deployment. - Accessibility — keep the declared viewport meta tag and the
no-
<script>invariant; both are asserted bytests/build-smoke.test.ts. The build copies the file verbatim todist/index.html, so what you author is exactly what is served.
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:
ws://on an HTTPS page —startupOptions.tlsmust betrue(browsers refuse insecure WS from secure pages). The default configs set it; a hand-edited config may not.direct: false— Kiwi is probing akiwiServergateway that nothing serves.startupOptions.directMUST betrue.- Wrong server — if you pinned a literal
server, it must be the Worker's hostname.{{hostname}}resolves towindow.location.hostnameand is the safest default. - 403 on upgrade — the
Originwas rejected (§5). CheckWEB_ORIGINSif you're cross-origin, or confirm the SPA and Worker are same-origin.
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). |