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.
Files changed (71) hide show
  1. package/README.md +108 -78
  2. package/build/client/client.d.ts +8 -1
  3. package/build/client/client.js +44 -22
  4. package/build/client/client.js.map +1 -1
  5. package/build/dashboard/client/crud.d.ts +16 -0
  6. package/build/dashboard/client/crud.js +525 -0
  7. package/build/dashboard/client/crud.js.map +1 -0
  8. package/build/dashboard/client/main.d.ts +1 -0
  9. package/build/dashboard/client/main.js +615 -0
  10. package/build/dashboard/client/main.js.map +1 -0
  11. package/build/dashboard/client/shim-server.d.ts +3 -0
  12. package/build/dashboard/client/shim-server.js +2 -0
  13. package/build/dashboard/client/shim-server.js.map +1 -0
  14. package/build/dashboard/dashboard.html +20 -0
  15. package/build/dashboard/index.d.ts +18 -0
  16. package/build/dashboard/index.d.ts.map +1 -0
  17. package/build/dashboard/index.js +50 -0
  18. package/build/dashboard/index.js.map +1 -0
  19. package/build/dashboard/serve.d.ts +18 -0
  20. package/build/dashboard/serve.d.ts.map +1 -0
  21. package/build/dashboard/serve.js +53 -0
  22. package/build/dashboard/serve.js.map +1 -0
  23. package/build/dashboard/server.d.ts +93 -0
  24. package/build/dashboard/server.d.ts.map +1 -0
  25. package/build/dashboard/server.js +384 -0
  26. package/build/dashboard/server.js.map +1 -0
  27. package/build/examples/helloworld/.edinburgh/commit_worker.log +4927 -0
  28. package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
  29. package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
  30. package/build/examples/helloworld/client/assets/style.css +0 -45
  31. package/build/examples/helloworld/client/index.html +3 -14
  32. package/build/examples/helloworld/client/js/base.css +1 -0
  33. package/build/examples/helloworld/client/js/base.d.ts +1 -4
  34. package/build/examples/helloworld/client/js/base.js +8 -71
  35. package/build/examples/helloworld/client/js/base.js.map +1 -1
  36. package/build/examples/helloworld/server/api.d.ts +8 -2
  37. package/build/examples/helloworld/server/api.d.ts.map +1 -1
  38. package/build/examples/helloworld/server/api.js +29 -8
  39. package/build/examples/helloworld/server/api.js.map +1 -1
  40. package/build/examples/helloworld/server/main.d.ts +1 -1
  41. package/build/examples/helloworld/server/main.d.ts.map +1 -1
  42. package/build/examples/helloworld/server/main.js +6 -17
  43. package/build/examples/helloworld/server/main.js.map +1 -1
  44. package/build/server/password.d.ts +10 -0
  45. package/build/server/password.d.ts.map +1 -0
  46. package/build/server/password.js +38 -0
  47. package/build/server/password.js.map +1 -0
  48. package/build/server/server.d.ts +5 -3
  49. package/build/server/server.d.ts.map +1 -1
  50. package/build/server/server.js +65 -7
  51. package/build/server/server.js.map +1 -1
  52. package/build/server/wshandler.d.ts +7 -1
  53. package/build/server/wshandler.d.ts.map +1 -1
  54. package/build/server/wshandler.js +54 -14
  55. package/build/server/wshandler.js.map +1 -1
  56. package/build/tsconfig.client.tsbuildinfo +1 -1
  57. package/build/tsconfig.server.tsbuildinfo +1 -1
  58. package/client/client.ts +47 -24
  59. package/dashboard/build-bundle.ts +44 -0
  60. package/dashboard/client/crud.ts +634 -0
  61. package/dashboard/client/index.html +12 -0
  62. package/dashboard/client/main.ts +554 -0
  63. package/dashboard/client/shim-server.ts +5 -0
  64. package/dashboard/index.ts +49 -0
  65. package/dashboard/server.ts +399 -0
  66. package/package.json +26 -11
  67. package/server/server.ts +61 -10
  68. package/server/wshandler.ts +57 -13
  69. package/skill/SKILL.md +82 -51
  70. package/skill/getStreamTypesForModel.md +7 -0
  71. package/skill/Connection_pruneCommitIds.md +0 -8
@@ -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
- export async function handleStart(apiFile: any) {
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
- const requestId = pack.readPositiveInt();
38
- const type = pack.readNumber();
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
- if (typeof func !== 'function' || methodName.startsWith('_')) {
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
- if (response instanceof ServerProxy) {
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, response.api);
156
+ proxies.set(requestId, serverProxyResponse.api);
115
157
 
116
- if (response.value instanceof StreamTypeBase) {
117
- const StreamType = response.value.constructor as typeof StreamTypeBase<any>;
118
- const instance = response.value._instance;
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, response.value, virtualSocketIds);
170
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy, serverProxyResponse.value, virtualSocketIds);
128
171
  }
129
172
 
130
- } else if (response instanceof StreamTypeBase) {
131
- const StreamType = response.constructor as typeof StreamTypeBase<any>;
132
- const instance = response._instance;
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
+ ![dashboard screenshot](dashboard_screenshot.png)
41
+
28
42
  ## Tutorial
29
43
 
30
44
  ### Project Setup
31
45
 
32
46
  ```bash
33
- bun init
34
- bun add lowlander aberdeen edinburgh
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
- [object Object],[object Object],[object Object]
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 | true; }`
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.ws · property
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
- #### connection.streamCache · property
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.connect · method
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
- **Parameters:**
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
- **Parameters:**
510
-
511
- - `cached: StreamCacheEntry`
542
+ **Signature:** `() => string`
512
543
 
@@ -0,0 +1,7 @@
1
+ ### getStreamTypesForModel · function
2
+
3
+ **Signature:** `(Model: AnyModelClass) => readonly (typeof StreamTypeBase<unknown>)[]`
4
+
5
+ **Parameters:**
6
+
7
+ - `Model: E.AnyModelClass`
@@ -1,8 +0,0 @@
1
- #### connection.pruneCommitIds · method
2
-
3
- **Signature:** `(request: ActiveRequest, maxCommitId: number) => void`
4
-
5
- **Parameters:**
6
-
7
- - `request: ActiveRequest`
8
- - `maxCommitId: number`