lowlander 0.2.3 → 0.3.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 +102 -48
- package/build/client/client.d.ts +7 -6
- package/build/client/client.js +64 -7
- package/build/client/client.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +25 -8
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +49 -40
- package/build/examples/helloworld/server/api.js.map +1 -1
- package/build/server/protocol.js +1 -1
- package/build/server/protocol.js.map +1 -1
- package/build/server/server.d.ts +26 -18
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +22 -14
- 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 +41 -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 +77 -15
- package/package.json +3 -3
- package/server/protocol.ts +1 -1
- package/server/server.ts +29 -24
- package/server/wshandler.ts +40 -9
- package/skill/SKILL.md +57 -8
- package/skill/ServerProxy.md +5 -0
- package/skill/createStreamType.md +13 -13
- 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,24 @@ 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) {
|
|
111
|
+
if (response.value instanceof StreamTypeBase) {
|
|
112
|
+
throw new Error('ServerProxy values cannot be streamed models; return the stream directly or from a proxy method instead');
|
|
113
|
+
}
|
|
99
114
|
let proxies = socketProxies.get(socketId);
|
|
100
115
|
if (!proxies) socketProxies.set(socketId, proxies = new Map());
|
|
101
116
|
if (logLevel >= 3) console.log('[lowlander] Setting proxy id', requestId, 'for socket', socketId);
|
|
102
117
|
proxies.set(requestId, response.api);
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
|
|
119
|
+
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy, response.value, virtualSocketIds);
|
|
105
120
|
|
|
106
121
|
} else if (response instanceof StreamTypeBase) {
|
|
107
122
|
const StreamType = response.constructor as typeof StreamTypeBase<any>;
|
|
@@ -115,14 +130,15 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
115
130
|
pushModel(virtualSocketId, instance, 0, StreamType, 1);
|
|
116
131
|
|
|
117
132
|
// Then respond, indicating which row should be top level
|
|
118
|
-
|
|
133
|
+
const cacheMs = StreamType.cache !== undefined ? StreamType.cache * 1000 : undefined;
|
|
134
|
+
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
|
|
119
135
|
} else {
|
|
120
136
|
// A regular result
|
|
121
|
-
|
|
137
|
+
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response, response, virtualSocketIds);
|
|
122
138
|
}
|
|
123
139
|
});
|
|
124
140
|
// Send response after transaction has committed
|
|
125
|
-
|
|
141
|
+
warpsocket.send(socketId, pendingPacket!);
|
|
126
142
|
} catch (error: any) {
|
|
127
143
|
console.error('RPC error', error);
|
|
128
144
|
sendError(socketId, requestId, error.message || 'Internal error');
|
|
@@ -132,7 +148,22 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
132
148
|
}
|
|
133
149
|
}
|
|
134
150
|
|
|
135
|
-
export function handleClose(socketId: number) {
|
|
151
|
+
export async function handleClose(socketId: number) {
|
|
136
152
|
if (logLevel >= 1) console.log('[lowlander] Client disconnected', socketId);
|
|
153
|
+
const proxies = socketProxies.get(socketId);
|
|
154
|
+
if (!proxies) return;
|
|
137
155
|
socketProxies.delete(socketId);
|
|
156
|
+
if (!proxies.values().some(p => typeof p.onDrop === 'function')) return;
|
|
157
|
+
|
|
158
|
+
await E.transact(async () => {
|
|
159
|
+
for (const proxy of proxies.values()) {
|
|
160
|
+
if (typeof proxy?.onDrop === 'function') {
|
|
161
|
+
try {
|
|
162
|
+
await proxy.onDrop();
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
console.error('handleClose onDrop error', err);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
138
169
|
}
|
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,25 +125,44 @@ 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
|
```
|
|
134
132
|
|
|
135
133
|
On the client, this returns a reactive Aberdeen proxy that updates live when server data changes.
|
|
136
134
|
|
|
135
|
+
```ts
|
|
136
|
+
// Client-side
|
|
137
|
+
const person = api.streamPerson('Alice');
|
|
138
|
+
// person.value starts as undefined while loading, and
|
|
139
|
+
// then becomes a live-updating reactive proxy object of Alice's data
|
|
140
|
+
A.dump(person);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
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).
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const PersonStream = createStreamType(Person, fields, { cache: 30 }); // cache for 30s after going out of scope
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
137
153
|
### ServerProxy for Stateful APIs
|
|
138
154
|
|
|
139
155
|
Wrap a class instance to expose per-connection stateful methods:
|
|
140
156
|
|
|
141
157
|
```ts
|
|
158
|
+
// server-side api
|
|
142
159
|
import { ServerProxy } from 'lowlander/server';
|
|
143
160
|
|
|
144
161
|
class UserAPI {
|
|
145
162
|
constructor(public userName: string) {}
|
|
146
163
|
|
|
147
164
|
get user(): Person {
|
|
148
|
-
return Person.
|
|
165
|
+
return Person.getBy('name', this.userName)!;
|
|
149
166
|
}
|
|
150
167
|
|
|
151
168
|
getBio() {
|
|
@@ -154,7 +171,7 @@ class UserAPI {
|
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
export async function authenticate(token: string) {
|
|
157
|
-
const user = Person.
|
|
174
|
+
const user = Person.getBy('name', token);
|
|
158
175
|
if (!user) throw new Error('User not found');
|
|
159
176
|
return new ServerProxy(new UserAPI(token), 'secret-value');
|
|
160
177
|
}
|
|
@@ -162,6 +179,14 @@ export async function authenticate(token: string) {
|
|
|
162
179
|
|
|
163
180
|
The client receives `'secret-value'` as `.value` and can call `UserAPI` methods via `.serverProxy`.
|
|
164
181
|
|
|
182
|
+
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.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// client-side
|
|
186
|
+
const auth = api.authenticate('Alice');
|
|
187
|
+
dump(auth.serverProxy.getBio());
|
|
188
|
+
```
|
|
189
|
+
|
|
165
190
|
### Socket Callbacks
|
|
166
191
|
|
|
167
192
|
Use `Socket<T>` parameters for server-push streaming. On the client, these become callback functions:
|
|
@@ -325,6 +350,10 @@ Starts the Lowlander WebSocket server.
|
|
|
325
350
|
|
|
326
351
|
**Type:** `number`
|
|
327
352
|
|
|
353
|
+
#### StreamTypeBase.cache · static property
|
|
354
|
+
|
|
355
|
+
**Type:** `number`
|
|
356
|
+
|
|
328
357
|
#### streamTypeBase.toString · method
|
|
329
358
|
|
|
330
359
|
**Signature:** `() => string`
|
|
@@ -404,6 +433,10 @@ and reactive updates.
|
|
|
404
433
|
|
|
405
434
|
**Type:** `ValueRef<boolean>`
|
|
406
435
|
|
|
436
|
+
#### connection.streamCache · property
|
|
437
|
+
|
|
438
|
+
**Type:** `Map<string, StreamCacheEntry>`
|
|
439
|
+
|
|
407
440
|
#### connection.api · property
|
|
408
441
|
|
|
409
442
|
Type-safe proxy to the server-side API. Methods return `PromiseProxy` objects
|
|
@@ -428,3 +461,19 @@ Returns the current connection status. Reactive in Aberdeen scopes.
|
|
|
428
461
|
|
|
429
462
|
#### [connection.pruneCommitIds](Connection_pruneCommitIds.md) · method
|
|
430
463
|
|
|
464
|
+
#### connection.cancelRequest · method
|
|
465
|
+
|
|
466
|
+
**Signature:** `(request: ActiveRequest) => void`
|
|
467
|
+
|
|
468
|
+
**Parameters:**
|
|
469
|
+
|
|
470
|
+
- `request: ActiveRequest`
|
|
471
|
+
|
|
472
|
+
#### connection.startLinger · method
|
|
473
|
+
|
|
474
|
+
**Signature:** `(cached: StreamCacheEntry) => void`
|
|
475
|
+
|
|
476
|
+
**Parameters:**
|
|
477
|
+
|
|
478
|
+
- `cached: StreamCacheEntry`
|
|
479
|
+
|
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: (new () => any) & ModelClassRuntime<any, readonly any[], any, any> & (new (initial?: Partial<any>, txn?: Transaction) => any) & (new (...args: any[]) => T), selection: S & ValidateSelection<...>, options?: { ...; }) => typeof StreamType`
|
|
9
9
|
|
|
10
10
|
**Type Parameters:**
|
|
11
11
|
|
|
@@ -14,31 +14,31 @@ 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
|
+
- `options?: { cache?: number }` - - Optional settings
|
|
19
20
|
|
|
20
21
|
**Returns:** Stream type class to instantiate in API functions
|
|
21
22
|
|
|
22
23
|
**Examples:**
|
|
23
24
|
|
|
24
25
|
```ts
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Exclude password, include friends' names
|
|
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' });
|
|
32
|
+
|
|
33
|
+
// Exclude password, include friends' names; cache 30s
|
|
34
34
|
const PersonStream = createStreamType(Person, {
|
|
35
35
|
name: true,
|
|
36
36
|
age: true,
|
|
37
37
|
friends: { name: true }
|
|
38
|
-
});
|
|
38
|
+
}, { cache: 30 });
|
|
39
39
|
|
|
40
40
|
export function streamPerson() {
|
|
41
|
-
const person = Person.
|
|
41
|
+
const person = Person.get('Alice')!;
|
|
42
42
|
return new PersonStream(person);
|
|
43
43
|
}
|
|
44
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
|
|