Skip to content
rifleDB
Docs: SDK guide

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 .data/redis) and point the server at it:

pnpm redis:dev                                               # needs redis-server on your PATH
REDIS_URL=redis://localhost:6380 pnpm -F @rifledb/server dev

Folders are declared in server/rifle.config.tsx. Its playground folder is a public sandbox anyone may write a few known keys in (counter, toggle, todos, plus the fast folder playground/live); the examples below use it. Browsers may only open a socket from an allowed origin: by default http://localhost:3000, :3001 and :5173 (RIFLE_ALLOWED_ORIGINS, 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 }]. api has 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, { status, ready, error }], keys relative to the prefix (nested folders excluded).
  • useAction(folder, name) → a function that runs a server action; it rejects on refusal and keeps the reason in .error. useEvent(folder, name, cb) receives what actions emit.
  • usePresence(folder, initialTuple, { hz, ttlMs }) → [others, setMine, { sid }] on a fast folder: your tuple lives at <folder>/<sid>, where sid is the client's slot (rifle.status.slot, stable across reconnects). It is throttled to hz with at most one write in flight, deleted when you leave (onDisconnect), and refreshed every ttlMs / 2 so the folder's TTL does not drop you while you idle. Pass the folder's ttlMs. Give the folder publicWrite='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.get/set/op/inc/…, rifle.subscribe(path, cb), rifle.subscribePrefix(prefix, cb), rifle.act(folder, name, args), rifle.on(folder, event, cb), rifle.onDisconnect(path, op, args), rifle.status, and rifle.share.* 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/next/server (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_SERVICE_KEY unset) makes every request fail with E_INVALID 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 paths

prints RIFLE_SERVICE_KEY (the Ed25519 private key, PKCS#8, base64url; server-side env only, never NEXT_PUBLIC_*) and RIFLE_SERVICE_KID, plus two ways to register the public key with the server:

  • in server/rifle.config.tsx, inside the folder: <Key kid=… pub=… perms='write' label='next app' /> (with authority it 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.registerFolderKey (a folder key without authority, revocable with 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 /v1/:

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 chg

Anything 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, hz per second; the SDK also keeps only the newest unsent write per key). Use them for cursors, sliders, presence.
  • Limits: a folder's limits.opsPerSec caps its writes per second for everyone together (E_RATE, scope folder); each user may store at most 1 MB across users/<uid>, /profile and /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/curves (with a console warning). localhost is a secure context and fine.