lowlander 0.4.0 → 0.6.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 +108 -78
- package/build/client/client.d.ts +8 -1
- package/build/client/client.js +44 -22
- package/build/client/client.js.map +1 -1
- package/build/dashboard/client/crud.d.ts +16 -0
- package/build/dashboard/client/crud.js +525 -0
- package/build/dashboard/client/crud.js.map +1 -0
- package/build/dashboard/client/main.d.ts +1 -0
- package/build/dashboard/client/main.js +615 -0
- package/build/dashboard/client/main.js.map +1 -0
- package/build/dashboard/client/shim-server.d.ts +3 -0
- package/build/dashboard/client/shim-server.js +2 -0
- package/build/dashboard/client/shim-server.js.map +1 -0
- package/build/dashboard/dashboard.html +20 -0
- package/build/dashboard/index.d.ts +18 -0
- package/build/dashboard/index.d.ts.map +1 -0
- package/build/dashboard/index.js +50 -0
- package/build/dashboard/index.js.map +1 -0
- package/build/dashboard/serve.d.ts +18 -0
- package/build/dashboard/serve.d.ts.map +1 -0
- package/build/dashboard/serve.js +53 -0
- package/build/dashboard/serve.js.map +1 -0
- package/build/dashboard/server.d.ts +93 -0
- package/build/dashboard/server.d.ts.map +1 -0
- package/build/dashboard/server.js +384 -0
- package/build/dashboard/server.js.map +1 -0
- package/build/examples/helloworld/.edinburgh/commit_worker.log +4927 -0
- package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
- package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
- package/build/examples/helloworld/client/assets/style.css +0 -45
- package/build/examples/helloworld/client/index.html +3 -14
- package/build/examples/helloworld/client/js/base.css +1 -0
- package/build/examples/helloworld/client/js/base.d.ts +1 -4
- package/build/examples/helloworld/client/js/base.js +8 -71
- package/build/examples/helloworld/client/js/base.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +8 -2
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +29 -8
- package/build/examples/helloworld/server/api.js.map +1 -1
- package/build/examples/helloworld/server/main.d.ts +1 -1
- package/build/examples/helloworld/server/main.d.ts.map +1 -1
- package/build/examples/helloworld/server/main.js +6 -17
- package/build/examples/helloworld/server/main.js.map +1 -1
- package/build/server/password.d.ts +10 -0
- package/build/server/password.d.ts.map +1 -0
- package/build/server/password.js +38 -0
- package/build/server/password.js.map +1 -0
- package/build/server/server.d.ts +5 -3
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +65 -7
- package/build/server/server.js.map +1 -1
- package/build/server/wshandler.d.ts +7 -1
- package/build/server/wshandler.d.ts.map +1 -1
- package/build/server/wshandler.js +54 -14
- 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 +47 -24
- package/dashboard/build-bundle.ts +44 -0
- package/dashboard/client/crud.ts +634 -0
- package/dashboard/client/index.html +12 -0
- package/dashboard/client/main.ts +554 -0
- package/dashboard/client/shim-server.ts +5 -0
- package/dashboard/index.ts +49 -0
- package/dashboard/server.ts +399 -0
- package/package.json +26 -11
- package/server/server.ts +61 -10
- package/server/wshandler.ts +57 -13
- package/skill/SKILL.md +82 -51
- package/skill/getStreamTypesForModel.md +7 -0
- package/skill/Connection_pruneCommitIds.md +0 -8
package/server/wshandler.ts
CHANGED
|
@@ -2,9 +2,13 @@ import DataPack from 'edinburgh/datapack';
|
|
|
2
2
|
import { warpsocket, Socket, StreamTypeBase, pushModel, ServerProxy, logLevel } from './server.js';
|
|
3
3
|
import { SERVER_MESSAGES, CLIENT_MESSAGES } from './protocol.js';
|
|
4
4
|
import * as E from 'edinburgh';
|
|
5
|
+
import type { HttpRequest, HttpResponse } from 'warpsocket';
|
|
5
6
|
|
|
6
7
|
let mainApi: object | undefined;
|
|
7
8
|
|
|
9
|
+
/** @internal Used by the dashboard module to introspect the user's API. */
|
|
10
|
+
export function getMainApi(): object | undefined { return mainApi; }
|
|
11
|
+
|
|
8
12
|
export const socketProxies = new Map<number, Map<number, any>>(); // {socketId: {proxyId: proxyObject}}
|
|
9
13
|
|
|
10
14
|
export interface Request {
|
|
@@ -26,16 +30,40 @@ function sendError(socketId: number, requestId: number, message: string) {
|
|
|
26
30
|
send(socketId, requestId, SERVER_MESSAGES.error, message);
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
let dashboardPassword = '';
|
|
34
|
+
/** @internal Used by the dashboard module to authenticate requests. */
|
|
35
|
+
export function getPassword(): string { return dashboardPassword; }
|
|
36
|
+
|
|
37
|
+
export async function handleStart(arg: any) {
|
|
38
|
+
const { apiFile, password } = arg;
|
|
39
|
+
dashboardPassword = password;
|
|
30
40
|
if (logLevel >= 1) console.log('[lowlander] Worker started, loading', apiFile);
|
|
31
41
|
mainApi = await import(apiFile);
|
|
32
42
|
}
|
|
33
43
|
|
|
44
|
+
export async function handleHttpRequest(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
45
|
+
const handler = (mainApi as any)?.handleHttpRequest;
|
|
46
|
+
if (typeof handler === 'function') {
|
|
47
|
+
return handler(req, res);
|
|
48
|
+
}
|
|
49
|
+
res.statusCode = 404;
|
|
50
|
+
res.setHeader('content-type', 'text/plain');
|
|
51
|
+
res.end('Not found');
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
|
|
35
55
|
export async function handleBinaryMessage(message: Uint8Array, socketId: number) {
|
|
56
|
+
if (logLevel >= 2) console.log('[lowlander] handleBinaryMessage socket', socketId, 'bytes', message.length, 'hex', Array.from(message.slice(0, 16)).map(b => b.toString(16).padStart(2,'0')).join(' '));
|
|
36
57
|
const pack = new DataPack(message);
|
|
37
|
-
|
|
38
|
-
|
|
58
|
+
let requestId: number;
|
|
59
|
+
let type: number;
|
|
60
|
+
try {
|
|
61
|
+
requestId = pack.readPositiveInt();
|
|
62
|
+
type = pack.readNumber();
|
|
63
|
+
} catch (e: any) {
|
|
64
|
+
console.error('[lowlander] Failed to parse message header:', e.message, 'hex:', Array.from(message.slice(0, 16)).map(b => b.toString(16).padStart(2,'0')).join(' '));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
39
67
|
|
|
40
68
|
if (type === CLIENT_MESSAGES.cancel) {
|
|
41
69
|
// Delete server proxy object, if any
|
|
@@ -81,7 +109,16 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
81
109
|
// Obtain function reference
|
|
82
110
|
const methodName = pack.readString();
|
|
83
111
|
let func = (api as any)[methodName];
|
|
84
|
-
|
|
112
|
+
// `_dashboard` is the one underscore-prefixed name reserved for the
|
|
113
|
+
// dashboard module; everything else with a leading underscore is private.
|
|
114
|
+
// Also block: classes (typeof === 'function' but not callable as plain
|
|
115
|
+
// function), and Object.prototype methods accessible via prototype chain.
|
|
116
|
+
if (
|
|
117
|
+
typeof func !== 'function' ||
|
|
118
|
+
(methodName.startsWith('_') && methodName !== '_dashboard') ||
|
|
119
|
+
Function.prototype.toString.call(func).trimStart().startsWith('class ') ||
|
|
120
|
+
Object.prototype.hasOwnProperty.call(Object.prototype, methodName)
|
|
121
|
+
) {
|
|
85
122
|
return sendError(socketId, requestId, `Method not found: ${methodName}`);
|
|
86
123
|
}
|
|
87
124
|
|
|
@@ -107,15 +144,21 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
107
144
|
// Result processing/serialization should be within the transaction, as it may involve (lazy) loading models.
|
|
108
145
|
// The actual socket send remains deferred until after commit.
|
|
109
146
|
|
|
110
|
-
|
|
147
|
+
// NOTE: Use duck-typing instead of instanceof for ServerProxy and StreamTypeBase,
|
|
148
|
+
// because api.js and wshandler.js each bundle their own copy of server.ts,
|
|
149
|
+
// so instanceof checks across those two bundles always return false.
|
|
150
|
+
// E.Model is safe to use because edinburgh is externalized (shared).
|
|
151
|
+
if (response !== null && typeof response === 'object' && 'api' in response && 'value' in response) {
|
|
152
|
+
const serverProxyResponse = response as ServerProxy<any, any>;
|
|
111
153
|
let proxies = socketProxies.get(socketId);
|
|
112
154
|
if (!proxies) socketProxies.set(socketId, proxies = new Map());
|
|
113
155
|
if (logLevel >= 3) console.log('[lowlander] Setting proxy id', requestId, 'for socket', socketId);
|
|
114
|
-
proxies.set(requestId,
|
|
156
|
+
proxies.set(requestId, serverProxyResponse.api);
|
|
115
157
|
|
|
116
|
-
if (
|
|
117
|
-
const
|
|
118
|
-
const
|
|
158
|
+
if (serverProxyResponse.value !== null && typeof serverProxyResponse.value === 'object' && (serverProxyResponse.value as any)._instance instanceof E.Model) {
|
|
159
|
+
const streamValue = serverProxyResponse.value as unknown as StreamTypeBase<any>;
|
|
160
|
+
const StreamType = streamValue.constructor as typeof StreamTypeBase<any>;
|
|
161
|
+
const instance = streamValue._instance;
|
|
119
162
|
|
|
120
163
|
const virtualSocketId = warpsocket.createVirtualSocket(socketId, DataPack.createUint8Array(requestId, SERVER_MESSAGES.model_data));
|
|
121
164
|
virtualSocketIds.push(virtualSocketId);
|
|
@@ -124,12 +167,13 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
|
|
|
124
167
|
const cacheMs = StreamType.cache !== undefined ? StreamType.cache * 1000 : undefined;
|
|
125
168
|
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
|
|
126
169
|
} else {
|
|
127
|
-
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy,
|
|
170
|
+
pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy, serverProxyResponse.value, virtualSocketIds);
|
|
128
171
|
}
|
|
129
172
|
|
|
130
|
-
} else if (response instanceof
|
|
131
|
-
const
|
|
132
|
-
const
|
|
173
|
+
} else if (response !== null && typeof response === 'object' && (response as any)._instance instanceof E.Model) {
|
|
174
|
+
const streamResponse = response as unknown as StreamTypeBase<any>;
|
|
175
|
+
const StreamType = streamResponse.constructor as typeof StreamTypeBase<any>;
|
|
176
|
+
const instance = streamResponse._instance;
|
|
133
177
|
|
|
134
178
|
// Create a virtual socket for the model updates, prefixed by requestId + 'd'
|
|
135
179
|
const virtualSocketId = warpsocket.createVirtualSocket(socketId, DataPack.createUint8Array(requestId, SERVER_MESSAGES.model_data));
|
package/skill/SKILL.md
CHANGED
|
@@ -25,17 +25,29 @@ This library is built on top of a number of libraries by the same author:
|
|
|
25
25
|
|
|
26
26
|
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.
|
|
27
27
|
|
|
28
|
+
## Example project
|
|
29
|
+
|
|
30
|
+
An example project is included in `examples/helloworld`. To run it:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm run example
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Opens at http://localhost:8080 with the Aberdeen dashboard at http://localhost:8080/_dashboard (password printed to console on start).
|
|
37
|
+
|
|
38
|
+
This is what the example dashboard looks like:
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
28
42
|
## Tutorial
|
|
29
43
|
|
|
30
44
|
### Project Setup
|
|
31
45
|
|
|
32
46
|
```bash
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
npm init
|
|
48
|
+
npm add lowlander aberdeen edinburgh
|
|
35
49
|
```
|
|
36
50
|
|
|
37
|
-
(npm should also work for all of this.)
|
|
38
|
-
|
|
39
51
|
Create the project structure:
|
|
40
52
|
|
|
41
53
|
```
|
|
@@ -150,6 +162,24 @@ const PersonStream = createStreamType(Person, fields, { cache: 30 }); // cache f
|
|
|
150
162
|
|
|
151
163
|
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
164
|
|
|
165
|
+
#### Virtual (computed) fields
|
|
166
|
+
|
|
167
|
+
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:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const Person = E.defineModel('Person', class {
|
|
171
|
+
name = E.field(E.string);
|
|
172
|
+
age = E.field(E.number);
|
|
173
|
+
get greeting() { return `Hi, I'm ${this.name} and I'm ${this.age}!`; }
|
|
174
|
+
}, { pk: 'name' });
|
|
175
|
+
|
|
176
|
+
const PersonStream = createStreamType(Person, {
|
|
177
|
+
greeting: true,
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
153
183
|
### ServerProxy for Stateful APIs
|
|
154
184
|
|
|
155
185
|
Wrap a class instance to expose per-connection stateful methods:
|
|
@@ -339,10 +369,50 @@ Set the `LOWLANDER_LOG_LEVEL` environment variable to a number from 0 to 3:
|
|
|
339
369
|
|
|
340
370
|
Set `EDINBURGH_LOG_LEVEL` similarly for Edinburgh internals.
|
|
341
371
|
|
|
372
|
+
### Dashboard
|
|
373
|
+
|
|
374
|
+
Lowlander ships with an optional admin/developer dashboard for inspecting
|
|
375
|
+
Edinburgh models, browsing index rows, listing RPC methods, viewing source
|
|
376
|
+
code, and peeking at warpsocket debug state (channels, sockets, workers,
|
|
377
|
+
KV). It's a single self-contained HTML bundle.
|
|
378
|
+
|
|
379
|
+
To enable it:
|
|
380
|
+
|
|
381
|
+
1. Re-export `_dashboard` from your top-level API module:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
// server/api.ts
|
|
385
|
+
export { _dashboard } from "lowlander/dashboard";
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
2. Serve the bundled HTML by calling `serveDashboard(res)` from a
|
|
389
|
+
`warpsocket` `handleHttpRequest` export:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
import type { HttpRequest, HttpResponse } from "warpsocket";
|
|
393
|
+
import { serveDashboard } from "lowlander/dashboard";
|
|
394
|
+
|
|
395
|
+
export function handleHttpRequest(req: HttpRequest, res: HttpResponse) {
|
|
396
|
+
if (req.url === '/_dashboard' || req.url.startsWith('/_dashboard?')) {
|
|
397
|
+
return serveDashboard(res);
|
|
398
|
+
}
|
|
399
|
+
// … serve your own static files …
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
3. On first server start (per warpsocket KV namespace), a random password
|
|
404
|
+
is generated and printed to the console. Override with the
|
|
405
|
+
`LOWLANDER_DASHBOARD_PASSWORD` env var.
|
|
406
|
+
|
|
407
|
+
The dashboard prompts for the websocket URL (defaults to the current host)
|
|
408
|
+
and password on first load, then stores them in localStorage.
|
|
409
|
+
|
|
342
410
|
## Server API Reference
|
|
343
411
|
|
|
344
412
|
The following is auto-generated from `server/server.ts`:
|
|
345
413
|
|
|
414
|
+
### [getStreamTypesForModel](getStreamTypesForModel.md) · function
|
|
415
|
+
|
|
346
416
|
### [createStreamType](createStreamType.md) · function
|
|
347
417
|
|
|
348
418
|
Creates a stream type for reactive model streaming to clients with automatic updates.
|
|
@@ -369,7 +439,7 @@ Starts the Lowlander WebSocket server.
|
|
|
369
439
|
|
|
370
440
|
### StreamTypeBase · abstract class
|
|
371
441
|
|
|
372
|
-
|
|
442
|
+
Base class for stream types created by .
|
|
373
443
|
|
|
374
444
|
**Type Parameters:**
|
|
375
445
|
|
|
@@ -377,7 +447,7 @@ Starts the Lowlander WebSocket server.
|
|
|
377
447
|
|
|
378
448
|
#### StreamTypeBase.fields · static property
|
|
379
449
|
|
|
380
|
-
**Type:** `{ [key: string]: number |
|
|
450
|
+
**Type:** `{ [key: string]: number | boolean; }`
|
|
381
451
|
|
|
382
452
|
#### StreamTypeBase.id · static property
|
|
383
453
|
|
|
@@ -446,29 +516,9 @@ Transforms server-side API objects to client-side proxy objects with type-safe R
|
|
|
446
516
|
WebSocket connection to a Lowlander server with type-safe RPC, automatic reconnection,
|
|
447
517
|
and reactive updates.
|
|
448
518
|
|
|
449
|
-
#### connection.
|
|
450
|
-
|
|
451
|
-
**Type:** `WebSocket`
|
|
452
|
-
|
|
453
|
-
#### connection.activeRequests · property
|
|
454
|
-
|
|
455
|
-
**Type:** `Map<number, ActiveRequest>`
|
|
456
|
-
|
|
457
|
-
#### connection.requestCounter · property
|
|
458
|
-
|
|
459
|
-
**Type:** `number`
|
|
460
|
-
|
|
461
|
-
#### connection.reconnectAttempts · property
|
|
462
|
-
|
|
463
|
-
**Type:** `number`
|
|
464
|
-
|
|
465
|
-
#### connection.onlineProxy · property
|
|
466
|
-
|
|
467
|
-
**Type:** `ValueRef<boolean>`
|
|
519
|
+
#### connection.[A.OPAQUE] · getter
|
|
468
520
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
**Type:** `Map<string, StreamCacheEntry>`
|
|
521
|
+
**Type:** `true`
|
|
472
522
|
|
|
473
523
|
#### connection.api · property
|
|
474
524
|
|
|
@@ -484,29 +534,10 @@ Returns the current connection status. Reactive in Aberdeen scopes.
|
|
|
484
534
|
|
|
485
535
|
**Signature:** `() => boolean`
|
|
486
536
|
|
|
487
|
-
#### connection.
|
|
488
|
-
|
|
489
|
-
**Signature:** `() => void`
|
|
490
|
-
|
|
491
|
-
#### connection.reconnect · method
|
|
492
|
-
|
|
493
|
-
**Signature:** `() => void`
|
|
494
|
-
|
|
495
|
-
#### [connection.pruneCommitIds](Connection_pruneCommitIds.md) · method
|
|
496
|
-
|
|
497
|
-
#### connection.cancelRequest · method
|
|
498
|
-
|
|
499
|
-
**Signature:** `(request: ActiveRequest) => void`
|
|
537
|
+
#### connection.getError · method
|
|
500
538
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
- `request: ActiveRequest`
|
|
504
|
-
|
|
505
|
-
#### connection.startLinger · method
|
|
506
|
-
|
|
507
|
-
**Signature:** `(cached: StreamCacheEntry) => void`
|
|
539
|
+
Returns the last WebSocket error message, or `undefined` if there is none.
|
|
540
|
+
Clears automatically when the connection comes online. Reactive in Aberdeen scopes.
|
|
508
541
|
|
|
509
|
-
**
|
|
510
|
-
|
|
511
|
-
- `cached: StreamCacheEntry`
|
|
542
|
+
**Signature:** `() => string`
|
|
512
543
|
|