yapern

Create a message

B I S x2 H “” </> •••

0/2000

Create new sub-yap

← Blog

Not a tavern, a yapern

· Yapern

There’s a post in y/scrollsnap about getting a macOS app running in Xcode. A few hours later, the app’s creator replied. He also posted a ScrollSnap v3.2 release announcement, including improvements to scrolling captures, sharper text, and warnings about incomplete screenshots.

That creator is Berker Güngör, Brkgng on the site, who builds ScrollSnap. His participation gives this little sub-yap a useful start: someone tries the software, shares what happened, and hears from the person who made it. A screenshot of a working build is a perfectly good conversation starter.

Brkgng’s actual ScrollSnap v3.2 post in y/scrollsnap lists capture improvements and thanks Andrew for creating the space and trying the app.

Yapern is our open source Reddit clone. Communities here are called sub-yaps, and the whole thing runs on Cloudflare. Once someone starts a conversation like that ScrollSnap thread, a surprisingly large part of the backend’s job is keeping small promises: a reply belongs under the right person, a retry doesn’t post it twice, and the notification is still there if nobody has the site open.

Why another Reddit?

A community puts time into its forum. People write answers, explain their projects, and leave behind knowledge that somebody else will need six months later. The About page makes a fairly modest argument: the people doing that work should be able to understand how the place runs and help improve it. We think the world needs that option for a familiar, useful kind of website.

The familiar part matters. Posts have addresses. Replies form conversations. A community can gather around one peculiar interest without explaining itself to the entire internet. Yapern’s version should load quickly, read comfortably on a phone, and keep the controls out of the way. “Better Reddit” has to show up in those ordinary interactions.

Both the web app and the backend are available under GPL v3. You can inspect how a vote is stored, report a bug, contribute a fix, or adapt the code under the license’s terms. Publishing the source gives people those options. Earning their trust also takes clear rules, good moderation tools, and competent operation; those jobs don’t come free with a license.

The early sub-yaps already have different subjects. Alongside ScrollSnap, y/duckling has posts about trying a Tauri-based viewer for CSV, Parquet, and databases. y/flowkeeper follows a desktop Pomodoro timer from download through installation. There’s also y/ai for AI discussion and y/los-angeles for the city. ScrollSnap is the example where the project’s creator has joined in. We’re early, and broader moderation tools still need work.

A static site with its own Worker

The frontend is HTML, CSS, and JavaScript, built with Vite and served through Workers Static Assets. A request for /y/scrollsnap loads the shared board page; the browser reads the URL and asks api.yapern.com for the records. Each post has a URL inside its sub-yap, so finding it doesn’t depend on its position in the latest feed.

Our API is a TypeScript Worker using Hono for routing and Zod for request and response schemas. The same route declarations produce the interactive API documentation and OpenAPI document.

Where the work happens
Cloudflare serviceWhat Yapern puts there
WorkersStatic frontend delivery and a separate API Worker.
D1Accounts, sessions, posts, reply trees, votes, notifications, and full-text indexes.
R2Uploaded post images, sub-yap logos, and cached link screenshots.
Durable ObjectsOne notification coordinator per recipient, with WebSockets to their open tabs.
Queues and Browser RunBackground screenshots for links that don’t supply a preview image.

Sign-in uses email and password, with scrypt password hashes and hashed session tokens stored in D1. The browser sends its session as a bearer token, and the Worker derives the author of a write from that session. The board owner check happens there too. The frontend can hide an edit button, but the server still has to refuse somebody else’s edit.

Search stays in SQLite

A useful post needs a way to be found after it falls out of the feed. The search field in the header searches message titles and bodies, or sub-yap names, slugs, and descriptions. It can narrow results to a particular sub-yap and sort by relevance or newest.

D1 supports SQLite’s FTS5 extension, so we can keep the search index beside the content. The migration creates external-content FTS tables: the original tables keep the text, while the index refers back to their integer rowid. Public post IDs stay the same. Insert, update, and delete triggers maintain the indexes in the transaction that changes the content, which avoids a separate indexing job falling behind.

The query syntax is intentionally small. We extract literal words, require all of them, and let the last word match a prefix. A search for scrollsnap screenshot becomes this FTS expression:

"scrollsnap" AND "screenshot"*

That last term can match “screenshots.” Case and diacritics are ignored. FTS operators in the input become ordinary words, and the expression goes into a bound SQL parameter. The ranking query uses BM25 with more weight on message titles and sub-yap names than body text. Snippets come back as plain text, so the renderer can display them without interpreting somebody’s post as HTML.

This first version searches posts and sub-yaps; replies aren’t indexed yet. It doesn’t do typo correction or semantic matching. We cap queries at 200 characters and 20 words, pages at 50 results, and offsets at 1,000. The API asks for one extra result to decide whether to show another page. FTS still takes storage and adds work to writes, but there’s no second search service to keep in sync.

Replies to replies need more than indentation

In the Xcode thread, Berker replied to the original post and Andrew answered him. That second response now belongs under Berker’s reply. The connecting line and collapse control make the relationship visible, and the composer opens beside the reply you’re answering.

The live Xcode thread shows Brkgng’s reply and Andrew’s indented answer connected by a subtle thread line. Brkgng’s reply has one direct child; Andrew’s has zero.

Every reply keeps the root post’s message_id and a nullable parent_reply_id. A null parent means a direct reply to the post. Otherwise it points to another reply in that same thread. The database guards require an existing parent in the same message and keep thread pointers immutable. A client can’t attach a reply to a different post’s conversation or reparent old rows into a cycle. Existing replies kept their IDs and became direct children of their original posts.

