A real-time todo application built with iii, demonstrating how the browser becomes a first-class worker connected to the engine via a single WebSocket.
┌─────────────────────┐ WebSocket ┌──────────────────┐
│ React + HeroUI │◄──────────────────────────► iii Engine │
│ (iii-browser-sdk) │ port 3111 │ │
│ Browser Worker │ │ ┌────────────┐ │
└─────────────────────┘ │ │ RBAC │ │
│ │ Streams │ │
┌─────────────────────┐ WebSocket │ │ OTel │ │
│ Node.js API │◄──────────────────────────► └────────────┘ │
│ (iii-sdk) │ port 49134 │ │
│ API Worker │ └──────────────────┘
└─────────────────────┘
The app consists of two workers connected to the same iii engine:
- API Worker (
apps/api) — A Node.js backend that registers CRUD functions and manages todo data through iii Streams. - Browser Worker (
apps/website) — A React frontend usingiii-browser-sdkthat registers its own functions, triggers backend functions, and receives real-time stream updates — all over a single WebSocket connection.
Both the server and browser register as workers with the iii engine using registerWorker:
Server (apps/api/src/iii.ts):
import { Logger, registerWorker } from 'iii-sdk'
const iii = registerWorker('ws://localhost:49134', {
workerName: 'api-worker',
})Browser (apps/website/src/lib/iii.ts):
import { registerWorker } from 'iii-browser-sdk'
const iii = registerWorker('ws://localhost:3111')The API worker registers functions that can be invoked by any other worker (including the browser). Each function has a namespaced ID like todos::create:
fn(
'todos::create',
async (req: { title: string }): Promise<Todo> => {
const id = crypto.randomUUID()
const result = await todosStream.set({
group_id: 'todos',
item_id: id,
data: { id, title: req.title, completed: false },
})
return result.new_value
},
{ description: 'Create a new todo' },
)Registered functions: todos::create, todos::list, todos::get, todos::toggle, todos::delete.
The browser calls backend functions directly using iii.trigger() — no REST endpoints, no fetch calls:
const { items } = await iii.trigger<{}, { items: Todo[] }>({
function_id: 'todos::list',
payload: {},
})Todos are stored in an iii Stream — a persistent, observable data structure backed by a file-based KV store. The TodoStream class wraps stream operations (get, set, list, update, delete):
export class TodoStream implements IStream<Todo> {
async set(args) {
return iii.trigger({ function_id: 'stream::set', payload: { ...args, stream_name: 'todo' } })
}
// get, list, update, delete...
}When any stream data changes, the engine automatically pushes the change event to all subscribers.
The browser registers a function and binds a stream trigger to it. Whenever a todo is created, updated, or deleted, the engine invokes this browser function in real time:
const funcRef = iii.registerFunction({ id: 'ui::on-todo-change' }, async (input: StreamChangeEvent) => {
switch (input.event.type) {
case 'create':
setTodos((prev) => [...prev, todo])
break
case 'update':
setTodos((prev) => prev.map((t) => (t.id === input.id ? todo : t)))
break
case 'delete':
setTodos((prev) => prev.filter((t) => t.id !== input.id))
break
}
return {}
})
iii.registerTrigger({
type: 'stream',
function_id: funcRef.id,
config: { stream_name: 'todo', group_id: 'todos' },
})The public-facing worker on port 3111 has RBAC enabled. An auth function creates a session for each connecting browser client and controls what the client is allowed to do:
fn('todo-project::auth-function', async (input: AuthInput): Promise<AuthResult> => {
const sessionId = crypto.randomUUID()
return {
allowed_functions: [],
forbidden_functions: [],
allow_function_registration: true,
allowed_trigger_types: ['stream'],
function_registration_prefix: sessionId,
// ...
}
})The engine config restricts which functions are exposed to browser clients:
rbac:
auth_function_id: todo-project::auth-function
expose_functions:
- match("todos::create")
- match("todos::list")
- match("todos::get")
- match("todos::delete")
- match("todos::toggle")The engine has built-in OpenTelemetry support for traces, metrics, and logs — configured via iii-config.yaml:
- class: modules::observability::OtelModule
config:
enabled: true
service_name: iii-test
metrics_enabled: true
logs_enabled: true
sampling_ratio: 1.0The API worker uses the iii Logger for structured log output:
import { Logger } from 'iii-sdk'
const logger = new Logger()
logger.info('Toggling todo', { id: req.id })
logger.warn('Todo not found', { id: req.id })This project uses iii-browser-sdk (v0.10.0-beta.2) to turn the React frontend into a full iii worker running in the browser. The browser SDK provides the same API as the server SDK (registerFunction, trigger, registerTrigger) but with zero Node.js dependencies and a browser-native WebSocket transport.
Key resources:
| Resource | Link |
|---|---|
| npm package | npmjs.com/package/iii-browser-sdk |
| Documentation | iii.dev/docs |
| Browser guide | Use iii in the Browser |
| Browser SDK API reference | iii.dev/docs/api-reference/sdk-browser |
| Server SDK (iii-sdk) | npmjs.com/package/iii-sdk |
| iii Engine | github.com/iii-hq/iii |
| Examples | github.com/iii-hq/iii-examples |
Imports used in this project:
| Import | Purpose |
|---|---|
iii-browser-sdk |
Core SDK — registerWorker to connect to the engine |
iii-browser-sdk/stream |
StreamChangeEvent type for handling real-time stream updates |
iii-sdk |
Server-side SDK — registerWorker, Logger, AuthInput, AuthResult |
iii-sdk/stream |
Server-side stream types — IStream, StreamSetInput, etc. |
- Node.js >= 22.12.0
- pnpm 10.33.0+ (managed via
packageManagerfield) - Vite+ (
vp) — install globally withnpm i -g vite-plus - iii Engine — install from github.com/iii-hq/iii
- Bun — used to run the API worker (
bun run --watch)
vp installFrom the apps/api directory, start the engine with the project config:
cd apps/api
iiii --config iii-config.yamlThis boots the engine with:
- Internal worker port on
49134(for the API worker) - Public worker port on
3111with RBAC (for browser clients) - Stream module on
3112with file-based KV persistence - OTel module for observability
- Exec module that auto-runs
pnpm devto start the API worker
In a separate terminal, from the root:
vp run devThis starts the Vite dev server for the React frontend.
Navigate to http://localhost:5173 to see the todo app. Every change is reflected in real time across all connected browser tabs.
todo-app/
├── apps/
│ ├── api/ # Backend worker (Node.js + iii-sdk)
│ │ ├── iii-config.yaml # iii engine configuration
│ │ ├── src/
│ │ │ ├── main.ts # Entry point — imports all routes
│ │ │ ├── iii.ts # Worker registration + logger
│ │ │ ├── lib/
│ │ │ │ ├── decorators.ts # fn() helper for function registration
│ │ │ │ └── rbac.ts # Auth function for browser RBAC
│ │ │ └── routes/
│ │ │ ├── todos.stream.ts # TodoStream — wraps iii stream ops
│ │ │ ├── todos.create.ts # Create a todo
│ │ │ ├── todos.list.ts # List all todos
│ │ │ ├── todos.get.ts # Get a todo by ID
│ │ │ ├── todos.toggle.ts # Toggle completed status
│ │ │ └── todos.delete.ts # Delete a todo
│ │ └── package.json
│ └── website/ # Frontend worker (React + iii-browser-sdk)
│ ├── src/
│ │ ├── App.tsx # Todo UI (HeroUI + Tailwind)
│ │ ├── main.tsx # React entry point
│ │ ├── hooks/
│ │ │ └── use-todos.ts # Real-time todo state via iii
│ │ └── lib/
│ │ └── iii.ts # Browser worker registration
│ └── package.json
├── package.json # Monorepo root
├── pnpm-workspace.yaml # pnpm workspace config
└── vite.config.ts # Vite+ root config (lint, fmt)
| Layer | Technology |
|---|---|
| Engine | iii |
| Server SDK | iii-sdk |
| Browser SDK | iii-browser-sdk |
| Frontend | React 19, HeroUI, Tailwind CSS |
| Tooling | Vite+, pnpm, TypeScript |
| API Runtime | Bun |
| Observability | OpenTelemetry (built into iii) |
