npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

serverless-ircd

v0.11.0

Published

Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters

Downloads

1,364

Readme

ServerlessIRCd

A serverless IRC daemon where the IRC protocol logic lives in a pure, platform-agnostic core, and two thin adapters run it on Cloudflare Workers (Durable Objects) or AWS (API Gateway WebSockets + Lambda + DynamoDB).

One TypeScript codebase. Two serverless substrates.

Status: preview. The pure protocol core, the IrcRuntime port + in-memory runtime, a runnable local CLI server, the Cloudflare Workers adapter, and the AWS (API Gateway WebSocket + Lambda + DynamoDB + CDK) adapter are all functional and deployed to staging.

Dual transport. Both adapters speak a real irc+tls :6697 (TLS-over-TCP) surface for stock IRC clients — Cloudflare via Spectrum + a Container origin, AWS via a Network Load Balancer + Lambda streaming — alongside the WebSocket path, plus SASL EXTERNAL via mTLS and a Cloudflare D1-backed SASL account store.

Complete protocol surface. The deferred IRC verbs (KILL, REHASH, LUSERS + STATS, TRACE, WALLOPS, SETNAME) are implemented, and the S2S (CONNECT / SQUIT / LINKS) and obsolete RFC 2812 (SERVICE / SUMMON / USERS) verbs are formally dropped.

IRCv3 extensions. Negotiated caps include account-notify, msgid, standard-replies, MONITOR, labeled-response, sts, draft/typing, draft/multiline, draft/read-marker, and draft/pre-away, plus the ISUPPORT tokens for each and the read-only user mode S (TLS connected).

Web client. A vendored Kiwi IRC SPA is served at /webclient/ and a static landing page at / directly by the Cloudflare Worker (via its [assets] binding) or by AWS via an S3 + CloudFront + OAC StaticSite construct (opt-in via CDK context). One apps/web/dist/ artifact ships on both platforms unchanged. The docs/ wiki is rendered to standalone HTML at /docs/. Both platforms wire Cross-Site WebSocket Hijacking (CSWSH) defense into the WS upgrade path: the CF Worker uses same-origin auto-derive by default with an optional WEB_ORIGINS allowlist for cross-origin deploys; the AWS $connect Lambda uses an explicit WEB_ORIGINS allowlist only (the SPA and the API Gateway wss endpoint are on different origins, so auto-derive does not apply). AWS deploys are stack-output-driven (a two-phase scripts/deploy-web-aws.mjs bakes the real ConnectUrl into the SPA config, then s3 sync + CloudFront invalidation).

Deploy security. The AWS CI deploy is OIDC-only — the GitHub Actions workflow assumes an IAM role via GitHub OIDC web identity (no long-lived access keys, with an in-workflow assertion that the assumed role matches AWS_DEPLOY_ROLE_ARN before any cdk deploy). API Gateway full-frame body logging (DataTraceEnabled) is off by default and hard-locked, so IRC frames (PASS, AUTHENTICATE <SASL-PLAIN>, channel keys, PRIVMSG/NOTICE) are never written to CloudWatch; the only way back on is an explicit two-flag sandbox escape hatch. The CF deploy workflow refuses placeholder hostnames (irc.example.com / irc.your-domain.invalid), and a CI env-var drift guard keeps the consumed-env-vars block of apps/cf-worker/wrangler.toml exactly in sync with the config loader.

Abuse controls & hardening. Layered admission control on both platforms: a global MAX_CLIENTS cap (Cloudflare reserves a CounterDO slot per upgrade; AWS keeps an atomic connection counter — over-cap connects get 429), per-IP simultaneous-connection caps and sliding-window connect-rate limits (a RateLimitDO at the CF edge; a source-IP GSI count + APIGW stage throttling + an opt-in WAFv2 per-IP rate rule on AWS), and a per-connection inbound frame-rate window at every adapter boundary. Credential handling is hardened end-to-end: scrypt-hashed oper credentials, constant-time server-password comparison, timing-equalized nick verification (no account enumeration), SASL/OPER/IDENTIFY brute-force lockouts, and credential redaction from parse-error logs. SASL EXTERNAL is operator opt-in (EXTERNAL_ENABLED, default off), gated on a bound mTLS identity source and a TLS transport, and binds accounts to the client cert's DER SHA-256 fingerprint (fp:<hex>, with a canonical-DN fallback).

Integrated IRC services. NickServ, ChanServ, HostServ, OperServ, and MemoServ run inside the daemon (no separate services process, no S2S link) backed by a ServicesStore port with persistent D1 (Cloudflare) and DynamoDB (AWS) backends (write-behind, surviving redeploys). The ServicesStore is the single credential home: SASL PLAIN, SASL EXTERNAL (CertFP), NickServ IDENTIFY, and PASS <nick>:<password> all verify through the same scrypt-hashed verifyNick / verifyCertFP surface, and draft/read-marker / draft/pre-away persist through the ServicesStore.

See CHANGELOG.md for the per-release manifests.


Why

IRC servers have historically been long-running stateful processes. This project moves the protocol logic into a set of pure reducers so that the only platform-specific code is the side-effect layer (transports, registries, fanout). The consequences:

  • Every command handler is a trivial unit test: arrange state, apply a message, assert the new state and emitted effects.
  • The same core runs unchanged on Cloudflare and AWS.
  • Authoritative state lives in exactly one place per piece of data, so races are structurally impossible rather than defended against.

Architecture

Hexagonal / ports-and-adapters. The core implements the IRC protocol; adapters handle I/O.

