Getting started with the rifleDB SDK
This guide connects an app to a rifleDB server with the rifledb SDK: first a plain React single-page app, then a
Next.js app that renders on the server and stays live in the browser. The binding design is
architecture.md; this page shows how to use it.
Workspace only. rifledb is not on npm. Inside this monorepo, depend on it with
"rifledb": "workspace:*". Apps consume it as TypeScript source (Next: add it to transpilePackages; Vite needs
nothing extra).
1. Run a server
pnpm install
pnpm -F @rifledb/server dev # http://localhost:4000, in-memory store (data is gone on restart)For data that survives restarts, start the development Redis (port 6380, append-only file in .) and point
the server at it:
pnpm redis:dev # needs redis-server on your PATH
REDIS_URL=redis://localhost:6380 pnpm -F @rifledb/server devFolders are declared in server/. Its playground folder is a public
sandbox anyone may write a few known keys in (counter, toggle, todos, plus the fast folder playground/); the
examples below use it. Browsers may only open a socket from an allowed origin: by default
http:/, :3001 and :5173 (RIFLE_, see Operations).
2. A React SPA
The full example is examples/vite-spa (pnpm -F vite-spa dev, then open
http://localhost:5173 in two windows).
// src/rifle.ts: one client per tab, created once at module level
import { createRifle } from 'rifledb'
import { createRifleHooks } from 'rifledb/react'
export type Schema = {
'playground/counter': number
'playground/todos': { id: string; text: string; done: boolean }[]
[k: `playground/live/presence/${string}`]: [x: number, y: number, hue: number]
}
export const rifle = createRifle<Schema>({
url: import.meta.env.VITE_RIFLE_URL ?? 'http://localhost:4000',
identity: 'none', // anonymous; 'auto' gives this browser a device key (needed for private folders)
})
// Hooks typed by the schema: a wrong value type for a path is a compile error.
export const { useRifle, useFolder } = createRifleHooks<Schema>()// src/main.tsx
import { createRoot } from 'react-dom/client'
import { RifleProvider } from 'rifledb/react'
import { rifle, useRifle } from './rifle'
function Counter() {
const [count, api, { pending, status }] = useRifle('playground/counter')
return (
<button onClick={() => api.inc()} aria-busy={pending}>
{count ?? 0} {status === 'offline' ? '(offline: the click waits for the connection)' : ''}
</button>
)
}
createRoot(document.getElementById('root')!).render(
<RifleProvider client={rifle}>
<Counter />
</RifleProvider>,
)What you get:
useRifle(path)→[value,.api, { rev, pending, status, ready, error }] apihas the op methods (set,inc,toggle,push,unshift,removeAt,removeWhere,merge,strAppend,strPrepend,del,update) plus the legacy aliases. Writes apply at once in the tab (optimistic) and roll back if the server refuses them; while offline they queue and replay with the same id when the socket is back.useFolder(prefix)→[entries,, keys relative to the prefix (nested folders excluded).{ status, ready, error }] useAction(folder,→ a function that runs a server action; it rejects on refusal and keeps the reason inname) ..error useEvent(folder,receives what actionsname, cb) emit.usePresence(folder,→initialTuple, { hz, ttlMs }) [others,on a fast folder: your tuple lives atsetMine, { sid }] <folder>/, where<sid> sidis the client's slot (rifle., stable across reconnects). It is throttled tostatus. slot hzwith at most one write in flight, deleted when you leave (onDisconnect), and refreshed everyttlMs /so the folder's TTL does not drop you while you idle. Pass the folder's2 ttlMs. Give the folderpublicWrite='own'and nobody else can move or remove your entry.useConnection(),useIdentity()(ensure,createLink,linkDevice,recover),useStepUp().
Without React, the core client has the same operations: rifle., rifle.,
rifle., rifle., rifle.,
rifle., rifle., and rifle. to share a folder you own (grants, claim and
key invites; Access control). There is no polling anywhere: everything arrives over the one
socket.
3. Next.js (App Router)
Create the client with connect: false: the module also runs while rendering on the server, and a socket there would
register a throwaway identity. <RifleProvider> connects it in the browser.
// lib/rifle.ts
import { createRifle } from 'rifledb'
export const rifle = createRifle({
url: process.env.NEXT_PUBLIC_RIFLE_URL ?? 'http://localhost:4000',
identity: 'auto',
connect: false,
})// app/providers.tsx
'use client'
import type { ReactNode } from 'react'
import { RifleProvider } from 'rifledb/react'
import { rifle } from '@/lib/rifle'
export function Providers({ children }: { children: ReactNode }) {
return <RifleProvider client={rifle}>{children}</RifleProvider>
}Server rendering with live hydration
rifledb/ (server-only) reads and writes over signed REST with a service key;
<RifleHydrate snapshot> seeds the browser cache so the first client render matches the server HTML, then the hooks
take over live.
// app/boards/[id]/page.tsx (a server component)
import { RifleHydrate } from 'rifledb/next'
import { createRifleServer } from 'rifledb/next/server'
import { Cards } from './cards' // a client component using useRifle
const rifle = createRifleServer({ url: process.env.RIFLE_URL!, serviceKey: process.env.RIFLE_SERVICE_KEY! })
export default async function BoardPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const snapshot = await rifle.snapshotFor([`boards/${id}/cards`])
return (
<RifleHydrate snapshot={snapshot}>
<Cards boardId={id} />
</RifleHydrate>
)
}Everything in a snapshot is serialized into the page for every visitor, and the service key may read more than the visitor can: only snapshot what the page shows to everyone who can open it.
createRifleServer also has get, read (value + rev + ETag), getPrefix, set (with ifMatch /
ifNoneMatch: '*'), del, op, inc, push, merge, removeWhere, update (an If-Match compare-and-set loop) and
act. Without serviceKey it is an anonymous client (public reads, public actions). An empty serviceKey
(RIFLE_ unset) makes every request fail with E_ instead of silently going anonymous; builds that
import the module without secrets still work.
Service keys
pnpm service-key --folder boards --perms write --label 'next app' # add --authority for authority pathsprints RIFLE_ (the Ed25519 private key, PKCS#8, base64url; server-side env only, never NEXT_)
and RIFLE_, plus two ways to register the public key with the server:
- in
server/, inside the folder:rifle. config. tsx <Key kid=… pub=… perms='write' label='next app' /(with> authorityit is a service key that may also write the folder's server-only paths), or - at runtime, by an elevated admin: the system action
keys.(a folder key without authority, revocable withregisterFolderKey keys.).revoke
Keys cover their folder and subfolders that inherit. Secret folders never accept them
(Access control).
4. REST from anywhere
Every path is also a URL under /:
curl http://localhost:4000/v1/playground/counter # {"v":3,"r":12}, ETag: "<epoch>:<rev>"
curl -X PUT http://localhost:4000/v1/playground/counter -H 'content-type: application/json' -d 7
curl -X PATCH http://localhost:4000/v1/playground/counter \
-H 'content-type: application/rifle-op+json' -d '{"op":"inc","args":{"by":5}}'
curl -N 'http://localhost:4000/v1/playground/counter?stream=1' # server-sent events: snapshot, then chgAnything that is not public needs signed requests: see Protocol, or let the SDK
(createRifleServer, or the browser client's HTTP layer) sign them.
5. Things to know
- Fast folders take only primitives and arrays of primitives, and may coalesce updates (last write wins per path,
hzper second; the SDK also keeps only the newest unsent write per key). Use them for cursors, sliders, presence. - Limits: a folder's
limits.caps its writes per second for everyone together (opsPerSec E_, scopeRATE folder); each user may store at most 1 MB acrossusers/,<uid> /andprofile /(secret E_).LIMIT - Secret means access-controlled, not end-to-end encrypted. The server can read every value.
- Epochs: the server's epoch changes on every boot. Clients then drop their cached revisions and resubscribe for fresh snapshots (with Redis the data stays and only the epoch moves; a dev server's in-memory data is gone after a restart). A reload is never needed for that.
- Plain-http LAN addresses have no WebCrypto Ed25519: the SDK falls back to
@noble/(with a console warning).curves localhostis a secure context and fine.