lowlander 0.4.0 → 0.5.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 (63) hide show
  1. package/README.md +76 -22
  2. package/build/client/client.js +5 -0
  3. package/build/client/client.js.map +1 -1
  4. package/build/dashboard/client/main.d.ts +1 -0
  5. package/build/dashboard/client/main.js +623 -0
  6. package/build/dashboard/client/main.js.map +1 -0
  7. package/build/dashboard/client/shim-server.d.ts +3 -0
  8. package/build/dashboard/client/shim-server.js +2 -0
  9. package/build/dashboard/client/shim-server.js.map +1 -0
  10. package/build/dashboard/dashboard.html +20 -0
  11. package/build/dashboard/index.d.ts +18 -0
  12. package/build/dashboard/index.d.ts.map +1 -0
  13. package/build/dashboard/index.js +50 -0
  14. package/build/dashboard/index.js.map +1 -0
  15. package/build/dashboard/serve.d.ts +18 -0
  16. package/build/dashboard/serve.d.ts.map +1 -0
  17. package/build/dashboard/serve.js +53 -0
  18. package/build/dashboard/serve.js.map +1 -0
  19. package/build/dashboard/server.d.ts +82 -0
  20. package/build/dashboard/server.d.ts.map +1 -0
  21. package/build/dashboard/server.js +248 -0
  22. package/build/dashboard/server.js.map +1 -0
  23. package/build/examples/helloworld/.edinburgh/commit_worker.log +3162 -0
  24. package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
  25. package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
  26. package/build/examples/helloworld/client/index.html +1 -1
  27. package/build/examples/helloworld/client/js/base.css +1 -0
  28. package/build/examples/helloworld/client/js/base.js +217 -71
  29. package/build/examples/helloworld/client/js/base.js.map +1 -1
  30. package/build/examples/helloworld/server/api.d.ts +4 -2
  31. package/build/examples/helloworld/server/api.d.ts.map +1 -1
  32. package/build/examples/helloworld/server/api.js +19 -8
  33. package/build/examples/helloworld/server/api.js.map +1 -1
  34. package/build/examples/helloworld/server/main.d.ts +1 -1
  35. package/build/examples/helloworld/server/main.d.ts.map +1 -1
  36. package/build/examples/helloworld/server/main.js +6 -17
  37. package/build/examples/helloworld/server/main.js.map +1 -1
  38. package/build/server/password.d.ts +10 -0
  39. package/build/server/password.d.ts.map +1 -0
  40. package/build/server/password.js +38 -0
  41. package/build/server/password.js.map +1 -0
  42. package/build/server/server.d.ts +2 -0
  43. package/build/server/server.d.ts.map +1 -1
  44. package/build/server/server.js +21 -2
  45. package/build/server/server.js.map +1 -1
  46. package/build/server/wshandler.d.ts +7 -1
  47. package/build/server/wshandler.d.ts.map +1 -1
  48. package/build/server/wshandler.js +54 -14
  49. package/build/server/wshandler.js.map +1 -1
  50. package/build/tsconfig.client.tsbuildinfo +1 -1
  51. package/build/tsconfig.server.tsbuildinfo +1 -1
  52. package/client/client.ts +6 -1
  53. package/dashboard/build-bundle.ts +38 -0
  54. package/dashboard/client/index.html +12 -0
  55. package/dashboard/client/main.ts +566 -0
  56. package/dashboard/client/shim-server.ts +5 -0
  57. package/dashboard/index.ts +49 -0
  58. package/dashboard/server.ts +255 -0
  59. package/package.json +22 -11
  60. package/server/server.ts +18 -2
  61. package/server/wshandler.ts +57 -13
  62. package/skill/SKILL.md +52 -4
  63. package/skill/getStreamTypesForModel.md +7 -0
