···11## Getting started
2233-Prerequisites: [Bun](https://bun.sh) and [Node.js](https://nodejs.org) v22. Bun runs the appview and tests. Node is needed for the local ATProto PDS (`@atproto/pds` requires `better-sqlite3` which only builds against Node 22).
44-55-Use [mise](https://mise.jdx.dev) to pin Node 22: `mise use node@22`. Or nvm: `nvm use 22`.
33+Prerequisites: [Bun](https://bun.sh). Everything runs under Bun — no Node, no native addons.
6475```bash
86bun install
97bun run dev # appview on :3000 (watch mode)
1010-bun run test:unit # unit tests (no Node needed)
1111-mise run test # full suite (unit + integration, needs Node 22)
88+bun test # full test suite
129bun run lint # check with Biome
1310bun run typecheck # tsgo --noEmit
1411```
15121613The database auto-seeds with sample data on first run. To reset, delete `lichen.db` and restart.
17141818-## Full local stack
1919-2020-`bun run dev:full` starts a test PDS (Node), the appview, and the firehose subscriber. Two test accounts are created automatically. Log in via:
2121-- `http://localhost:3000/dev/login/alice.test`
2222-- `http://localhost:3000/dev/login/bob.test`
2323-2415## All commands
25162617```bash
2718bun run dev # appview server (watch mode)
2828-bun run dev:full # full local stack: PDS + appview + firehose
2919bun run dev:firehose # firehose subscriber only
3020bun run dev:viz # watch-mode viz bundle
3121bun run dev:editor # watch-mode editor bundle
3232-bun run test:unit # unit tests only (no Node needed)
3333-mise run test:integration # integration tests (Node 22 for PDS subprocess)
3434-mise run test # full suite (unit + integration)
2222+bun test # full test suite
3523bun run lint:fix # auto-fix lint/format
3624bun run typecheck # type check
3725bun run build:css # build Tailwind CSS
···5139 views/ # HTML templates
5240lexicons/ # ATProto lexicon schemas
5341public/ # static assets (editor, viz, CSS)
5454-scripts/ # dev tooling
5542tests/ # mirrors src/ structure
5643```
5744···84718572Tests mirror `src/` under `tests/`. All test files share one in-memory DB singleton (`tests/preload.ts` sets `DB_PATH=:memory:`), so tests that create data must clean up in `afterAll`.
86738787-Integration tests in `tests/integration/` write real ATProto records to a PDS subprocess and verify they flow through the firehose into the appview DB. The PDS uses `better-sqlite3` (native addon), which requires Node 22. Run them via `mise run test:integration` to ensure Node 22 is on PATH.
7474+Tests in `tests/integration/` are scenario tests that synthesize firehose commit events and invoke `handleCommitEvent` directly — no real PDS, no Node subprocess. They cover multi-step flows (revision chaining, multi-user edits, membership lifecycle) on top of the per-event coverage in `tests/firehose/handlers.test.ts`.
···11-/**
22- * start-pds.mjs — Start a test PDS via @atproto/dev-env.
33- *
44- * Must run under Node (not Bun) because @atproto/pds uses better-sqlite3
55- * native addon which is incompatible with Bun's runtime.
66- *
77- * Outputs a JSON line to stdout with PDS URL and account details,
88- * then keeps running until killed.
99- */
1010-1111-import { TestNetworkNoAppView } from "@atproto/dev-env";
1212-1313-const network = await TestNetworkNoAppView.create({});
1414-const sc = network.getSeedClient();
1515-1616-await sc.createAccount("alice", {
1717- handle: "alice.test",
1818- email: "alice@test.com",
1919- password: "password123",
2020-});
2121-await sc.createAccount("bob", {
2222- handle: "bob.test",
2323- email: "bob@test.com",
2424- password: "password123",
2525-});
2626-2727-const aliceDid = sc.dids.alice;
2828-const bobDid = sc.dids.bob;
2929-3030-const info = {
3131- pdsUrl: network.pds.url,
3232- plcUrl: network.plc.url,
3333- accounts: {
3434- alice: {
3535- did: aliceDid,
3636- handle: "alice.test",
3737- password: "password123",
3838- },
3939- bob: {
4040- did: bobDid,
4141- handle: "bob.test",
4242- password: "password123",
4343- },
4444- },
4545-};
4646-4747-// Signal readiness to parent process
4848-console.log(JSON.stringify(info));
4949-5050-// Keep alive until killed
5151-process.on("SIGINT", async () => {
5252- await network.close();
5353- process.exit(0);
5454-});
5555-process.on("SIGTERM", async () => {
5656- await network.close();
5757- process.exit(0);
5858-});
+3-3
src/firehose/handlers.ts
···196196// --- Event dispatch ---
197197198198/**
199199- * Normalized commit event consumed by the handler. Shaped to be easy to build
200200- * from either a jetstream message (production) or an `@atproto/sync` commit
201201- * (integration tests against a local PDS).
199199+ * Normalized commit event consumed by the handler. Built from a jetstream
200200+ * message in production; integration tests invoke this handler directly
201201+ * after writing to the test PDS, since asynchrony adds nothing testable.
202202 */
203203export interface FirehoseCommit {
204204 did: string;
-113
src/lib/ws-polyfill.ts
···11-/**
22- * Polyfill for ws.createWebSocketStream, which Bun's built-in ws module
33- * does not implement. Used by @atproto/ws-client for firehose subscriptions.
44- *
55- * This creates a Node.js Duplex stream backed by a WebSocket — the same
66- * interface as the real ws package's createWebSocketStream.
77- */
88-99-import { Duplex } from "node:stream";
1010-// @ts-expect-error — Bun's built-in ws module has no type declarations
1111-import { WebSocket } from "ws";
1212-1313-function createWebSocketStream(
1414- ws: WebSocket,
1515- options?: { signal?: AbortSignal; readableObjectMode?: boolean },
1616-): Duplex {
1717- let terminateOnDestroy = true;
1818-1919- const duplex = new Duplex({
2020- ...options,
2121- autoDestroy: false,
2222- emitClose: false,
2323- objectMode: false,
2424- writableObjectMode: false,
2525- });
2626-2727- ws.on("message", (msg: Buffer | string, isBinary: boolean) => {
2828- const data = !isBinary && duplex.readableObjectMode ? msg.toString() : msg;
2929- if (!duplex.push(data)) {
3030- ws.pause?.();
3131- }
3232- });
3333-3434- ws.once("error", (err: Error) => {
3535- if (duplex.destroyed) return;
3636- terminateOnDestroy = false;
3737- duplex.destroy(err);
3838- });
3939-4040- ws.once("close", () => {
4141- if (duplex.destroyed) return;
4242- duplex.push(null);
4343- });
4444-4545- duplex._destroy = (err, callback) => {
4646- if (ws.readyState === ws.CLOSED) {
4747- callback(err);
4848- process.nextTick(() => duplex.emit("close"));
4949- return;
5050- }
5151-5252- let called = false;
5353-5454- ws.once("error", (wsErr: Error) => {
5555- called = true;
5656- callback(wsErr);
5757- });
5858-5959- ws.once("close", () => {
6060- if (!called) callback(err);
6161- process.nextTick(() => duplex.emit("close"));
6262- });
6363-6464- if (terminateOnDestroy) ws.terminate();
6565- };
6666-6767- duplex._final = (callback) => {
6868- if (ws.readyState === WebSocket.CONNECTING) {
6969- ws.once("open", () => {
7070- duplex._final(callback);
7171- });
7272- return;
7373- }
7474-7575- ws.close();
7676- callback();
7777- };
7878-7979- duplex._read = () => {
8080- if ((ws as unknown as { isPaused?: boolean }).isPaused) {
8181- ws.resume?.();
8282- }
8383- };
8484-8585- duplex._write = (chunk, _encoding, callback) => {
8686- if (ws.readyState === WebSocket.CONNECTING) {
8787- ws.once("open", () => {
8888- duplex._write(chunk, _encoding, callback);
8989- });
9090- return;
9191- }
9292-9393- ws.send(chunk, callback);
9494- };
9595-9696- if (options?.signal) {
9797- options.signal.addEventListener("abort", () => {
9898- duplex.destroy(
9999- Object.assign(new Error("The operation was aborted"), {
100100- code: "ABORT_ERR",
101101- cause: options.signal?.reason,
102102- }),
103103- );
104104- });
105105- }
106106-107107- return duplex;
108108-}
109109-110110-// Patch the ws module — Bun's built-in ws exports a createWebSocketStream
111111-// that throws "Not supported yet in Bun", so we always override it.
112112-const wsModule = require("ws");
113113-wsModule.createWebSocketStream = createWebSocketStream;