---
name: lowlander
description: Expert guidance for building full-stack TypeScript apps with Lowlander. Covers type-safe RPCs, Edinburgh model streaming, reactive client sync, ServerProxy, Socket callbacks, and Connection setup.
---

# 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](https://github.com/vanviegen/lowlander/blob/main/examples/helloworld/server/api.ts) and
 - [client-side UI](https://github.com/vanviegen/lowlander/blob/main/examples/helloworld/client/js/base.ts) code.

## Tech

This library is built on top of a number of libraries by the same author:

- [Edinburgh](https://github.com/vanviegen/edinburgh): use JavaScript objects as really fast ACID database records.
- [OLMDB](https://github.com/vanviegen/olmdb): a very fast on-disk key/value store with MVCC and optimistic transactions, used by Edinburgh for persistence.
- [WarpSocket](https://github.com/vanviegen/warpsocket): a high-performance WebSocket server written in Rust, that coordinates multiple JavaScript worker threads and provides an API for channel subscriptions.
- [Aberdeen](https://github.com/vanviegen/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

An example project is included in `examples/helloworld`. To run it:

```bash
npm run example
```

Opens at http://localhost:8080 with the Aberdeen dashboard at http://localhost:8080/_dashboard (password printed to console on start).

This is what the example dashboard looks like:

![dashboard screenshot](dashboard_screenshot.png)

## Tutorial

### Project Setup

```bash
npm init
npm add lowlander aberdeen edinburgh
```

Create the project structure:

```
server/
  main.ts     # starts the server
  api.ts      # exported functions = RPC endpoints
client/
  app.ts      # UI using Aberdeen + Connection
```

If 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:

```bash
mkdir -p .claude/skills
ln -s ../../node_modules/lowlander/skill .claude/skills/lowlander
ln -s ../../node_modules/aberdeen/skill .claude/skills/aberdeen
ln -s ../../node_modules/edinburgh/skill .claude/skills/edinburgh
```

### Server Entry Point

The entry point starts the WarpSocket server and points it at the API file:

```ts
// server/main.ts
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

Every exported function in the API file is callable from the client. No decorators or registration needed:

```ts
// server/api.ts
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

Define persistent data models using Edinburgh. See [Edinburgh docs](https://github.com/vanviegen/edinburgh) for full details.

```ts
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`

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:

```ts
import { createStreamType } from 'lowlander/server';

// Exclude password; include friends' names and ages
const 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:

```ts
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.

```ts
// Client-side
const person = api.streamPerson('Alice');
// person.value starts as undefined while loading, and
// then becomes a live-updating reactive proxy object of Alice's data
A.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). 

```ts
const PersonStream = createStreamType(Person, fields, { cache: 30 }); // cache for 30s after going out of scope
```

After 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

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:

```ts
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

Wrap a class instance to expose per-connection stateful methods:

```ts
// server-side api
import { 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:

```ts
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.

```ts
// client-side
const auth = api.authenticate('Alice');
dump(auth.serverProxy.getBio());
```

### Socket Callbacks

Use `Socket<T>` parameters for server-push streaming. On the client, these become callback functions:

```ts
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

Connect to the server with full type safety:

```ts
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

```ts
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 error
```

#### Using ServerProxy

```ts
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

```ts
const person = api.streamPerson('Alice');
// person.value is a reactive proxy that auto-updates
```

#### Socket Callbacks

```ts
api.streamNumbers(num => console.log(num));
```

On the server-side we should have a `export function streamNumbers(socket: Socket<number>)`.

#### Reactive Integration with Aberdeen

`PromiseProxy` results are reactive in Aberdeen scopes:

```ts
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:

```ts
const model = api.streamModel();
A(() => {
    if (!model.value) return;
    A('h2#' + model.value.name);
    A('p#Owner: ' + model.value.owner.name);
});
```

#### Connection Status

```ts
A(() => {
    A('span#' + (conn.isOnline() ? 'Connected' : 'Offline'));
});
```

Reconnection is automatic with exponential backoff.

#### Cleanup

Aberdeen's `clean()` handles RPC lifecycle. When a reactive scope is destroyed, active requests and subscriptions are cancelled automatically.


#### 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:

```ts
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

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

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:

1. Re-export `_dashboard` from your top-level API module:

   ```ts
   // server/api.ts
   export { _dashboard } from "lowlander/dashboard";
   ```

2. Serve the bundled HTML by calling `serveDashboard(res)` from a
   `warpsocket` `handleHttpRequest` export:

   ```ts
   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 …
   }
   ```

3. On first server start (per warpsocket KV namespace), a random password
   is generated and printed to the console. Override with the
   `LOWLANDER_DASHBOARD_PASSWORD` env 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

The following is auto-generated from `server/server.ts`:

### [getStreamTypesForModel](getStreamTypesForModel.md) · function

### [createStreamType](createStreamType.md) · function

Creates a stream type for reactive model streaming to clients with automatic updates.

### [sendModel](sendModel.md) · 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.

### [pushModel](pushModel.md) · 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.

### [start](start.md) · function

Starts the Lowlander WebSocket server.

### logLevel · constant

**Value:** `number`

### [warpsocket](warpsocket.md) · class

### [StreamTypeBase](StreamTypeBase.md) · abstract class

Base class for stream types created by `createStreamType`.

### [ServerProxy](ServerProxy.md) · 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.

### [Socket](Socket.md) · class

Server-side socket for pushing data to a client. Server functions with `Socket<T>` parameters
receive client callbacks on the client side.

## Client API Reference

The following is auto-generated from `client/client.ts`:

### setLogLevel · function

Set to 0-3 for increasing verbosity.

**Signature:** `(level: number) => void`

**Parameters:**

- `level: number`

### 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](Connection.md) · class

WebSocket connection to a Lowlander server with type-safe RPC, automatic reconnection,
and reactive updates.