┌─────────────────────────────────────────────────────────────────┐
│  packages/irc-core         (pure TS, no cloud deps)             │
│    protocol/  parse · serialize · numerics · messages           │
│    commands/  pure reducers: (state, msg) → { state, effects }  │
│    state/     channel · connection · mode · registry shapes     │
└─────────────────────────────────────────────────────────────────┘
                               ▲ implements IrcRuntime (port)
                               │
┌─────────────────────────────────────────────────────────────────┐
│  packages/irc-server      (orchestration, defines the port)     │
│    IrcRuntime   side-effect interface (transport + state IO)    │
│    dispatch     interprets Effect[] against a bound runtime     │
└─────────────────────────────────────────────────────────────────┘
            ▲                                   ▲
┌────────────────────────────┐      ┌──────────────────────────────┐
│  packages/cf-adapter       │      │  packages/aws-adapter        │
│    ConnectionDO            │      │    $connect/$disconnect/     │
│    ChannelDO               │      │    $default Lambda handlers  │
│    ChannelRegistryDO       │      │    DynamoDB tables           │
│    RegistryDO              │      │    AwsRuntime                │
│    CfRuntime               │      │                              │
└────────────────────────────┘      └──────────────────────────────┘

packages/in-memory-runtime is the reference implementation of IrcRuntime, used by integration tests and the local CLI server.

Two transports, one core. Both adapters accept WebSocket text frames (the serverless default) and a raw irc+tls :6697 (TLS-over-TCP) surface for stock IRC clients. The TCP+TLS edge is platform-specific — Cloudflare Spectrum fronts a stateful Container origin (apps/cf-tcp-container) with proxy_protocol = "v1" and tls_mode = "full": the container itself terminates TLS (pinned to 1.2/1.3), requires and parses a PROXY v1 header on every flow to recover the real client IP, and runs as a non-root user. AWS terminates TLS at a Network Load Balancer — the streaming handler asserts the flow is TLS-secured (surfacing user mode S), rejects missing or malformed flow headers, and enforces the same per-IP connect-rate budget — and invokes a Lambda streaming function. Both feed the same ConnectionActor through a Transport seam (WsTextFrameTransport vs. TcpByteStreamTransport); the parser/reducer/dispatch pipeline is identical.

Design: pure reducers + location-of-authority

Each command handler is a pure function:

type Reducer<S> = (state: S, msg: IrcMessage, ctx: Ctx) => {
  state: S;
  effects: Effect[];
};

Side effects are values (Effect[]), not performed in the handler. The actor layer's dispatch(effects, runtime) interprets each effect against the bound runtime.

Each reducer runs in whichever authority owns the state it mutates:

| Command family | Runs in authority | Primary state mutated | |-----------------------------|-------------------------|------------------------------| | Registration (NICK/USER) | Connection entity | Connection state | | PING/PONG, QUIT, AWAY | Connection entity | Connection state | | JOIN / PART / channel MODE | Channel entity | Roster, channel modes | | PRIVMSG/NOTICE to channel | Channel entity | Fanout (reads roster) | | Nick collision check | Registry entity | Nick → Connection map | | NAMES / WHO / WHOIS | Reads snapshots | (no mutation) | | Services (PRIVMSG NickServ/ChanServ/…) | Connection entity | ServicesStore (account/channel/vhost/memo state) |


Repository layout

ServerlessIRCd/
├── packages/
│   ├── irc-core/          pure protocol + reducers
│   ├── irc-server/        orchestration, IrcRuntime port, dispatch
│   ├── in-memory-runtime/ reference runtime (used by tests + local CLI)
│   ├── irc-test-support/  parametrized IRC scenario suite + harness seam
│   ├── cf-adapter/        Durable Objects + CfRuntime
│   └── aws-adapter/       Lambda + DynamoDB + AwsRuntime
├── apps/
│   ├── cf-worker/         worker entry, DO migrations, bindings, [assets] + CSWSH gate
│   ├── cf-tcp-container/  Spectrum + Container origin for irc+tls :6697
│   ├── aws-stack/         CDK stack (APIGW WS + NLB + Lambda streaming + DynamoDB)
│   ├── web/               vendored Kiwi IRC SPA (→ /webclient/) + static landing page (→ /)
│   └── local-cli/         runnable WS + TCP server using in-memory-runtime
├── tools/
│   ├── tcp-ws-forwarder/  local TCP↔ws/wss bridge for stock IRC clients
│   ├── load-test/         synthetic WebSocket IRC client pool (10k conns, p50/p95/p99, drop rate)
│   ├── ci-hardening/      coverage-gate + mutation-config + env-var-drift validators
│   ├── hash-oper-cred.ts  scrypt oper-credential generator (OPER_SALT + OPER_HASH)
│   ├── seed-aws-accounts.ts  scrypt-hash SASL PLAIN accounts into DynamoDB
│   ├── seed-cf-accounts.ts   scrypt-hash SASL PLAIN accounts into Cloudflare D1
│   └── migrate-accounts-to-services.ts  one-shot AccountStore→ServicesStore credential backfill
├── scripts/
│   └── deploy-web-aws.mjs stack-output-driven AWS web client deploy (describe → bake → s3 sync → invalidate)
├── pnpm-workspace.yaml    turbo.json   tsconfig.base.json
└── README.md              CHANGELOG.md

Toolchain

| Concern | Choice | |------------------|---------------------------------------------------| | Runtime | Node ≥ 24, ES2022+ | | Package mgr | pnpm workspaces | | Build cache | turbo | | Language | TypeScript (strict, exactOptionalPropertyTypes) | | Test runner | vitest + @vitest/coverage-v8 | | Property tests | fast-check | | Mutation tests | Stryker (≥80% score gate on irc-core) | | AWS IaC | AWS CDK v2 | | Lint / format | Biome |


