USERDO: PER-USER DURABLE OBJECTS
User data pods with live updates and boring ops.
TECHNICAL GUIDE 2025.01.15
Each user gets a Durable Object—ordered state, simple auth, instant SSE.
THE QUICK VERSION
Give every user their own Durable Object. Read/write their profile and stream updates over SSE. It’s simple, ordered, and fast.
Great for settings, presence, inboxes, and profile-driven UIs.
ARCHITECTURE
Client ↔ Worker → UserDO(name = user.sub) ↓ ↓ JWT auth SSE updates
INFRASTRUCTURE
Install the package.
# Install bun install userdo
YOUR DURABLE OBJECT
Extend `UserDO` to add your tables and methods.
// 1) Extend UserDO (your data + logic) import { UserDO, type Env } from "userdo/server"; import { z } from "zod"; const PostSchema = z.object({ title: z.string(), content: z.string() }); export class BlogDO extends UserDO { posts: any; constructor(state: DurableObjectState, env: Env) { super(state, env); this.posts = this.table('posts', PostSchema, { userScoped: true }); } async createPost(title: string, content: string) { return await this.posts.create({ title, content }); } async getPosts() { return await this.posts.orderBy('createdAt', 'desc').get(); } }
WORKER
Auth endpoints come built-in. Add your own routes.
// 2) Create Worker (auth built-in; add your endpoints) import { createUserDOWorker, createWebSocketHandler, getUserDOFromContext } from 'userdo/server'; import type { BlogDO } from './blog-do'; const app = createUserDOWorker('BLOG_DO'); const wsHandler = createWebSocketHandler('BLOG_DO'); app.post('/api/posts', async (c) => { const user = c.get('user'); if (!user) return c.json({ error: 'Unauthorized' }, 401); const { title, content } = await c.req.json(); const blog = getUserDOFromContext(c, user.email, 'BLOG_DO') as unknown as BlogDO; const post = await blog.createPost(title, content); return c.json({ post }); }); export default { async fetch(request: Request, env: any, ctx: any) { if (request.headers.get('upgrade') === 'websocket') return wsHandler.fetch(request, env, ctx); return app.fetch(request, env, ctx); } };
CLIENT
Use the browser client. It handles auth and real-time.
// 4) Browser client import { UserDOClient } from 'userdo/client'; const client = new UserDOClient('/api'); await client.signup('user@example.com', 'password'); await client.login('user@example.com', 'password'); client.onChange('table:posts', (evt) => console.log('post changed', evt));
WRANGLER CONFIG
Enable SQLite for your DO via migration.
{ "main": "src/index.ts", "compatibility_flags": ["nodejs_compat"], "vars": { "JWT_SECRET": "your-jwt-secret-here" }, "durable_objects": { "bindings": [ { "name": "BLOG_DO", "class_name": "BlogDO" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["BlogDO"] } ] }
BUILT-IN ENDPOINTS
Built-in endpoints: - POST /api/signup - POST /api/login - POST /api/logout - GET /api/me - GET /api/ws (WebSocket) - GET /data - POST /data - Organizations: /api/organizations...
ORGANIZATIONS
Org-scoped tables and membership management.
// Organization-scoped example import { UserDO, type Env } from 'userdo/server'; import { z } from 'zod'; const Project = z.object({ name: z.string() }); const Task = z.object({ title: z.string() }); export class TeamDO extends UserDO { projects: any; tasks: any; constructor(state: DurableObjectState, env: Env) { super(state, env); this.projects = this.table('projects', Project, { organizationScoped: true }); this.tasks = this.table('tasks', Task, { organizationScoped: true }); } }