Lowlander
An experimental TypeScript framework for data persistence and (partial) client synchronization.
This project is still under heavy development. DO NOT USE for anything serious. Early feedback is very welcome though!
To get an impression of what use of this framework currently looks like, check out the example project’s…
- server-side API and
- client-side UI code.
This library is built on top of a number of libraries by the same author:
- Edinburgh: use JavaScript objects as really fast ACID database records.
- OLMDB: a very fast on-disk key/value store with MVCC and optimistic transactions, used by Edinburgh for persistence.
- WarpSocket: a high-performance WebSocket server written in Rust, that coordinates multiple JavaScript worker threads and provides an API for channel subscriptions.
- Aberdeen: a reactive UI library for JavaScript. It features fine-grained updates, needs no virtual DOM, and uses Proxy for reactivity.
Lowlander glues these together and adds real-time partial data synchronization and type-safe RPCs to provide a framework for rapidly building performant full-stack (database included!) web applications.
Example project
Section titled “Example project”An example project is included in examples/helloworld. To run it:
npm run exampleOpens at http://localhost:8080 with the Aberdeen dashboard at http://localhost:8080/_dashboard (password printed to console on start).
Tutorial
Section titled “Tutorial”Project Setup
Section titled “Project Setup”npm initnpm add lowlander aberdeen edinburghCreate the project structure:
server/ main.ts # starts the server api.ts # exported functions = RPC endpointsclient/ app.ts # UI using Aberdeen + ConnectionIf you use Claude Code, GitHub Copilot or another AI agent that supports Skills, Lowlander and its dependencies include skill/ directories that provide specialized knowledge to the AI.
Symlink them into your project’s .claude/skills directory:
mkdir -p .claude/skillsln -s ../../node_modules/lowlander/skill .claude/skills/lowlanderln -s ../../node_modules/aberdeen/skill .claude/skills/aberdeenln -s ../../node_modules/edinburgh/skill .claude/skills/edinburghServer Entry Point
Section titled “Server Entry Point”The entry point starts the WarpSocket server and points it at the API file:
import { start } from 'lowlander/server';import { fileURLToPath } from 'url';import { resolve, dirname } from 'path';
const API_FILE = resolve(dirname(fileURLToPath(import.meta.url)), 'api.js');start(API_FILE, { bind: '0.0.0.0:8080' });Options: bind (address:port), threads (worker count).
Defining RPC Endpoints
Section titled “Defining RPC Endpoints”Every exported function in the API file is callable from the client. No decorators or registration needed:
export function add(a: number, b: number): number { return a + b;}Functions can be async. Thrown errors are sent to the client as error responses.
Edinburgh Models
Section titled “Edinburgh Models”Define persistent data models using Edinburgh. See Edinburgh docs for full details.
import * as E from 'edinburgh';
const Person = E.defineModel('Person', class { name = E.field(E.string); age = E.field(E.number); friends = E.field(E.array(E.link(() => Person))); password = E.field(E.string);}, { pk: 'name' });Models are ACID, and RPC calls automatically run in transactions. When creating a new Instance() or updating props on an existing instance, changes are persisted to disk automatically. E.link objects are lazy-loaded.
Model Streaming with createStreamType
Section titled “Model Streaming with createStreamType”Stream a subset of model fields to clients with real-time updates. Changes are pushed automatically. First you need to create a stream type, by doing this once:
import { createStreamType } from 'lowlander/server';
// Exclude password; include friends' names and agesconst PersonStream = createStreamType(Person, { name: true, age: true, friends: { // nested linked model: specify sub-selection name: true, age: true, }});Use true for plain fields. For linked model fields, provide a nested selection object. To return a stream instance from an API function:
export function streamPerson(name: string) { const person = Person.getBy('name', name)!; return new PersonStream(person);}On the client, this returns a reactive Aberdeen proxy that updates live when server data changes.
// Client-sideconst person = api.streamPerson('Alice');// person.value starts as undefined while loading, and// then becomes a live-updating reactive proxy object of Alice's dataA.dump(person);Lowlander will keep person.value up-to-date as long as the Aberdeen scope containing api.streamPerson remains active. When the scope is destroyed, the stream subscription is automatically cancelled.
It’s quite common for the same RPC call to be used to get the same stream multiple times in a short period; when navigating back and forth, or when navigating to a new page that requires some of the same data as the previous page. To optimize for this, createStreamType accepts an optional cache parameter (in seconds).
const PersonStream = createStreamType(Person, fields, { cache: 30 }); // cache for 30s after going out of scopeAfter a stream with caching goes out of scope, the server keeps it alive for that many seconds, so that if the same stream is requested again with the same parameters, it can be reused instantly without re-sending initial data or re-subscribing to updates. Cached stream rpcs also deduplicate within that time window, so if the same stream is requested multiple times while it’s still active or cached, only one stream is created on the server and shared among all requests.
Virtual (computed) fields
Section titled “Virtual (computed) fields”Plain getter properties on the model can be selected like any other field. Lowlander detects them and re-evaluates on each commit; an update is pushed only when the getter’s return value actually changes:
const Person = E.defineModel('Person', class { name = E.field(E.string); age = E.field(E.number); get greeting() { return `Hi, I'm ${this.name} and I'm ${this.age}!`; }}, { pk: 'name' });
const PersonStream = createStreamType(Person, { greeting: true,});On every model update, greeting will be invoked for both the old and new data, to check for changes. So avoid doing expensive operations in these getters.
ServerProxy for Stateful APIs
Section titled “ServerProxy for Stateful APIs”Wrap a class instance to expose per-connection stateful methods:
// server-side apiimport { ServerProxy } from 'lowlander/server';
class UserAPI { constructor(public userName: string) {}
get user(): Person { return Person.getBy('name', this.userName)!; }
getBio() { return `${this.user.name} is ${this.user.age} years old`; }}
export async function authenticate(token: string) { const user = Person.getBy('name', token); if (!user) throw new Error('User not found'); return new ServerProxy(new UserAPI(token), 'secret-value');}The client receives 'secret-value' as .value and can call UserAPI methods via .serverProxy.
You can also pass a stream type instance as the value — the client’s .value will then be reactive and update live whenever the model changes:
const PersonStream = createStreamType(Person, { name: true, age: true });
export async function authenticate(token: string) { const user = Person.getBy('name', token); if (!user) throw new Error('User not found'); return new ServerProxy(new UserAPI(token), new PersonStream(user));}The client gets both .serverProxy (for calling UserAPI methods) and a live-updating .value.
When a proxy is dropped, because the request’s Aberdeen scope was destroyed or the WebSocket disconnected, Lowlander calls onDrop() on the API object if it exists, letting you clean up server-side state.
// client-sideconst auth = api.authenticate('Alice');dump(auth.serverProxy.getBio());Socket Callbacks
Section titled “Socket Callbacks”Use Socket<T> parameters for server-push streaming. On the client, these become callback functions:
import { Socket } from 'lowlander/server';
export function streamNumbers(socket: Socket<number>) { const interval = setInterval(() => { if (!socket.send(Math.random())) clearInterval(interval); }, 1000);}socket.send() returns falsy when the client disconnects.
Client Connection
Section titled “Client Connection”Connect to the server with full type safety:
import { Connection } from 'lowlander/client';import type * as API from './server/api.js';
const conn = new Connection<typeof API>('ws://localhost:8080/');const api = conn.api;All server exports are available on conn.api with matching types, except Socket<T> params become callbacks.
Simple RPC
Section titled “Simple RPC”const sum = api.add(1, 2);// sum is a PromiseProxy:// - sum.value starts out as undefined, and reactively updates to the result when available// - sum.error is an Error object if the call threw, or undefined otherwise// - sum.promise can be awaited: `const val = await sum.promise;` - this throws on errorUsing ServerProxy
Section titled “Using ServerProxy”const auth = api.authenticate('Frank');// auth.value → 'secret-value' (after resolution)// auth.serverProxy → typed proxy to UserAPI methods
const bio = auth.serverProxy.getBio();// bio.value → "Frank is 45 years old"The server proxy is usable immediately—calls queue until authentication completes. If auth fails, queued calls fail too.
Model Streaming
Section titled “Model Streaming”const person = api.streamPerson('Alice');// person.value is a reactive proxy that auto-updatesSocket Callbacks
Section titled “Socket Callbacks”api.streamNumbers(num => console.log(num));On the server-side we should have a export function streamNumbers(socket: Socket<number>).
Reactive Integration with Aberdeen
Section titled “Reactive Integration with Aberdeen”PromiseProxy results are reactive in Aberdeen scopes:
import A from 'aberdeen';
const sum = api.add(1, 2);A(() => { if (sum.busy) A('span#Loading...'); else if (sum.error) A('span#Error: ' + sum.error.message); else A('span#Result: ' + sum.value);});Model streams are also reactive—nested data updates trigger fine-grained UI updates:
const model = api.streamModel();A(() => { if (!model.value) return; A('h2#' + model.value.name); A('p#Owner: ' + model.value.owner.name);});Connection Status
Section titled “Connection Status”A(() => { A('span#' + (conn.isOnline() ? 'Connected' : 'Offline'));});Reconnection is automatic with exponential backoff.
Cleanup
Section titled “Cleanup”Aberdeen’s clean() handles RPC lifecycle. When a reactive scope is destroyed, active requests and subscriptions are cancelled automatically.
Named Client-Side Types
Section titled “Named Client-Side Types”Use ClientProxyObject<T> to get the fully-typed client API shape, which is useful for deriving types from stream methods without duplicating field selections:
import type { ClientProxyObject } from 'lowlander/client';import type * as API from './server/api.js';
type APIClient = ClientProxyObject<typeof API>;const api: APIClient = new Connection<typeof API>('ws://localhost:8080/').api;
type SomethingType = ReturnType<APIClient['streamSomething']>;const something: SomethingType = api.streamSomething();ClientProxyObject maps server return types to their client-side equivalents. Stream methods return PromiseProxy<ProjectedData>, plain values return PromiseProxy<T>, and ServerProxy<API, R> methods return a proxy with a .serverProxy of type ClientProxyObject<SubAPI>.
Logging
Section titled “Logging”Set the LOWLANDER_LOG_LEVEL environment variable to a number from 0 to 3:
- 0: no logging (default)
- 1: connections & lifecycle
- 2: RPC calls & responses
- 3: model streaming & internals
Set EDINBURGH_LOG_LEVEL similarly for Edinburgh internals.
Dashboard
Section titled “Dashboard”Lowlander ships with an optional admin/developer dashboard for inspecting Edinburgh models, browsing index rows, listing RPC methods, viewing source code, and peeking at warpsocket debug state (channels, sockets, workers, KV). It’s a single self-contained HTML bundle.
To enable it:
-
Re-export
_dashboardfrom your top-level API module:server/api.ts export { _dashboard } from "lowlander/dashboard"; -
Serve the bundled HTML by calling
serveDashboard(res)from awarpsockethandleHttpRequestexport:import type { HttpRequest, HttpResponse } from "warpsocket";import { serveDashboard } from "lowlander/dashboard";export function handleHttpRequest(req: HttpRequest, res: HttpResponse) {if (req.url === '/_dashboard' || req.url.startsWith('/_dashboard?')) {return serveDashboard(res);}// … serve your own static files …} -
On first server start (per warpsocket KV namespace), a random password is generated and printed to the console. Override with the
LOWLANDER_DASHBOARD_PASSWORDenv var.
The dashboard prompts for the websocket URL (defaults to the current host) and password on first load, then stores them in localStorage.
Server API Reference
Section titled “Server API Reference”The following is auto-generated from server/server.ts:
getStreamTypesForModel · function
Section titled “getStreamTypesForModel · function”Signature: (Model: AnyModelClass) => readonly (typeof StreamTypeBase<unknown>)[]
Parameters:
Model: E.AnyModelClass
createStreamType · function
Section titled “createStreamType · function”Creates a stream type for reactive model streaming to clients with automatic updates.
Specify which fields to include; when they change, updates are pushed to subscribed clients. Supports nested linked models and type-safe field selection.
Signature: <T, S extends FieldSelection<T>>(Model: object & ModelClassRuntime<any, readonly any[], any, any> & (new (initial?: Partial<any>, txn?: Transaction) => any) & (new (...args: any[]) => T), selection: S & ValidateSelection<...>, options?: { ...; }) => { ...; }
Type Parameters:
TS extends FieldSelection<T>
Parameters:
Model: E.AnyModelClass & (new (...args: any[]) => T)- - The Edinburgh model classselection: S & ValidateSelection<T, S>- - Field selection:truefor simple fields, nested object for linked modelsoptions?: { cache?: number }- - Optional settings
Returns: Stream type class to instantiate in API functions
Examples:
const Person = E.defineModel('Person', class { name = E.field(E.string); age = E.field(E.number); password = E.field(E.string); friends = E.field(E.array(E.link(() => Person)));}, { pk: 'name' });
// Exclude password, include friends' names; cache 30sconst PersonStream = createStreamType(Person, { name: true, age: true, friends: { name: true }}, { cache: 30 });
export function streamPerson() { const person = Person.get('Alice')!; return new PersonStream(person);}sendModel · function
Section titled “sendModel · function”Sends (updated) data for model to target.
target is a virtual socket with a requestId+‘d’ user prefix, or a channel that subscribes such virtual sockets.
Signature: (target: number | Uint8Array<ArrayBufferLike> | number[], model: any, commitId: number, StreamType: typeof StreamTypeBase<any>, changed?: Change) => void
Parameters:
target: Uint8Array | number | number[]model: E.Model<any>commitId: numberStreamType: typeof StreamTypeBase<any>changed?: E.Change
pushModel · function
Section titled “pushModel · function”Subscribes target to this model, and sends initial data.
target is a virtual socket with a requestId+‘d’ user prefix, or a channel that subscribes such virtual sockets.
Signature: (target: number | Uint8Array<ArrayBufferLike> | number[], model: any, commitId: number, SubStreamType: typeof StreamTypeBase<any>, delta: number) => void
Parameters:
target: number | Uint8Array | number[]model: E.Model<any>commitId: numberSubStreamType: typeof StreamTypeBase<any>delta: number
Starts the Lowlander WebSocket server.
Signature: (mainApiFile: string, opts?: { bind?: string; threads?: number; injectWarpSocket?: typeof import("/var/home/frank/projects/warpsocket/dist/src/index", { with: { "resolution-mode": "import" } }); }) => Promise<void>
Parameters:
mainApiFile: string- - Absolute path to the compiled API file exporting server functionsopts: {bind?: string, threads?: number, injectWarpSocket?: typeof realWarpsocket}(optional)
Examples:
import { start } from 'lowlander/server';import { fileURLToPath } from 'url';import { resolve, dirname } from 'path';
const API_FILE = resolve(dirname(fileURLToPath(import.meta.url)), 'api.js');start(API_FILE, { bind: '0.0.0.0:8080' });logLevel · constant
Section titled “logLevel · constant”Value: number
warpsocket · class
Section titled “warpsocket · class”Type: typeof import("/var/home/frank/projects/warpsocket/dist/src/index", { with: { "resolution-mode": "import" } })
StreamTypeBase · abstract class
Section titled “StreamTypeBase · abstract class”Base class for stream types created by .
Type Parameters:
T
StreamTypeBase.fields · static property
Section titled “StreamTypeBase.fields · static property”Type: { [key: string]: number | boolean; }
StreamTypeBase.id · static property
Section titled “StreamTypeBase.id · static property”Type: number
StreamTypeBase.cache · static property
Section titled “StreamTypeBase.cache · static property”Type: number
streamTypeBase.toString · method
Section titled “streamTypeBase.toString · method”Signature: () => string
ServerProxy · class
Section titled “ServerProxy · class”Wraps a server-side API object to create a stateful, type-safe proxy accessible from clients. Use for authentication, sessions, or any stateful context that persists across RPC calls.
If the API object has an onDrop() method, it is called when the proxy is dropped, either
because the client cancelled the request (scope cleanup) or the WebSocket disconnected.
Use this to clean up server-side state kept on behalf of the client.
Type Parameters:
API extends objectRETURN
Examples:
export class UserAPI { constructor(public user: User) {} getSecret() { return this.user.secret; } onDrop() { console.log('client gone'); }}
export async function authenticate(token: string) { const user = await validateToken(token); return new ServerProxy(new UserAPI(user), user.name);}
// Client: auth.value is user name, auth.serverProxy.getSecret() calls UserAPI methodConstructor Parameters:
api: - Server-side API object exposed to the clientvalue: - Value returned immediately to the client
serverProxy.toString · method
Section titled “serverProxy.toString · method”Signature: () => string
Socket · class
Section titled “Socket · class”Server-side socket for pushing data to a client. Server functions with Socket<T> parameters
receive client callbacks on the client side.
Type Parameters:
T
Examples:
// Serverexport function streamNumbers(socket: Socket<number>) { setInterval(() => { if (!socket.send(Math.random())) clearInterval(interval); }, 1000);}
// Clientapi.streamNumbers(num => console.log(num));socket.send · method
Section titled “socket.send · method”Sends data to the client.
Signature: (data: T) => number
Parameters:
data: T- - Data to send (automatically serialized)
Returns: true if sent, false if socket is closed
socket.subscribe · method
Section titled “socket.subscribe · method”Signature: (channel: Uint8Array<ArrayBufferLike>, delta?: number) => void
Parameters:
channel: Uint8Arraydelta: any(optional)
socket.toString · method
Section titled “socket.toString · method”Signature: () => string
socket.[Symbol.for(‘nodejs.util.inspect.custom’)] · method
Section titled “socket.[Symbol.for(‘nodejs.util.inspect.custom’)] · method”Signature: () => string
Client API Reference
Section titled “Client API Reference”The following is auto-generated from client/client.ts:
setLogLevel · function
Section titled “setLogLevel · function”Set to 0-3 for increasing verbosity.
Signature: (level: number) => void
Parameters:
level: number
ClientProxyObject · type
Section titled “ClientProxyObject · type”Transforms server-side API objects to client-side proxy objects with type-safe RPC methods.
Type: { [K in keyof T]: ClientProxyFunction<T[K]> }
Connection · class
Section titled “Connection · class”WebSocket connection to a Lowlander server with type-safe RPC, automatic reconnection, and reactive updates.
Type Parameters:
T
Examples:
import type * as API from './server/api.js';const conn = new Connection<typeof API>('ws://localhost:8080/');
// Simple RPC - returns PromiseProxyconst sum = conn.api.add(1, 2);
// Server proxy for stateful APIsconst auth = conn.api.authenticate('token');const secret = auth.serverProxy.getSecret();
// Streaming with callbacksconn.api.streamData(data => console.log(data));
// Use within Aberdeen reactive scopes$(() => { dump(conn.isOnline()); dump(sum);});Constructor Parameters:
url: - WebSocket URL (e.g., ‘ws://localhost:8080/’), or a fake WebSocket object for testing
connection.[A.OPAQUE] · getter
Section titled “connection.[A.OPAQUE] · getter”Type: true
connection.api · property
Section titled “connection.api · property”Type-safe proxy to the server-side API. Methods return PromiseProxy objects
that work reactively in Aberdeen scopes. ServerProxy returns include a
.serverProxy property for accessing stateful server APIs.
Type: ClientProxyObject<T>
connection.isOnline · method
Section titled “connection.isOnline · method”Returns the current connection status. Reactive in Aberdeen scopes.
Signature: () => boolean
connection.getError · method
Section titled “connection.getError · method”Returns the last WebSocket error message, or undefined if there is none.
Clears automatically when the connection comes online. Reactive in Aberdeen scopes.
Signature: () => string