Getting started

Requires Node ≥ 24 and pnpm 11. Enable corepack so the packageManager pin is resolved automatically:

corepack enable            # lets the pinned pnpm@11 run on any Node ≥ 24

git submodule update --init   # docs + apps/web Kiwi upstream sources
pnpm install              # install workspace deps

pnpm build                # build all packages (turbo)
pnpm typecheck            # tsc --noEmit across the workspace
pnpm test                 # run all tests once
pnpm test:watch           # vitest watch mode
pnpm coverage             # tests + v8 coverage report (enforces thresholds)

pnpm lint                 # biome check .
pnpm lint:fix             # biome check --write .
pnpm format               # biome format --write .

pnpm mutation             # Stryker spot-check on irc-core (protocol + commands)

pnpm clean                # remove dist/coverage/.turbo + node_modules

The web client (apps/web) vendors Kiwi IRC as a git submodule. Building it needs the upstream sources (git submodule update --init apps/web/upstream) and yarn (Kiwi ships a yarn.lock; corepack enable provides it):

pnpm --filter web build   # builds the Kiwi SPA into apps/web/dist/webclient/ (/webclient/)

Coverage reports are written to packages/*/coverage/. CI (.github/workflows/ci.yml) runs lint, typecheck, the coverage gate, the parametrized contract suite, and a Stryker mutation spot-check on every push and pull request. Coverage thresholds enforce 100% on irc-core / irc-server / in-memory-runtime / ci-hardening and ≥90% on every other package.


Running the local server

apps/local-cli is a runnable IRC server built on the in-memory runtime. It's the manual-test harness and the e2e fixture target — the same irc-core reducers and ConnectionActor the cloud adapters use, just wired to plain localhost listeners. By default it binds two listeners that share one runtime, so a TCP client and a WS client can see each other (cross-transport channel broadcast):

  • a WebSocket listener (the serverless transport) on --port, and
  • an RFC-style TCP listener on --tcp-port (default <port> + 1) so real IRC clients (WeeChat, HexChat, irssi, …) can connect directly.

First build the workspace (the CLI runs from compiled dist/):

pnpm build

Then start the server (from the repo root):

pnpm --filter local-cli start
# or with explicit args:
pnpm --filter local-cli start --port 6667 --host 127.0.0.1

On startup it logs a JSON line like:

{"ts":"...","level":"info","msg":"local-cli listening","wsUrl":"ws://127.0.0.1:6667/","port":6667,"tcpPort":6668,"hostname":"127.0.0.1"}

CLI flags

| Flag | Default | Description | |--------------------|---------------|--------------------------------------------------------| | --port <n> | 6667 | WebSocket port to bind. Use 0 for an ephemeral. | | --host <h> | 127.0.0.1 | Hostname / interface to bind. | | --tcp-port <n> | <port> + 1 | TCP port for RFC-style IRC clients. | | --no-tcp | | Disable the TCP listener (WebSocket only). | | --motd-file <p> | built-in | Read MOTD lines from this file (one per line). | | --server-name <h>| irc.localhost | Server hostname advertised in 001/005. Production MUST override. | | --network-name <n>| LocalNet | Network name advertised in 005 NETWORK=…. | | --server-password <p>| | Server-password gate. When set, every connection must supply PASS <p> (or SASL-identify) before 001. Treat as a secret. | | -h, --help | | Show help and exit. |

Pass --host 0.0.0.0 to expose the server on all interfaces. SIGINT / SIGTERM perform a graceful shutdown (closes active sockets, then exits).

Server password (SERVER_PASSWORD / --server-password)

All three adapters (Cloudflare Worker, AWS Lambda, local CLI) support an optional server-password gate. When the knob is set, every connection must supply the matching PASS <value> before 001 RPL_WELCOME is emitted; when unset or empty the gate is disabled (the default).

| Adapter | Knob | |----------------|-------------------------------------------------------------------------------| | Cloudflare | wrangler secret put SERVER_PASSWORD (Workers secret; never a [vars] entry). | | AWS | SERVER_PASSWORD Lambda env var (sourced from Secrets Manager / SSM). | | local CLI | --server-password <p> flag, or serverPassword on StartServerOptions. |

On a mismatched/missing PASS the server emits 464 ERR_PASSWDMISMATCH and disconnects with reason Bad Password. SASL short-circuit: a connection that has authenticated an account via SASL (AUTHENTICATE PLAIN / EXTERNAL) is exempt — the reducer's gate treats state.account !== undefined as already authorised, so a deployment with both SERVER_PASSWORD and configured SASL accounts does not need to hand the shared password to identified users. The server-wide gate is a shared deployment secret (never log it); per-user credentials still go through the unified services credential store (ServicesStore.verifyNick — D1 nickserv_accounts / DynamoDB Services), which is unaffected.

PASS-based account login: in addition to SASL, a client may identify to its NickServ account by sending PASS <nick>:<password> (the same <nick>:<password> literal the SASL_ACCOUNTS seed tooling uses). When a ServicesStore is configured, NICK alice + PASS alice:hunter2

  • USER … verifies the credentials via services.verifyNick and, on success, logs the connection in as alice — emitting 900 RPL_LOGGEDIN
  • 903 RPL_SASLSUCCESS (the same "account is set" numerics SASL uses) before 001 RPL_WELCOME, stamping user mode +r, and running the same read-marker / away / memo replay as a SASL login. The login is also honoured when PASS <nick>:<password> arrives after registration has completed (e.g. a client that sent NICK+USER before PASS, or any PASS arriving after CAP END resolved): the same verify + applyAccountSuccess pipeline runs, emitting 900/903 and stamping +r in-band. The nick left of the : must match the connection's current nick; a bare PASS <value> with no colon is always treated as a server-password candidate (silent no-op once registration has completed), never as account credentials. A wrong password or unknown nick is indistinguishable (no 904/464 from the auth path) and the connection simply proceeds un-identified when no server password is set. This composes with the gate above by precedence: an already-identified connection (SASL or PASS-auth) satisfies the gate; a <nick>:<password> that fails verify never matches a bare shared secret, so a server with both configured still rejects it with 464.

Connecting

The WebSocket listener speaks WebSocket text frames (one IRC message per frame) — point any WebSocket-capable IRC client or script at ws://127.0.0.1:6667/. The TCP listener speaks plain RFC IRC over the wire, so stock clients connect directly, e.g. in WeeChat:

/connect 127.0.0.1/6668

No tcp-ws-forwarder is needed for local development — both transports are built in. The MOTD is the built-in default unless --motd-file is given.


Connecting a TCP IRC client to a deployed stack (TCP→WS forwarder)

The local CLI ships a built-in TCP listener, so no bridge is needed for local development. Deployed stacks (Cloudflare Workers and AWS API Gateway) are WebSocket-only, so a stock TCP IRC client (WeeChat, HexChat, irssi, …) cannot connect to them directly. tools/tcp-ws-forwarder is a local bridge for that case: it listens on a TCP port and, for each connection, opens one WebSocket to a ws:// / wss:// target and forwards IRC lines both directions — reassembling the TCP byte stream into one message per WS frame outbound, and normalizing inbound frames back to canonical CRLF.

Build the workspace, then start the forwarder (from the repo root):

pnpm build

# Bridge localhost:16667 → the local WS server started above:
pnpm --filter tcp-ws-forwarder start \
  --target ws://127.0.0.1:6667/ --listen-port 16667

# Or bridge straight to a deployed stack:
pnpm --filter tcp-ws-forwarder start \
  --target wss://irc.example.com/ --listen-port 16667

On startup it logs a JSON line like:

{"ts":"...","level":"info","msg":"tcp-ws-forwarder listening","listen":"127.0.0.1:16667","target":"ws://127.0.0.1:6667/"}

CLI flags

| Flag | Default | Description | |---------------------|-------------|--------------------------------------------------| | --target <url> | (required) | Upstream ws:// or wss:// URL to bridge to. | | --listen-port <n> | 6667 | TCP port to listen on. Use 0 for an ephemeral. | | --listen-host <h> | 127.0.0.1 | Interface to bind. | | -h, --help | | Show help and exit. |

Connecting

Point any TCP IRC client at 127.0.0.1:<listen-port> and it will appear as a direct connection to the WebSocket endpoint. SIGINT / SIGTERM perform a graceful shutdown (closes the listener and every live bridge). The forwarder is transport-agnostic — it ships no @serverless-ircd/* dependency and works against any line-oriented WebSocket endpoint.


Load testing (tools/load-test)

A synthetic WebSocket IRC client pool. It opens N connections to a ws:// / wss:// target, registers each (NICK/USER), joins a channel, and optionally chats. Per-stage p50 / p95 / p99 latency (connect / register / join / message) and the drop rate are captured, then printed as a markdown report to stdout (and written to --report <path> when given).

Build the workspace, then start a run (from the repo root):

pnpm build

pnpm --filter load-test start -- \
  --target wss://irc.staging.example.com/ \
  --connections 10000 --concurrency 200 --ramp-ms 60000 \
  --channel '#loadtest' --messages 3 --platform cf-staging \
  --report reports/cf-staging-2026-08-03.md

The harness drives the WebSocket transport only (the serverless default); a TCP IRC client is not what a serverless load test exercises. Each connection is independent — the package ships no @serverless-ircd/* runtime dependency (only the IRC line framing shared with the forwarder), so it can be pointed at any WebSocket IRC endpoint.

CLI flags

| Flag | Default | Description | |-------------------------|-------------|----------------------------------------------------------| | --target <url> | (required) | Upstream ws:// or wss:// URL to load test. | | --connections <n> | 1000 | Total connections to open. | | --concurrency <n> | 50 | Concurrent in-flight connection attempts during ramp-up. | | --ramp-ms <ms> | 30000 | Ramp-up duration over which connections are opened. | | --channel <c> | #loadtest | Channel every client JOINs. | | --messages <n> | 0 | PRIVMSGs each client sends after JOIN (0 = connect only). | | --echo-message | | Negotiate echo-message so sent PRIVMSGs round-trip. | | --platform <name> | unknown | Label baked into the report header. | | --report <path> | | Write the markdown report to this path. | | -h, --help | | Show help and exit. |

SIGINT / SIGTERM cancel an in-flight run gracefully (drains live sockets, prints the partial report). The package ships its own vitest suite at ≥90% coverage (config parsing, framing, harness, metrics, report).


Web client (apps/web)

A vendored Kiwi IRC SPA served at /webclient/ and a static project landing page served at /, both baked out of apps/web/dist/. The same artifact is served by both platforms:

| Platform | HTTP/SPA origin | WebSocket origin | Same-origin? | |-------------|----------------------------------------------------|---------------------------------------------------|--------------| | Cloudflare | the Worker (*.workers.dev / custom domain) | the same Worker | Yes — one domain serves both | | AWS | CloudFront (*.cloudfront.net / custom domain) | API Gateway (*.execute-api.*.amazonaws.com) | No — two different endpoints |

On Cloudflare the Worker's [assets] binding serves the SPA and the WS upgrade on one origin — the browser opens a native wss:// directly to the Worker, no proxy or gateway. On AWS the SPA is fronted by an S3 + CloudFront + OAC StaticSite construct (apps/aws-stack/src/static-site.ts, opt-in via CDK context) and a two-phase deploy bakes the real ConnectUrl into the SPA config (see "Deploying on AWS" below). See docs/WebClientGuide.md for the CF end-to-end guide and docs/AWS-Deployment.md §16 for the AWS path.

Build the SPA + landing page (needs the submodule + yarn, provided by corepack enable):

git submodule update --init apps/web/upstream   # one-time per clone
git submodule update --init docs                # one-time per clone (docs site)
pnpm --filter web build                          # or build:prod (CF) / build:prod-aws (AWS)
# → apps/web/dist/index.html        (landing page, served at /)
# → apps/web/dist/webclient/index.html    (Kiwi SPA, served at /webclient/)
# → apps/web/dist/webclient/static/config.json  (baked, env-specific)
# → apps/web/dist/docs/<slug>.html      (rendered docs/, served at /docs/)

The build also renders the docs/ submodule (the Gitea wiki, 23 GFM markdown files) to standalone HTML under apps/web/dist/docs/ so the existing [assets] binding serves them at /docs/<slug>.html (and /docs/ for the index — Home.md becomes index.html). Requires the docs/ submodule checkout; the build fails loudly with the recovery command if it is missing or empty (mirroring the Kiwi upstream/ guard). Home.md is the docs landing page; ADR-Index.md lists every ADR.

Deploying on Cloudflare

Run everything locally (Worker serves SPA + landing page + WS on one origin):

pnpm build                                     # workspace packages
pnpm --filter @serverless-ircd/cf-worker dev   # http://localhost:8787
#   /        → landing page
#   /webclient/    → Kiwi SPA (opens ws://localhost:8787/)
#   /docs/   → rendered docs/ (Home → index, per-page HTML)
#   /health  → plaintext liveness

Deploy the Worker (Worker + assets in one command):

pnpm deploy:cf   # wrangler deploy

Configuration vars & secrets (Cloudflare)

Every env var the Worker consumes is enumerated — and drift-guarded in CI — in the consumed-env-vars block of apps/cf-worker/wrangler.toml, classified as a plaintext [vars] knob or a [secret]. Credential material must be set as Workers secrets, never [vars]:

| Secret | Purpose | |--------|---------| | SERVER_PASSWORD | server-wide PASS gate (see above) | | OPER_PASSWORD | legacy plaintext oper credential | | OPER_SALT + OPER_HASH | hashed oper credential — generate with node --import tsx tools/hash-oper-cred.ts --user admin --stdin | | SASL_ACCOUNTS | newline-delimited user:password SASL seed list (see tools/seed-cf-accounts.ts) |

Set each with wrangler secret put <NAME>. Everything else (SERVER_NAME, MAX_CLIENTS, MAX_CONNECTIONS_PER_IP, PER_IP_CONNECTION_RATE_*, MAX_FRAMES_PER_WINDOW, FRAME_WINDOW_SECONDS, EXTERNAL_ENABLED, …) is a non-sensitive [vars] knob.

Deploying on AWS

The web client is opt-in: provision the StaticSite construct by passing webSite* CDK context on the stack deploy, and (once the SPA ships) set webOrigins for the CSWSH defence:

pnpm deploy:aws -- \
  -c webSiteCustomDomain=app.example.com \
  -c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
  -c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
  -c webOrigins=https://app.example.com

Then bake + ship the SPA (stack-output-driven — reads ConnectUrl, WebsiteBucketName, WebsiteDistributionId from the stack outputs, bakes the real wss URL into config.json, s3 syncs the build, and invalidates the CloudFront edge cache):

node scripts/deploy-web-aws.mjs

The ACM certificate must be in us-east-1 (CloudFront requirement). See docs/AWS-Deployment.md §16 for the full two-phase flow, the server / direct_path config split (irc-framework prepends wss:// itself, so the host is baked scheme-less), and the custom-domain setup.

WebSocket Origin policy (CSWSH defense)

WebSocket upgrades do not follow the same-origin policy, so a malicious page can open a WebSocket to the IRC server from a victim's browser and drive the session with their credentials (Cross-Site WebSocket Hijacking, CSWSH). Both adapters enforce an Origin policy on the upgrade — but the modes differ, because the CF Worker serves the SPA and the wss endpoint on the same origin while AWS serves them on different origins:

Cloudflare — see apps/cf-worker/src/origin-allowlist.ts and the SPA guide §5. Two modes, evaluated in order:

  1. Explicit allowlist — set WEB_ORIGINS (comma-separated) for cross-origin deploys (SPA on a different domain than the Worker, e.g. Cloudflare Pages). Only listed origins proceed.
  2. Same-origin auto-derive (the default) — when WEB_ORIGINS is unset/empty, the Worker compares the browser's Origin against the request's own origin. Match → proceed; mismatch → 403 Forbidden.

WEB_ORIGINS is optional on CF — same-origin auto-derive needs zero per-env config and works for *.workers.dev, custom domains, and preview URLs alike.

AWS — see packages/aws-adapter/src/origin-allowlist.ts and docs/AWS-Deployment.md §8.2. Explicit allowlist only (no auto-derive): the SPA is on a CloudFront origin and the wss endpoint is on an API Gateway origin, so the request's own host is never the SPA's origin. The defence is opt-in — unset WEB_ORIGINS skips the check entirely (existing bare-IRC deployments without a web frontend are unchanged on upgrade); set it to the SPA's origin(s) once the web client ships:

pnpm deploy:aws -- -c webOrigins=https://app.example.com
# or as a stack prop: webOrigins: 'https://app.example.com,https://staging.app.example.com'

Non-browser clients (curl, WeeChat, the tcp-ws-forwarder, scripted harnesses) never send Origin and pass through unchanged on both platforms.


WebSocket transport (IRCv3 subprotocols)

The WebSocket entry points (the local CLI, the Cloudflare Worker, and the AWS API Gateway $connect route) implement the IRCv3 WebSocket support subprotocol negotiation. A client advertises support by offering one of two registered subprotocols in its opening handshake's Sec-WebSocket-Protocol header:

| Subprotocol | Frames | Notes | |--------------------|----------|--------------------------------------------------| | text.ircv3.net | UTF-8 text | Lone surrogates → U+FFFD; binary frames rejected (close 1003). | | binary.ircv3.net | binary | UTF-8 encoded bytes; same framing as text. |

The server echoes back the first supported entry in client-preference order (in the 101 response's Sec-WebSocket-Protocol header). Once negotiated, the connection uses spec framing:

  • One IRC message per WebSocket message — a frame is never split on an embedded line break. A single optional trailing CR-LF (or bare LF) is stripped; the remainder is exactly one IRC line.
  • No trailing CR-LF on the wire, outbound or inbound.
  • 510-byte message budget — the 512-byte IRC line limit minus the omitted CR-LF. A message exceeding 510 bytes is a protocol violation and the connection is closed with RFC 6455 code 1009 (Message Too Big).

Legacy fallback. A client that offers no recognized subprotocol (or none at all) is still accepted, in legacy mode: frames are split on \r\n so older clients that concatenate several messages into one frame keep working, and the parser's existing 512-byte line cap applies. Nothing breaks until a client opts into a subprotocol. The in-tree tcp-ws-forwarder offers binary.ircv3.net upstream, so a stock TCP IRC client bridged to a deployed stack exercises the spec path end-to-end.


Abuse controls & credential hardening

Every transport edge enforces layered admission and rate limits, and every credential path verifies through hardened, timing-equalized comparisons. The knobs below are [vars] on the Cloudflare Worker and mirrored as Lambda env vars on AWS.

Connection admission & rate limiting

| Layer | Knobs | Enforcement | |------------------------|---------------------------------------------------|-------------| | Global cap | MAX_CLIENTS | CF reserves a CounterDO slot before each upgrade and answers 429 (ERROR :Closing link: server full) at the cap (slots released on close, TTL-reaped if a DO dies); AWS keeps an atomic connection counter in DynamoDB. | | Per-IP simultaneous | MAX_CONNECTIONS_PER_IP | Admission gates on both platforms reject over-budget source IPs with 429. | | Per-IP connect rate | PER_IP_CONNECTION_RATE_MAX / PER_IP_CONNECTION_RATE_WINDOW_MS | CF: a RateLimitDO sliding window keyed on CF-Connecting-IP, checked at the worker edge (rejections never consume budget, so a blocked IP recovers after window decay). AWS: $connect counts in-window establishments via a sourceIp+connectedSince GSI, the NLB path applies the same budget to new flows, APIGW stage throttling backstops globally, and an opt-in WAFv2 per-IP rate rule (-c wafConnectRateLimit=<n>) sits at the edge. | | Per-connection frames | MAX_FRAMES_PER_WINDOW / FRAME_WINDOW_SECONDS | Inbound frame budget enforced at the adapter boundary before the actor / storage write. | | Line-buffer memory | (fixed) | TCP input buffers capped at 8 KiB at every transport edge (local CLI, container origin, NLB handler, forwarder). |

Protocol budgets

All length limits are enforced in UTF-8 bytes, not UTF-16 code units: the 510-byte WS frame / 512-byte TCP line budgets, TOPICLEN enforced on character boundaries, draft/multiline batch byte budgets enforced incrementally as lines arrive, at most 15 tags and an 8192-byte tag section per message, long NAMES rosters split across multiple 353 replies within the 510-byte budget, CHATHISTORY limits capped at a configurable ceiling (default 100), and MAX_TARGETS_PER_COMMAND capping comma-split targets. The parser rejects bare-CR smuggling and NUL/control characters in channel names, builds tag maps with a null prototype (blocking prototype pollution), and OPER requires a registered connection (451 ERR_NOTREGISTERED).

Credentials & anti-abuse

Oper credentials verify against scrypt hashes (OPER_USER + OPER_SALT/OPER_HASH, generated with tools/hash-oper-cred.ts); the server password compares in constant time; verifyNick runs a dummy scrypt verify on unknown nicks so response timings cannot enumerate accounts. Brute force is throttled at every auth surface: a per-connection SASL failure lockout, a per-IP OPER failure lockout, and a per-account NickServ IDENTIFY freeze. HostServ auto-approve honours a vhost denylist/allowlist, and ChanServ DEOP/KICK protect founders and enforce caller rank. Parse-error logs carry only token/length/reason — PASS / AUTHENTICATE payloads are redacted — and the Worker's log sampling defaults to 10%.

SASL EXTERNAL (mTLS)

EXTERNAL is advertised and accepted only when all of the following hold: an mTLS identity source is bound for the connection (CF API Shield / AWS API Gateway client certs), the operator opt-in EXTERNAL_ENABLED is set (default off), and the transport is TLS-secured. Otherwise the sasl cap and 908 ERR_SASLMECHS list PLAIN only — refusals do not count toward the SASL lockout. Accounts bind to the client cert's DER SHA-256 fingerprint (fp:<hex> entries — what Cloudflare surfaces from request.cf.tlsClientAuth; the preferred binding) or a canonical subject DN (the only identifier API Gateway exposes; DNs are canonicalised — types lower-cased, whitespace collapsed, RDN/AVA order sorted — so re-issued or differently-ordered certificates still match). Transports without any client-cert surface — the Spectrum container origin, and the NLB stream handler unless an mTLS provider is injected — pin the mechanism off and reject AUTHENTICATE EXTERNAL with a transport-specific 904.


Testing strategy

This project follows strict TDD (Red → Green → Refactor) — every reducer is landed test-first. See the project-level CLAUDE.md / AGENTS.md for the full rules.

| Layer | Tooling | What it asserts | |--------------------|---------------------------------------------|---------------------------------------| | Parser/serializer | vitest + fast-check | Grammar correctness, round-trip | | Reducers (core) | vitest pure unit tests | (state,msg) → {state,effects} exact | | Mutation (core) | Stryker spot-check (≥80% score gate) | Reducer test quality, surviving mutants | | Runtime contract | vitest parametrized via irc-test-support | Same scenarios pass in-memory+CF+AWS | | CF adapter | vitest-pool-workers (real workerd) | DO behavior, alarms, stub fanout | | AWS adapter | vitest + testcontainers (DynamoDB Local) | DynamoDB schema, transactions, fanout | | E2E | scripts/smoke.mjs against deployed stack | CONNECT→REGISTER→JOIN→PRIVMSG→QUIT |

Determinism: reducers never touch real timers or randomness. A Clock port and an IdFactory port are injected so tests are fully deterministic.


Roadmap

A v1 ships when the parametrized contract suite passes against every runtime, the client compatibility matrix is signed off, and every package clears its coverage gate. Per-release manifests live in CHANGELOG.md.

Active follow-ups:

  • Client compatibility sweep — formal sign-off on ≥3 reference clients (WeeChat / HexChat / IRCCloud / TheLounge) against both deployed adapters, on both the WebSocket and irc+tls :6697 surfaces.
  • Web client e2e — Playwright headless-browser e2e exercising the vendored Kiwi IRC SPA against a deployed stack.
  • Coverage hardeningirc-core, irc-server, in-memory-runtime, local-cli, cf-worker, aws-stack, load-test, cf-tcp-container, tcp-ws-forwarder, irc-test-support, and ci-hardening sit at 100% line coverage; cf-adapter (~99.7%) and aws-adapter (~98%) clear the 90% gate with follow-ups driving each to full coverage.
  • Protocol follow-upsWHOX (WHO <mask> %<fields> / 354 RPL_WHOSPCRPL), LIST search masks + ELIST=MNTU filters, cap-notify capability-change push, services data lifecycle (last-used tracking + expiry sweep), and a persisted oper audit trail.
  • Persistent ChanServ ban list — ban masks currently live on ChannelState.banMasks and do not survive an empty-recreate of a channel. Extending ServicesStore with a persistent ban list is the documented next step.

Protocol scope

Core (RFC 1459/2812 subset): registration (NICK/USER/CAP/PASS), PING/PONG, QUIT, JOIN, PART, PRIVMSG, NOTICE, MODE (user + channel), TOPIC, KICK, INVITE, NAMES, LIST, WHO, WHOIS, WHOWAS, MOTD, AWAY, OPER (credential auth → o user mode), the query verbs VERSION, TIME, ADMIN, INFO, USERHOST, ISON, the modern-online-presence verb MONITOR (+/-/C/L/S with the 730/731/732/734 numerics and async online/offline push), the network-statistics verbs LUSERS and STATS (u uptime + l link-info letters; unknown letters get the charybdis-style empty body), the routing-diagnostic verb TRACE (oper-gated per-connection detail), the realname-change verb SETNAME, and the oper verbs KILL (force- disconnect), REHASH (config reload via IrcRuntime.reloadConfig()), WALLOPS (global broadcast to every +w user).

(Out of scope: the S2S verbs CONNECT/SQUIT/LINKS and the obsolete RFC 2812 verbs SERVICE/SUMMON/USERS are formally dropped — they return 421 ERR_UNKNOWNCOMMAND, and their reserved numerics have been removed. S2S linking is a stated non-goal.)

Channel modes: o v b i k l t n m s p, plus the services-derived modes r (registered), R (block unidentified join/message), M (moderated-identified) — settable only via ChanServ, not via MODE. User modes: i, o (local only), w, s, and the read-only S (TLS connected — set by the transport at registration, surfaced in WHOIS via 276 RPL_WHOISSECURE, not settable via MODE) and the read-only r (registered — set by NickServ IDENTIFY / SASL login, surfaced as +r, not settable via MODE).

Integrated IRC services: the service nicks NickServ, ChanServ, HostServ, OperServ, and MemoServ are reserved and routed to dedicated pure reducers when a ServicesStore is bound — there is no separate services process and no S2S link. Each service also accepts a shortcut verb (/NICKSERV, /NS, /CS, /HS, /MS, /OS, …) that the daemon rewrites to the equivalent PRIVMSG <Service> :<args> for clients with a dedicated slash-command UX. Reached via PRIVMSG <Service> :<subcommand>:

  • NickServREGISTER / IDENTIFY (alias: ID) / DROP / INFO (owner + oper only for the Email: line) / SET ENFORCE + nick enforcement on the NICK path / SET PASSWORD (self-service password change; requires the current password re-supplied). INFO <nick> with no target resolves to the caller's own account.
  • ChanServREGISTER / DROP / SET (FOUNDER / MLOCK / RESTRICTED / KEEPTOPIC) / INFO / ACCESS (SOP / AOP / HOP / VOP, each ADD / DEL / LIST) / LEVELS, plus the prefix / roster mutation verbs OP, DEOP, VOICE, DEVOICE, KICK, and BAN / UNBAN (each emits the corresponding :ChanServ MODE / KICK broadcast and ApplyChannelDelta against the channel authority). Auto-op / auto-voice on JOIN follows the access list; the first joiner of a registered channel no longer gets auto-opped merely for being first.
  • HostServON / OFF / REQUEST + oper SET / APPROVE / ACTIVATE / REJECT / LIST, with CHGHOST fanout. An assigned vhost is auto-applied on identify (SASL, NickServ IDENTIFY, or PASS-auth): state.host is set, state.vhostActive is stamped, and any currently-joined chghost-capable peers see the broadcast. Oper-only subcommands are hidden from non-opers in the help NOTICE.
  • OperServAKILL / JUPE / UNJUPE / RAW (oper-only).
  • MemoServSEND / LIST / READ / DEL with queue delivery at identify.

The ServicesStore is the single credential home: SASL PLAIN, SASL EXTERNAL (CertFP), NickServ IDENTIFY, and PASS <nick>:<password> all verify through the same scrypt-hashed verifyNick / verifyCertFP surface, so a registered nick is also a SASL login and vice versa. The credential env vars that seed and unlock these paths (SASL_ACCOUNTS, OPER_*, SERVER_PASSWORD) are Workers secrets on Cloudflare — the full consumed-var list lives in the drift-guarded consumed-env-vars block of apps/cf-worker/wrangler.toml (see Configuration vars & secrets). Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the local CLI / tests (all write-behind; registrations survive redeploys). When no store is bound, services commands reply 501 and the rest of the daemon is unaffected. See docs/Services.md for the full reference. IRCv3 extensions (negotiated via CAP): message-tags (incl. the TAGMSG command), server-time, account-tag, account-notify (pushes ACCOUNT on SASL login/logout), echo-message, batch, sasl (PLAIN always; EXTERNAL only under the triple gate of operator opt-in (EXTERNAL_ENABLED, default off) + a bound edge-mTLS identity source + a TLS connection — accounts bind to the client cert's DER SHA-256 fingerprint or a canonical subject DN, refusals answer 908 ERR_SASLMECHS listing PLAIN only, and the cf-tcp-container Spectrum origin rejects AUTHENTICATE EXTERNAL with a transport-specific 904; see Abuse controls & credential hardening), multi-prefix, away-notify, chghost, invite-notify, extended-join, msgid (@+msgid=<id> on every PRIVMSG/NOTICE/TAGMSG, shared between live and draft/chathistory replay), standard-replies (FAIL/WARN/NOTE replacements for a curated numeric subset), MONITOR=<n> (config-driven ceiling, default 30), labeled-response (+label=<id>BATCH +id labeled-response … BATCH -id wrapping), sts (Strict Transport Security — conditionally advertised with duration/port/optional preload from ServerConfig when configured, so compliant clients upgrade from plaintext to TLS and pin the secure listener), draft/chathistory, safelist, draft/typing (typing-indicator broadcast), draft/multiline=<n> (multi-line BATCH, default 4096-byte budget), draft/read-marker (per-account persisted last-read, with the timestamp-based MARKREAD <target> [timestamp] verb plus the +draft/read-marker tag fanout), and draft/pre-away (per-account persisted away reason, replayed at identify). Case-insensitive nick/channel comparison uses RFC 1459 case-mapping (advertised via 005 CASEMAPPING=rfc1459). The new MONITOR / MULTILINE / TYPING / STATUSMSG / EXTBAN / ACCOUNTEXTBAN ISUPPORT tokens are advertised from ServerConfig.

Transport: deployed stacks (Cloudflare Workers, AWS API Gateway) speak WebSocket text frames by default (one IRC message per frame, with tolerance for \r\n-joined frames), and optionally expose a raw irc+tls :6697 (TLS-over-TCP) surface — Cloudflare via Spectrum + Container, AWS via a Network Load Balancer + Lambda streaming function. The local CLI additionally binds a plain RFC TCP listener for direct IRC-client access, and the tcp-ws-forwarder bridges a stock TCP client to a deployed WebSocket endpoint.


Further reading

  • CHANGELOG.md — per-release manifests in Keep a Changelog format.
  • docs/Services.md — operator and contributor reference for the integrated IRC services: architecture (integrated vs. pseudo-client), the per-service command tables (including ChanServ OP/DEOP/ VOICE/DEVOICE/KICK/BAN/UNBAN, HostServ oper approval flow, the +r/+R/+M modes, nick enforcement, the unified scrypt account store, service shortcut verbs), adapter backends, and an end-to-end registration walkthrough.
  • docs/WebClientGuide.md — end-to-end contributor/operator doc for the Cloudflare web client: build pipeline, per-env config matrix, CSWSH rationale and the optional WEB_ORIGINS var, local dev, optional Cloudflare Pages alternative, and troubleshooting.
  • docs/AWS-Deployment.md — end-to-end AWS deploy guide: first-time setup, CDK reference, DynamoDB capacity planning, region strategy, cost notes, OIDC-only CI (§17), APIGW DataTraceEnabled hard- lock (§7.6), and the S3 + CloudFront + OAC web client (§16, including the two-phase stack-output-driven deploy and the webOrigins CSWSH knob).
  • docs/Cloudflare-TCP-Deployment.md and docs/AWS-TCP-Deployment.md — end-to-end guides for the :6697 TCP+TLS variants (Spectrum/Container on CF, NLB + Lambda streaming on AWS), including mTLS trust-store setup.
  • README.md in each packages/* and apps/* — per-package notes (e.g. packages/aws-adapter/README.md for the DynamoDB-Local test setup, apps/aws-stack/README.md for CDK commands and localstack validation).