@@ -0,0 +1,255 @@
1
+ import * as E from 'edinburgh';
2
+ import { timingSafeEqual } from 'crypto';
3
+ import { ServerProxy, createStreamType, getStreamTypesForModel, warpsocket } from '../server/server.js';
4
+ import { getMainApi, getPassword } from '../server/wshandler.js';
5
+
6
+ // `modelRegistry` is a documented export of edinburgh's models module but is
7
+ // not re-exported from the package entry. Reach it by URL relative to the
8
+ // resolved package main — works in both Bun (.ts) and Node (built .js).
9
+ const _modelsModule = await import(new URL('./models.js', import.meta.resolve('edinburgh')).href) as {
10
+ modelRegistry: Record<string, E.AnyModelClass>;
11
+ };
12
+ const modelRegistry = _modelsModule.modelRegistry;
13
+
14
+
15
+ function passwordOk(provided: string): boolean {
16
+ const expected = getPassword();
17
+ if (typeof provided !== 'string' || provided.length !== expected.length) return false;
18
+ return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
19
+ }
20
+
21
+ function describeType(type: E.TypeWrapper<unknown>): { kind: string; display: string; linkedModel?: string } {
22
+ const linked = type.getLinkedModel();
23
+ return {
24
+ kind: (type as any).kind,
25
+ display: type.toString(),
26
+ linkedModel: linked?.tableName,
27
+ };
28
+ }
29
+
30
+ function describeIndex(index: any) {
31
+ const fields = Array.from(index._indexFields.keys()) as string[];
32
+ return {
33
+ fields,
34
+ fieldTypes: fields.map(f => describeType(index._indexFields.get(f))),
35
+ computed: !!index._computeFn,
36
+ kind: index._getTypeName() as 'primary' | 'unique' | 'secondary',
37
+ };
38
+ }
39
+
40
+ function describeModel(Model: E.AnyModelClass) {
41
+ const fields: { name: string; type: ReturnType<typeof describeType>; description?: string; hasDefault: boolean }[] = [];
42
+ for (const [name, cfg] of Object.entries(Model.fields) as [string, E.FieldConfig<unknown>][]) {
43
+ fields.push({
44
+ name,
45
+ type: describeType(cfg.type),
46
+ description: cfg.description,
47
+ hasDefault: cfg.default !== undefined,
48
+ });
49
+ }
50
+ const indexes: { name: string; info: ReturnType<typeof describeIndex> }[] = [{
51
+ name: '(primary)',
52
+ info: describeIndex(Model),
53
+ }];
54
+ for (const [name, idx] of Object.entries((Model as any)._secondaries || {})) {
55
+ indexes.push({ name, info: describeIndex(idx) });
56
+ }
57
+ const streamTypes = getStreamTypesForModel(Model).map((ST: any) => ({
58
+ id: ST.id,
59
+ fields: ST.fields,
60
+ cache: ST.cache,
61
+ }));
62
+ return {
63
+ tableName: Model.tableName,
64
+ fields,
65
+ indexes,
66
+ streamTypes,
67
+ };
68
+ }
69
+
70
+ function serializeValue(v: any, depth = 0): any {
71
+ if (v === null || v === undefined) return v;
72
+ if (v instanceof E.Model) {
73
+ const cls: any = v.constructor;
74
+ const tn = cls.tableName || cls.name;
75
+ const pkBytes = (v as any).getPrimaryKey?.();
76
+ const pkArr = pkBytes && cls._pkToArray ? cls._pkToArray(pkBytes) : null;
77
+ const pk = pkArr ? (pkArr.length === 1 ? pkArr[0] : pkArr) : null;
78
+ return { __ref: tn, pk };
79
+ }
80
+ if (v instanceof Uint8Array) return Array.from(v.slice(0, 64)).map(b => b.toString(16).padStart(2, '0')).join('');
81
+ if (v instanceof Date) return v.toISOString();
82
+ if (v instanceof Set) return depth > 2 ? `Set(${v.size})` : Array.from(v).map(x => serializeValue(x, depth + 1));
83
+ if (Array.isArray(v)) return depth > 2 ? `Array(${v.length})` : v.map(x => serializeValue(x, depth + 1));
84
+ if (typeof v === 'object') {
85
+ if (depth > 2) return '...';
86
+ const out: Record<string, any> = {};
87
+ for (const k of Object.keys(v)) out[k] = serializeValue(v[k], depth + 1);
88
+ return out;
89
+ }
90
+ return v;
91
+ }
92
+
93
+ function instanceToPlain(instance: any): Record<string, any> {
94
+ const Model = instance.constructor as E.AnyModelClass;
95
+ const out: Record<string, any> = {};
96
+ for (const fieldName of Object.keys(Model.fields)) {
97
+ try {
98
+ out[fieldName] = serializeValue((instance as any)[fieldName]);
99
+ } catch (err: any) {
100
+ out[fieldName] = `<error: ${err?.message || err}>`;
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ function modelByName(name: string): E.AnyModelClass {
107
+ const Model = modelRegistry[name];
108
+ if (!Model) throw new Error(`Unknown model: ${name}`);
109
+ return Model;
110
+ }
111
+
112
+ function findIndex(Model: E.AnyModelClass, indexName: string): any {
113
+ if (indexName === '(primary)') return Model;
114
+ const idx = (Model as any)._secondaries?.[indexName];
115
+ if (!idx) throw new Error(`Unknown index ${indexName} on ${Model.tableName}`);
116
+ return idx;
117
+ }
118
+
119
+ // =====================================================================
120
+
121
+ class DashboardAPI {
122
+ listModels() {
123
+ return Object.keys(modelRegistry).sort().map(name => ({
124
+ tableName: name,
125
+ indexCount: 1 + Object.keys((modelRegistry[name] as any)._secondaries || {}).length,
126
+ fieldCount: Object.keys(modelRegistry[name].fields).length,
127
+ streamTypeCount: getStreamTypesForModel(modelRegistry[name]).length,
128
+ }));
129
+ }
130
+
131
+ getModel(name: string) {
132
+ return describeModel(modelByName(name));
133
+ }
134
+
135
+ listApiMethods() {
136
+ const api = getMainApi();
137
+ if (!api) return [];
138
+ const out: { name: string; kind: 'function' | 'value' }[] = [];
139
+ for (const key of Object.keys(api).sort()) {
140
+ if (key === '_dashboard' || key.startsWith('_')) continue;
141
+ const v = (api as any)[key];
142
+ out.push({ name: key, kind: typeof v === 'function' ? 'function' : 'value' });
143
+ }
144
+ return out;
145
+ }
146
+
147
+ getApiMethodSource(name: string): string | undefined {
148
+ const api = getMainApi();
149
+ const fn = api && (api as any)[name];
150
+ if (typeof fn !== 'function') return undefined;
151
+ try {
152
+ // Bundlers usually preserve enough source for a useful preview
153
+ const s = String(fn);
154
+ return s.length > 4000 ? s.slice(0, 4000) + '\n/* … truncated */' : s;
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ }
159
+
160
+ findRecords(modelName: string, indexName: string, opts: {
161
+ search?: any;
162
+ reverse?: boolean;
163
+ limit?: number;
164
+ } = {}) {
165
+ const Model = modelByName(modelName);
166
+ const idx = findIndex(Model, indexName);
167
+ const fieldNames = Array.from(idx._indexFields.keys()) as string[];
168
+
169
+ const findOpts: any = {};
170
+ if (opts.search !== undefined && opts.search !== '') {
171
+ findOpts.is = opts.search;
172
+ }
173
+ if (opts.reverse) findOpts.reverse = true;
174
+
175
+ const limit = Math.min(opts.limit ?? 10, 1000);
176
+ let iter: Iterable<any>;
177
+ try { iter = idx.find(findOpts); }
178
+ catch { return { rows: [], indexFields: fieldNames, scanned: 0 }; }
179
+ const results: { pk: any; values: Record<string, any> }[] = [];
180
+ let scanned = 0;
181
+ for (const row of iter) {
182
+ scanned++;
183
+ const plain = instanceToPlain(row);
184
+ const cls: any = (row as any).constructor;
185
+ const pkBytes = (row as any).getPrimaryKey ? (row as any).getPrimaryKey() : null;
186
+ const pkArr = pkBytes && cls._pkToArray ? cls._pkToArray(pkBytes) : null;
187
+ const pk = pkArr ? (pkArr.length === 1 ? pkArr[0] : pkArr) : null;
188
+ results.push({
189
+ pk: serializeValue(pk),
190
+ values: plain,
191
+ });
192
+ if (results.length >= limit) break;
193
+ }
194
+ return {
195
+ rows: results,
196
+ indexFields: fieldNames,
197
+ scanned,
198
+ };
199
+ }
200
+
201
+ getRecord(modelName: string, pk: any) {
202
+ const Model = modelByName(modelName);
203
+ const pkArgs = Array.isArray(pk) ? pk : [pk];
204
+ const instance = (Model as any).get(...pkArgs);
205
+ if (!instance) return undefined;
206
+ return instanceToPlain(instance);
207
+ }
208
+
209
+ streamRecord(modelName: string, streamTypeId: number, pk: any) {
210
+ const Model = modelByName(modelName);
211
+ const ST = getStreamTypesForModel(Model).find((s: any) => s.id === streamTypeId) as any;
212
+ if (!ST) throw new Error(`No stream type with id ${streamTypeId} for ${modelName}`);
213
+ const pkArgs = Array.isArray(pk) ? pk : [pk];
214
+ const instance = (Model as any).get(...pkArgs);
215
+ if (!instance) throw new Error('Record not found');
216
+ return new ST(instance);
217
+ }
218
+
219
+ getDebugState(mode: 'channels' | 'sockets' | 'workers' | 'kv') {
220
+ return warpsocket.getDebugState(mode as any);
221
+ }
222
+ }
223
+
224
+ const DashboardProxyValue = 'authenticated';
225
+
226
+ /**
227
+ * RPC entry point. Re-export this from your top-level api file:
228
+ *
229
+ * ```ts
230
+ * export { _dashboard } from 'lowlander/dashboard';
231
+ * ```
232
+ *
233
+ * On first call, a password is generated (or reused from the
234
+ * `LOWLANDER_DASHBOARD_PASSWORD` env var) and printed to the server console.
235
+ */
236
+ export function _dashboard(password: string) {
237
+ if (!passwordOk(password)) {
238
+ throw new Error('Invalid dashboard password');
239
+ }
240
+ return new ServerProxy(new DashboardAPI(), DashboardProxyValue);
241
+ }
242
+
243
+ // Re-export so callers don't need a separate import
244
+ export type { DashboardAPI };
245
+
246
+ // Convenience stream type used internally so live record viewing works
247
+ // for arbitrary models without the developer having to pre-create one.
248
+ // (Built lazily per model+selection because createStreamType is keyed on the
249
+ // shape of the selection.)
250
+ const allFieldsStreams = new Map<string, ReturnType<typeof createStreamType>>();
251
+ export function _dashboardAllFieldsStream(modelName: string) {
252
+ // Currently unused; kept for future "view live" feature.
253
+ void allFieldsStreams;
254
+ void modelName;
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lowlander",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript framework for data persistence, type-safe RPCs and real-time (partial) client synchronization.",
5
5
  "type": "module",
6
6
  "main": "./build/server/server.js",
@@ -21,34 +21,45 @@
21
21
  "bun": "./server/server.ts",
22
22
  "import": "./build/server/server.js",
23
23
  "types": "./build/server/server.d.ts"
24
+ },
25
+ "./dashboard": {
26
+ "bun": "./dashboard/index.ts",
27
+ "import": "./build/dashboard/index.js",
28
+ "types": "./build/dashboard/index.d.ts"
24
29
  }
25
30
  },
26
31
  "files": [
27
32
  "build/",
28
33
  "server/",
29
34
  "client/",
35
+ "dashboard/",
30
36
  "skill/"
31
37
  ],
32
38
  "scripts": {
33
- "build": "tsc -b && cp -rf examples/helloworld/client/assets examples/helloworld/client/index.html build/examples/helloworld/client/ && npm run build:docs",
39
+ "build": "tsc -b && node --import=tsx/esm dashboard/build-bundle.ts && node --import=tsx/esm examples/helloworld/build-client.ts && npm run build:docs",
34
40
  "build:docs": "rm -rf skill ; mkdir skill ; cat skill-header.md README.md > skill/SKILL.md && readme-tsdoc --split --file skill/SKILL.md && readme-tsdoc --repo-url https://github.com/vanviegen/lowlander",
35
- "typecheck": "bun x tsc -b",
41
+ "typecheck": "npx tsc -b",
36
42
  "clean": "rm -rf build dist skill",
37
- "prepack": "bun install && bun run build",
38
- "test": "bun test",
43
+ "prepack": "npm install && npm run build",
44
+ "test": "vitest run",
45
+ "example": "npm run build && (cd examples/helloworld && npm install) && node build/examples/helloworld/server/main.js",
39
46
  "postinstall": "[ -f .local-post-install.sh ] && sh .local-post-install.sh || true"
40
47
  },
41
48
  "dependencies": {
42
- "warpsocket": "^0.8.3"
49
+ "warpsocket": "^0.9.1"
43
50
  },
44
51
  "peerDependencies": {
45
- "aberdeen": "^1.10.1",
46
- "edinburgh": "^0.6.1"
52
+ "aberdeen": "^1.15.0",
53
+ "edinburgh": "^0.6.4"
47
54
  },
48
55
  "devDependencies": {
49
- "@types/bun": "^1.3.10",
50
- "aberdeen": "^1.10.1",
51
- "readme-tsdoc": "^1.1.6"
56
+ "@types/node": "^20.19.41",
57
+ "aberdeen": "^1.15.0",
58
+ "esbuild": "^0.28.0",
59
+ "readme-tsdoc": "^1.1.6",
60
+ "sirv": "^3.0.2",
61
+ "tsx": "^4.0.0",
62
+ "vitest": "^4.1.7"
52
63
  },
53
64
  "workspaces": [
54
65
  "examples/*"
package/server/server.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import * as E from "edinburgh";
2
2
  import DataPack from "edinburgh/datapack";
3
3
  import * as realWarpsocket from 'warpsocket';
4
+ import { randomBytes } from 'crypto';
5
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
4
7
 
5
8
  // Get log level from environment variable
6
9
  // 0: no logging (default)
@@ -22,6 +25,11 @@ const CHANNEL_TYPE_MODEL = 1;
22
25
 
23
26
  /** @internal Registry mapping model classes to their stream types */
24
27
  const streamTypesPerModel: Map<E.AnyModelClass, typeof StreamTypeBase<unknown>[]> = new Map();
28
+
29
+ /** @internal Used by the dashboard module to inspect registered stream types. */
30
+ export function getStreamTypesForModel(Model: E.AnyModelClass): readonly (typeof StreamTypeBase<unknown>)[] {
31
+ return streamTypesPerModel.get(Model) || [];
32
+ }
25
33
 
26
34
  /**
27
35
  * Base class for stream types created by {@link createStreamType}.
@@ -209,7 +217,9 @@ export function createStreamType<T, S extends FieldSelection<T>>(
209
217
  static id = streamTypeId;
210
218
  static cache = options?.cache;
211
219
  }
212
- streamTypes.push(StreamType);
220
+ if (!streamTypes.some(st => (st as any).id === streamTypeId)) {
221
+ streamTypes.push(StreamType);
222
+ }
213
223
  return StreamType as any;
214
224
  }
215
225
 
@@ -454,10 +464,16 @@ export async function start(mainApiFile: string, opts: {bind?: string, threads?:
454
464
  if (opts.injectWarpSocket) {
455
465
  warpsocket = opts.injectWarpSocket;
456
466
  }
467
+ const pwFile = join(process.cwd(), '.lowlander_dashboard_password');
468
+ if (!existsSync(pwFile)) try { writeFileSync(pwFile, randomBytes(24).toString('base64url'), { flag: 'wx' }); } catch {}
469
+ const password = process.env.LOWLANDER_DASHBOARD_PASSWORD ?? readFileSync(pwFile, 'utf8').trim();
470
+ if (!process.env.LOWLANDER_DASHBOARD_PASSWORD) {
471
+ console.log(`\n${'='.repeat(56)}\n Lowlander dashboard password: ${password}\n${'='.repeat(56)}\n`);
472
+ }
457
473
  await warpsocket.start({
458
474
  bind: opts.bind || '0.0.0.0:8080',
459
475
  threads: opts.threads,
460
476
  workerPath: WSHANDLER_FILE,
461
- workerArg: mainApiFile,
477
+ workerArg: { apiFile: mainApiFile, password },
462
478
  });
463
479
  }
@@ -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,25 @@ 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
+
28
38
  ## Tutorial
29
39
 
30
40
  ### Project Setup
31
41
 
32
42
  ```bash
33
- bun init
34
- bun add lowlander aberdeen edinburgh
43
+ npm init
44
+ npm add lowlander aberdeen edinburgh
35
45
  ```
36
46
 
37
- (npm should also work for all of this.)
38
-
39
47
  Create the project structure:
40
48
 
41
49
  ```
@@ -339,10 +347,50 @@ Set the `LOWLANDER_LOG_LEVEL` environment variable to a number from 0 to 3:
339
347
 
340
348
  Set `EDINBURGH_LOG_LEVEL` similarly for Edinburgh internals.
341
349
 
350
+ ### Dashboard
351
+
352
+ Lowlander ships with an optional admin/developer dashboard for inspecting
353
+ Edinburgh models, browsing index rows, listing RPC methods, viewing source
354
+ code, and peeking at warpsocket debug state (channels, sockets, workers,
355
+ KV). It's a single self-contained HTML bundle.
356
+
357
+ To enable it:
358
+
359
+ 1. Re-export `_dashboard` from your top-level API module:
360
+
361
+ ```ts
362
+ // server/api.ts
363
+ export { _dashboard } from "lowlander/dashboard";
364
+ ```
365
+
366
+ 2. Serve the bundled HTML by calling `serveDashboard(res)` from a
367
+ `warpsocket` `handleHttpRequest` export:
368
+
369
+ ```ts
370
+ import type { HttpRequest, HttpResponse } from "warpsocket";
371
+ import { serveDashboard } from "lowlander/dashboard";
372
+
373
+ export function handleHttpRequest(req: HttpRequest, res: HttpResponse) {
374
+ if (req.url === '/_dashboard' || req.url.startsWith('/_dashboard?')) {
375
+ return serveDashboard(res);
376
+ }
377
+ // … serve your own static files …
378
+ }
379
+ ```
380
+
381
+ 3. On first server start (per warpsocket KV namespace), a random password
382
+ is generated and printed to the console. Override with the
383
+ `LOWLANDER_DASHBOARD_PASSWORD` env var.
384
+
385
+ The dashboard prompts for the websocket URL (defaults to the current host)
386
+ and password on first load, then stores them in localStorage.
387
+
342
388
  ## Server API Reference
343
389
 
344
390
  The following is auto-generated from `server/server.ts`:
345
391
 
392
+ ### [getStreamTypesForModel](getStreamTypesForModel.md) · function
393
+
346
394
  ### [createStreamType](createStreamType.md) · function
347
395
 
348
396
  Creates a stream type for reactive model streaming to clients with automatic updates.
@@ -0,0 +1,7 @@
1
+ ### getStreamTypesForModel · function
2
+
3
+ **Signature:** `(Model: AnyModelClass) => readonly (typeof StreamTypeBase<unknown>)[]`
4
+
5
+ **Parameters:**
6
+
7
+ - `Model: E.AnyModelClass`