There’s no application limit on stored reply depth. Loading the page is bounded, though. The API returns a page of immediate children, twenty by default, and expanding a branch fetches its children separately. Each branch and sort order has its own keyset cursor. Instead of “skip the first twenty rows,” that cursor says where to continue after the last returned timestamp or score and ID. Deleting that last reply doesn’t invalidate the cursor. Top sorting uses a cached, indexed vote score; because votes can change between requests, the client deduplicates appended replies and offers a refresh.

The browser shows five reply levels before offering “Continue this thread,” which opens a focused view. A deep link can fetch a bounded chain of ancestors without downloading the whole conversation. Those are display and loading choices, so a long discussion doesn’t have to squeeze itself into an ever-narrower strip on a phone.

The counts describe two different things. A post’s reply_count includes every reply below it. A reply’s direct_reply_count includes only its immediate children. In the pictured exchange, the post has two replies in total, Berker’s reply has one child, and Andrew’s has none. Database triggers update the root total and immediate parent counter alongside the insert. Adding a reply fifty levels down still changes those two counters; it doesn’t walk fifty ancestors. Feed reads can use the stored total without counting the thread again.

The composer also generates an Idempotency-Key for each submission and reuses it on a retry. If a connection drops after D1 has saved the reply, retrying the same body and target returns the original row. It creates no second reply, vote, counter increment, or notification. Reusing the key with different content returns a conflict. A flaky connection is a lousy reason to make someone repeat themselves.

The bell has a Durable Object

A reply to Berker should notify Berker. It shouldn’t notify the original post’s author every time two other people answer each other. The nested-reply trigger chooses the immediate parent’s author, or the post’s author for a direct reply, and suppresses self-notifications.

D1 saves that notification in the same transaction as the reply. The unread record therefore exists even if the recipient has no tabs open. After the save, the Worker asks the recipient’s UserNotifications Durable Object to read a fresh snapshot from D1 and broadcast it to their connected tabs. Each recipient gets their own object, so one person’s bell doesn’t need a global coordinator.

Sequence diagram: the Worker inserts a reply into D1, where the reply, counters, author vote, and notification commit together. After commit, the Worker requests a publish from the recipient’s Durable Object. The object reads a D1 snapshot and broadcasts it to connected tabs.

The object uses Cloudflare’s WebSocket Hibernation API, which lets it sleep while idle with clients still connected. Its own SQLite storage keeps single-use socket tickets and a monotonically increasing snapshot version; the notification history stays in D1. Clients use the version to discard delayed snapshots. A failed publish leaves the D1 delivery record pending, and the five-minute scheduled job retries it. Reconnecting also reads the saved state.

Opening notifications acknowledges only the IDs the client has observed. Suppose the inbox snapshot ends at notification 41 and a new reply creates 42 before the acknowledgement arrives. Marking everything read would quietly erase the new notification. We send through_id: 41, so 42 stays unread, and the updated snapshot goes to the other tabs too.

The socket connection needs a little care as well. The browser exchanges its bearer session for a single-use ticket that expires after sixty seconds. The ticket goes in the WebSocket URL, keeping the bearer token out of it. The server checks the browser’s Origin, rechecks the session when consuming the ticket, and checks attached sessions again on broadcasts. Runtime-level ping/pong can keep the connection alive without waking the object.

One vote per person

The vote arrows used to sit beside a hard-coded one. They now represent rows in D1 with a unique key for each user and target, and a value restricted to minus one, zero, or plus one. A new post or reply gets its author’s initial plus one. Clicking again can’t stack another contribution from the same person.

The transition rule is deliberate: pressing down after your upvote removes it first, returning your contribution to zero. You can then downvote. The API enforces that rule, so a direct request can’t skip the neutral state. A first upvote from someone else creates a notification, and a unique actor-and-target record prevents toggling the vote off and on from sending repeated notifications. The saved notification keeps its read state.

Links get a slower path

Posts can contain uploaded images, and sub-yaps can have logos. Those files go into R2 after type, signature, and size checks. Their paths live in D1. R2 uploads aren’t part of a D1 transaction, so the API cleans up a new object if saving the row fails and removes an old logo after a successful replacement.

Link previews sometimes need more work than an upload. We first look for an Open Graph or Twitter image in the linked page. If neither is available, the API queues a job and gives the composer a favicon or placeholder while it waits. The queue consumer uses Browser Run to capture a 1200 × 630 JPEG, saves it in R2, and records the result in D1. The browser polls the job’s status; it doesn’t hold the original request open while a remote website renders.

What the composer gets back
Preview resultAPI behavior
Page supplies an image200 with the image URL, ready to display.
Screenshot is queued202 with a job ID, status URL, and a three-second retry hint.
Rendering finishesStatus polling returns the R2 image, or a failed result with fallback and diagnostics.

Cloudflare Queues provides at-least-once delivery, so the consumer uses expiring job leases to handle duplicates and checks that it still owns the lease before publishing a result. Successful screenshots are cached for fourteen days, failures for six hours. A scheduled cleanup removes expired jobs and their R2 objects. Per-user quotas, a browser deadline, and concurrency limits bound how much work a pasted URL can start.

One of the more revealing fixes in the commits is in DNS validation. The resolver could return CNAME hostnames alongside IP addresses, and the earlier check treated those names as unsafe addresses. Legitimate previews failed. The fix filters out names, requires at least one resolved IP, and checks every returned address against the public-address rules. Rejecting a CNAME string never told us whether the destination was private. It just broke some websites.

We still have to earn the “better” in the About page, especially as communities need more ways to look after themselves. Having the source available gives people a way to help build those tools and question the choices we make.

For now, the useful test is small enough to visit: open y/scrollsnap, read the creator’s release post, and follow the exchange about running his app. A project has a place where its users and author can talk, the conversation has an address, and the code behind it is available to change. That’s a forum worth working on.