lowlander 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -51
- package/build/client/client.d.ts +8 -2
- package/build/client/client.js +10 -4
- package/build/client/client.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +40 -23
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +59 -41
- package/build/examples/helloworld/server/api.js.map +1 -1
- package/build/server/protocol.d.ts +1 -0
- package/build/server/protocol.d.ts.map +1 -1
- package/build/server/protocol.js +1 -0
- package/build/server/protocol.js.map +1 -1
- package/build/server/server.d.ts +22 -22
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +15 -11
- package/build/server/server.js.map +1 -1
- package/build/server/wshandler.d.ts +1 -1
- package/build/server/wshandler.d.ts.map +1 -1
- package/build/server/wshandler.js +48 -8
- package/build/server/wshandler.js.map +1 -1
- package/build/tsconfig.client.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/client/client.ts +15 -8
- package/package.json +3 -3
- package/server/protocol.ts +1 -0
- package/server/server.ts +56 -36
- package/server/wshandler.ts +48 -9
- package/skill/SKILL.md +48 -8
- package/skill/ServerProxy.md +5 -0
- package/skill/createStreamType.md +9 -10
- package/skill/pushModel.md +1 -1
- package/skill/sendModel.md +1 -1
package/server/wshandler.ts
CHANGED
|
@@ -41,7 +41,18 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
41
41
|
// Delete server proxy object, if any
|
|
42
42
|
const cancelRequestId = pack.readPositiveInt();
|
|
43
43
|
const proxies = socketProxies.get(socketId);
|
|
44
|
-
if (proxies)
|
|
44
|
+
if (proxies) {
|
|
45
|
+
/** Call `onDrop()` on a proxy if it has one. Runs in a transaction; errors are logged. */
|
|
46
|
+
const proxy = proxies.get(cancelRequestId);
|
|
47
|
+
proxies.delete(cancelRequestId);
|
|
48
|
+
if (proxy && typeof proxy.onDrop === 'function') {
|
|
49
|
+
try {
|
|
50
|
+
await E.transact(() => proxy.onDrop());
|
|
51
|
+
} catch (err: any) {
|
|
52
|
+
console.error('cancel request onDrop error', err);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
|
|
46
57
|
// Delete any virtual sockets created for this request
|
|
47
58
|
for(const virtualSocketId of pack.read() || []) {
|
|
@@ -88,20 +99,33 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
try {
|
|
91
|
-
let
|
|
102
|
+
let pendingPacket: Uint8Array | undefined;
|
|
92
103
|
await E.transact(async () => {
|
|
93
104
|
let response = await func.apply(api, params);
|
|
94
105
|
if (logLevel >= 2) console.log('[lowlander] Called', methodName, 'with', params, '->', typeof response === 'object' && response ? response.toString() : JSON.stringify(response));
|
|
95
106
|
|
|
96
|
-
// Result processing/
|
|
107
|
+
// Result processing/serialization should be within the transaction, as it may involve (lazy) loading models.
|
|
108
|
+
// The actual socket send remains deferred until after commit.
|
|
97
109
|
|
|
98
110
|
if (response instanceof ServerProxy) {
|
|
99
111
|
let proxies = socketProxies.get(socketId);
|
|
100
112
|
if (!proxies) socketProxies.set(socketId, proxies = new Map());
|
|
101
113
|
if (logLevel >= 3) console.log('[lowlander] Setting proxy id', requestId, 'for socket', socketId);
|
|
102
114
|
proxies.set(requestId, response.api);
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
|
|
116
|
+
if (response.value instanceof StreamTypeBase) {
|
|
117
|
+
const StreamType = response.value.constructor as typeof StreamTypeBase<any>;
|
|
118
|
+
const instance = response.value._instance;
|
|
119
|
+
|
|
120
|
+
const virtualSocketId = warpsocket.createVirtualSocket(socketId, DataPack.createUint8Array(requestId, SERVER_MESSAGES.model_data));
|
|
121
|
+
virtualSocketIds.push(virtualSocketId);
|
|
122
|
+
pushModel(virtualSocketId, instance, 0, StreamType, 1);
|
|
123
|
+
|
|
124
|
+
const cacheMs = StreamType.cache !== undefined ? StreamType.cache * 1000 : undefined;
|
|
125
|
+
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
|
|
126
|
+
} else {
|
|
127
|
+
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy, response.value, virtualSocketIds);
|
|
128
|
+
}
|
|
105
129
|
|
|
106
130
|
} else if (response instanceof StreamTypeBase) {
|
|
107
131
|
const StreamType = response.constructor as typeof StreamTypeBase<any>;
|
|
@@ -116,14 +140,14 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
116
140
|
|
|
117
141
|
// Then respond, indicating which row should be top level
|
|
118
142
|
const cacheMs = StreamType.cache !== undefined ? StreamType.cache * 1000 : undefined;
|
|
119
|
-
|
|
143
|
+
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
|
|
120
144
|
} else {
|
|
121
145
|
// A regular result
|
|
122
|
-
|
|
146
|
+
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response, response, virtualSocketIds);
|
|
123
147
|
}
|
|
124
148
|
});
|
|
125
149
|
// Send response after transaction has committed
|
|
126
|
-
|
|
150
|
+
warpsocket.send(socketId, pendingPacket!);
|
|
127
151
|
} catch (error: any) {
|
|
128
152
|
console.error('RPC error', error);
|
|
129
153
|
sendError(socketId, requestId, error.message || 'Internal error');
|
|
@@ -133,7 +157,22 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
133
157
|
}
|
|
134
158
|
}
|
|
135
159
|
|
|
136
|
-
export function handleClose(socketId: number) {
|
|
160
|
+
export async function handleClose(socketId: number) {
|
|
137
161
|
if (logLevel >= 1) console.log('[lowlander] Client disconnected', socketId);
|
|
162
|
+
const proxies = socketProxies.get(socketId);
|
|
163
|
+
if (!proxies) return;
|
|
138
164
|
socketProxies.delete(socketId);
|
|
165
|
+
if (!proxies.values().some(p => typeof p.onDrop === 'function')) return;
|
|
166
|
+
|
|
167
|
+
await E.transact(async () => {
|
|
168
|
+
for (const proxy of proxies.values()) {
|
|
169
|
+
if (typeof proxy?.onDrop === 'function') {
|
|
170
|
+
try {
|
|
171
|
+
await proxy.onDrop();
|
|
172
|
+
} catch (err: any) {
|
|
173
|
+
console.error('handleClose onDrop error', err);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
139
178
|
}
|
package/skill/SKILL.md
CHANGED
|
@@ -93,14 +93,12 @@ Define persistent data models using Edinburgh. See [Edinburgh docs](https://gith
|
|
|
93
93
|
```ts
|
|
94
94
|
import * as E from 'edinburgh';
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
class Person extends E.Model<Person> {
|
|
98
|
-
static byName = E.primary(Person, 'name');
|
|
96
|
+
const Person = E.defineModel('Person', class {
|
|
99
97
|
name = E.field(E.string);
|
|
100
98
|
age = E.field(E.number);
|
|
101
|
-
friends = E.field(E.array(E.link(Person)));
|
|
99
|
+
friends = E.field(E.array(E.link(() => Person)));
|
|
102
100
|
password = E.field(E.string);
|
|
103
|
-
}
|
|
101
|
+
}, { pk: 'name' });
|
|
104
102
|
```
|
|
105
103
|
|
|
106
104
|
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.
|
|
@@ -127,7 +125,7 @@ Use `true` for plain fields. For linked model fields, provide a nested selection
|
|
|
127
125
|
|
|
128
126
|
```ts
|
|
129
127
|
export function streamPerson(name: string) {
|
|
130
|
-
const person = Person.
|
|
128
|
+
const person = Person.getBy('name', name)!;
|
|
131
129
|
return new PersonStream(person);
|
|
132
130
|
}
|
|
133
131
|
```
|
|
@@ -157,13 +155,14 @@ After a stream with caching goes out of scope, the server keeps it alive for tha
|
|
|
157
155
|
Wrap a class instance to expose per-connection stateful methods:
|
|
158
156
|
|
|
159
157
|
```ts
|
|
158
|
+
// server-side api
|
|
160
159
|
import { ServerProxy } from 'lowlander/server';
|
|
161
160
|
|
|
162
161
|
class UserAPI {
|
|
163
162
|
constructor(public userName: string) {}
|
|
164
163
|
|
|
165
164
|
get user(): Person {
|
|
166
|
-
return Person.
|
|
165
|
+
return Person.getBy('name', this.userName)!;
|
|
167
166
|
}
|
|
168
167
|
|
|
169
168
|
getBio() {
|
|
@@ -172,7 +171,7 @@ class UserAPI {
|
|
|
172
171
|
}
|
|
173
172
|
|
|
174
173
|
export async function authenticate(token: string) {
|
|
175
|
-
const user = Person.
|
|
174
|
+
const user = Person.getBy('name', token);
|
|
176
175
|
if (!user) throw new Error('User not found');
|
|
177
176
|
return new ServerProxy(new UserAPI(token), 'secret-value');
|
|
178
177
|
}
|
|
@@ -180,6 +179,28 @@ export async function authenticate(token: string) {
|
|
|
180
179
|
|
|
181
180
|
The client receives `'secret-value'` as `.value` and can call `UserAPI` methods via `.serverProxy`.
|
|
182
181
|
|
|
182
|
+
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:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
const PersonStream = createStreamType(Person, { name: true, age: true });
|
|
186
|
+
|
|
187
|
+
export async function authenticate(token: string) {
|
|
188
|
+
const user = Person.getBy('name', token);
|
|
189
|
+
if (!user) throw new Error('User not found');
|
|
190
|
+
return new ServerProxy(new UserAPI(token), new PersonStream(user));
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The client gets both `.serverProxy` (for calling `UserAPI` methods) and a live-updating `.value`.
|
|
195
|
+
|
|
196
|
+
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.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
// client-side
|
|
200
|
+
const auth = api.authenticate('Alice');
|
|
201
|
+
dump(auth.serverProxy.getBio());
|
|
202
|
+
```
|
|
203
|
+
|
|
183
204
|
### Socket Callbacks
|
|
184
205
|
|
|
185
206
|
Use `Socket<T>` parameters for server-push streaming. On the client, these become callback functions:
|
|
@@ -288,6 +309,25 @@ Reconnection is automatic with exponential backoff.
|
|
|
288
309
|
|
|
289
310
|
Aberdeen's `clean()` handles RPC lifecycle. When a reactive scope is destroyed, active requests and subscriptions are cancelled automatically.
|
|
290
311
|
|
|
312
|
+
|
|
313
|
+
#### Named Client-Side Types
|
|
314
|
+
|
|
315
|
+
Use `ClientProxyObject<T>` to get the fully-typed client API shape, which is useful for deriving types from stream methods without duplicating field selections:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
import type { ClientProxyObject } from 'lowlander/client';
|
|
319
|
+
import type * as API from './server/api.js';
|
|
320
|
+
|
|
321
|
+
type APIClient = ClientProxyObject<typeof API>;
|
|
322
|
+
const api: APIClient = new Connection<typeof API>('ws://localhost:8080/').api;
|
|
323
|
+
|
|
324
|
+
type SomethingType = ReturnType<APIClient['streamSomething']>;
|
|
325
|
+
const something: SomethingType = api.streamSomething();
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`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>`.
|
|
329
|
+
|
|
330
|
+
|
|
291
331
|
### Logging
|
|
292
332
|
|
|
293
333
|
Set the `LOWLANDER_LOG_LEVEL` environment variable to a number from 0 to 3:
|
package/skill/ServerProxy.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
Wraps a server-side API object to create a stateful, type-safe proxy accessible from clients.
|
|
4
4
|
Use for authentication, sessions, or any stateful context that persists across RPC calls.
|
|
5
5
|
|
|
6
|
+
If the API object has an `onDrop()` method, it is called when the proxy is dropped, either
|
|
7
|
+
because the client cancelled the request (scope cleanup) or the WebSocket disconnected.
|
|
8
|
+
Use this to clean up server-side state kept on behalf of the client.
|
|
9
|
+
|
|
6
10
|
**Type Parameters:**
|
|
7
11
|
|
|
8
12
|
- `API extends object`
|
|
@@ -14,6 +18,7 @@ Use for authentication, sessions, or any stateful context that persists across R
|
|
|
14
18
|
export class UserAPI {
|
|
15
19
|
constructor(public user: User) {}
|
|
16
20
|
getSecret() { return this.user.secret; }
|
|
21
|
+
onDrop() { console.log('client gone'); }
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export async function authenticate(token: string) {
|
|
@@ -5,7 +5,7 @@ Creates a stream type for reactive model streaming to clients with automatic upd
|
|
|
5
5
|
Specify which fields to include; when they change, updates are pushed to subscribed clients.
|
|
6
6
|
Supports nested linked models and type-safe field selection.
|
|
7
7
|
|
|
8
|
-
**Signature:** `<T, S extends FieldSelection<T>>(Model:
|
|
8
|
+
**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?: { ...; }) => { ...; }`
|
|
9
9
|
|
|
10
10
|
**Type Parameters:**
|
|
11
11
|
|
|
@@ -14,7 +14,7 @@ Supports nested linked models and type-safe field selection.
|
|
|
14
14
|
|
|
15
15
|
**Parameters:**
|
|
16
16
|
|
|
17
|
-
- `Model:
|
|
17
|
+
- `Model: E.AnyModelClass & (new (...args: any[]) => T)` - - The Edinburgh model class
|
|
18
18
|
- `selection: S & ValidateSelection<T, S>` - - Field selection: `true` for simple fields, nested object for linked models
|
|
19
19
|
- `options?: { cache?: number }` - - Optional settings
|
|
20
20
|
|
|
@@ -23,13 +23,12 @@ Supports nested linked models and type-safe field selection.
|
|
|
23
23
|
**Examples:**
|
|
24
24
|
|
|
25
25
|
```ts
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
26
|
+
const Person = E.defineModel('Person', class {
|
|
27
|
+
name = E.field(E.string);
|
|
28
|
+
age = E.field(E.number);
|
|
29
|
+
password = E.field(E.string);
|
|
30
|
+
friends = E.field(E.array(E.link(() => Person)));
|
|
31
|
+
}, { pk: 'name' });
|
|
33
32
|
|
|
34
33
|
// Exclude password, include friends' names; cache 30s
|
|
35
34
|
const PersonStream = createStreamType(Person, {
|
|
@@ -39,7 +38,7 @@ const PersonStream = createStreamType(Person, {
|
|
|
39
38
|
}, { cache: 30 });
|
|
40
39
|
|
|
41
40
|
export function streamPerson() {
|
|
42
|
-
const person = Person.
|
|
41
|
+
const person = Person.get('Alice')!;
|
|
43
42
|
return new PersonStream(person);
|
|
44
43
|
}
|
|
45
44
|
```
|
package/skill/pushModel.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Subscribes `target` to this model, and sends initial data.
|
|
4
4
|
`target` is a virtual socket with a requestId+'d' user prefix, or a channel that subscribes such virtual sockets.
|
|
5
5
|
|
|
6
|
-
**Signature:** `(target: number | Uint8Array<ArrayBufferLike> | number[], model:
|
|
6
|
+
**Signature:** `(target: number | Uint8Array<ArrayBufferLike> | number[], model: any, commitId: number, SubStreamType: typeof StreamTypeBase<any>, delta: number) => void`
|
|
7
7
|
|
|
8
8
|
**Parameters:**
|
|
9
9
|
|
package/skill/sendModel.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Sends (updated) data for `model` to `target`.
|
|
4
4
|
`target` is a virtual socket with a requestId+'d' user prefix, or a channel that subscribes such virtual sockets.
|
|
5
5
|
|
|
6
|
-
**Signature:** `(target: number | Uint8Array<ArrayBufferLike> | number[], model:
|
|
6
|
+
**Signature:** `(target: number | Uint8Array<ArrayBufferLike> | number[], model: any, commitId: number, StreamType: typeof StreamTypeBase<any>, changed?: Change) => void`
|
|
7
7
|
|
|
8
8
|
**Parameters:**
|
|
9
9
|
|