lowlander 0.3.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 (70) hide show
  1. package/README.md +125 -38
  2. package/build/client/client.d.ts +5 -1
  3. package/build/client/client.js +15 -4
  4. package/build/client/client.js.map +1 -1
  5. package/build/dashboard/client/main.d.ts +1 -0
  6. package/build/dashboard/client/main.js +623 -0
  7. package/build/dashboard/client/main.js.map +1 -0
  8. package/build/dashboard/client/shim-server.d.ts +3 -0
  9. package/build/dashboard/client/shim-server.js +2 -0
  10. package/build/dashboard/client/shim-server.js.map +1 -0
  11. package/build/dashboard/dashboard.html +20 -0
  12. package/build/dashboard/index.d.ts +18 -0
  13. package/build/dashboard/index.d.ts.map +1 -0
  14. package/build/dashboard/index.js +50 -0
  15. package/build/dashboard/index.js.map +1 -0
  16. package/build/dashboard/serve.d.ts +18 -0
  17. package/build/dashboard/serve.d.ts.map +1 -0
  18. package/build/dashboard/serve.js +53 -0
  19. package/build/dashboard/serve.js.map +1 -0
  20. package/build/dashboard/server.d.ts +82 -0
  21. package/build/dashboard/server.d.ts.map +1 -0
  22. package/build/dashboard/server.js +248 -0
  23. package/build/dashboard/server.js.map +1 -0
  24. package/build/examples/helloworld/.edinburgh/commit_worker.log +3162 -0
  25. package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
  26. package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
  27. package/build/examples/helloworld/client/index.html +1 -1
  28. package/build/examples/helloworld/client/js/base.css +1 -0
  29. package/build/examples/helloworld/client/js/base.js +217 -71
  30. package/build/examples/helloworld/client/js/base.js.map +1 -1
  31. package/build/examples/helloworld/server/api.d.ts +37 -26
  32. package/build/examples/helloworld/server/api.d.ts.map +1 -1
  33. package/build/examples/helloworld/server/api.js +38 -10
  34. package/build/examples/helloworld/server/api.js.map +1 -1
  35. package/build/examples/helloworld/server/main.d.ts +1 -1
  36. package/build/examples/helloworld/server/main.d.ts.map +1 -1
  37. package/build/examples/helloworld/server/main.js +6 -17
  38. package/build/examples/helloworld/server/main.js.map +1 -1
  39. package/build/server/password.d.ts +10 -0
  40. package/build/server/password.d.ts.map +1 -0
  41. package/build/server/password.js +38 -0
  42. package/build/server/password.js.map +1 -0
  43. package/build/server/protocol.d.ts +1 -0
  44. package/build/server/protocol.d.ts.map +1 -1
  45. package/build/server/protocol.js +1 -0
  46. package/build/server/protocol.js.map +1 -1
  47. package/build/server/server.d.ts +9 -9
  48. package/build/server/server.d.ts.map +1 -1
  49. package/build/server/server.js +21 -2
  50. package/build/server/server.js.map +1 -1
  51. package/build/server/wshandler.d.ts +7 -1
  52. package/build/server/wshandler.d.ts.map +1 -1
  53. package/build/server/wshandler.js +62 -14
  54. package/build/server/wshandler.js.map +1 -1
  55. package/build/tsconfig.client.tsbuildinfo +1 -1
  56. package/build/tsconfig.server.tsbuildinfo +1 -1
  57. package/client/client.ts +20 -6
  58. package/dashboard/build-bundle.ts +38 -0
  59. package/dashboard/client/index.html +12 -0
  60. package/dashboard/client/main.ts +566 -0
  61. package/dashboard/client/shim-server.ts +5 -0
  62. package/dashboard/index.ts +49 -0
  63. package/dashboard/server.ts +255 -0
  64. package/package.json +22 -11
  65. package/server/protocol.ts +1 -0
  66. package/server/server.ts +53 -17
  67. package/server/wshandler.ts +66 -13
  68. package/skill/SKILL.md +85 -4
  69. package/skill/createStreamType.md +1 -1
  70. 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.3.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.0"
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/*"
@@ -8,6 +8,7 @@ export const SERVER_MESSAGES = {
8
8
  response: 'r', // followed by result + virtualSocketIds
9
9
  response_proxy: 'p', // followed by result + virtualSocketIds (like above, but indicate that a ServerProxy has been created for this request)
10
10
  response_model: 'm', // followed by virtualSocketIds + dbKey + cacheMs (undefined = no caching/dedup)
11
+ response_proxy_model: 'q', // followed by virtualSocketIds + dbKey + cacheMs — like response_model but a ServerProxy was also registered
11
12
  model_data: 'd', // followed by dbKey + commitId + delta
12
13
  };
13
14
 
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}.
@@ -46,6 +54,7 @@ export abstract class StreamTypeBase<T> {
46
54
  /**
47
55
  * Type-safe selector for specifying which model fields to stream to clients.
48
56
  * Use `true` to include a field, or an object to select nested fields in linked models.
57
+ * Set<U> and Record<string, U> fields take a selection for U (applied to every element).
49
58
  *
50
59
  * @typeParam T - The model type
51
60
  */
@@ -54,9 +63,16 @@ type FieldSelection<T> =
54
63
  ? true | FieldSelection<U>
55
64
  : T extends Array<infer U>
56
65
  ? true | FieldSelection<U>
57
- : T extends object
58
- ? true | { [K in keyof T]?: FieldSelection<T[K]> }
59
- : true;
66
+ : T extends ReadonlySet<infer U>
67
+ ? true | FieldSelection<U>
68
+ // string extends keyof T distinguishes Record<string,U> from finite-keyed model objects
69
+ : string extends keyof T
70
+ ? T extends Record<string, infer U>
71
+ ? true | FieldSelection<U>
72
+ : true
73
+ : T extends object
74
+ ? true | { [K in keyof T]?: FieldSelection<T[K]> }
75
+ : true;
60
76
 
61
77
  /**
62
78
  * Validates field selection compatibility at compile time.
@@ -67,13 +83,19 @@ type ValidateSelection<T, S> =
67
83
  ? S extends true ? true : ValidateSelection<U, S>
68
84
  : T extends Array<infer U>
69
85
  ? S extends true ? true : ValidateSelection<U, S>
70
- : T extends object
71
- ? S extends true
72
- ? true
73
- : S extends object
74
- ? { [K in keyof S]-?: K extends keyof T ? ValidateSelection<T[K], S[K]> : never }
75
- : never
76
- : S extends true ? true : never;
86
+ : T extends ReadonlySet<infer U>
87
+ ? S extends true ? true : ValidateSelection<U, S>
88
+ : string extends keyof T
89
+ ? T extends Record<string, infer U>
90
+ ? S extends true ? true : ValidateSelection<U, S>
91
+ : never
92
+ : T extends object
93
+ ? S extends true
94
+ ? true
95
+ : S extends object
96
+ ? { [K in keyof S]-?: K extends keyof T ? ValidateSelection<T[K], S[K]> : never }
97
+ : never
98
+ : S extends true ? true : never;
77
99
 
78
100
  /**
79
101
  * Computes the resulting type after applying a field selection.
@@ -86,9 +108,15 @@ type Project<T, S> =
86
108
  ? ReadonlyArray<Project<U, S>>
87
109
  : T extends Array<infer U>
88
110
  ? Array<Project<U, S>>
89
- : T extends object
90
- ? { [K in Extract<keyof S, keyof T>]: Project<T[K], S[K & keyof T]> }
91
- : T;
111
+ : T extends ReadonlySet<infer U>
112
+ ? Project<U, S>[]
113
+ : string extends keyof T
114
+ ? T extends Record<string, infer U>
115
+ ? Record<string, Project<U, S>>
116
+ : T
117
+ : T extends object
118
+ ? { [K in Extract<keyof S, keyof T>]: Project<T[K], S[K & keyof T]> }
119
+ : T;
92
120
 
93
121
 
94
122
  /**
@@ -162,7 +190,7 @@ export function createStreamType<T, S extends FieldSelection<T>>(
162
190
  Model: E.AnyModelClass & (new (...args: any[]) => T),
163
191
  selection: S & ValidateSelection<T, S>,
164
192
  options?: { cache?: number }
165
- ) {
193
+ ): { new(instance: T): StreamTypeBase<Project<T, S>>; id: number; fields: Record<string, true|number>; cache?: number } {
166
194
  let streamTypes = streamTypesPerModel.get(Model);
167
195
  if (!streamTypes) streamTypesPerModel.set(Model, streamTypes = []);
168
196
 
@@ -189,8 +217,10 @@ export function createStreamType<T, S extends FieldSelection<T>>(
189
217
  static id = streamTypeId;
190
218
  static cache = options?.cache;
191
219
  }
192
- streamTypes.push(StreamType);
193
- return StreamType;
220
+ if (!streamTypes.some(st => (st as any).id === streamTypeId)) {
221
+ streamTypes.push(StreamType);
222
+ }
223
+ return StreamType as any;
194
224
  }
195
225
 
196
226
  /** Writes `value` to `pack`, replacing Model instances with XOR'd hash refs. */
@@ -434,10 +464,16 @@ export async function start(mainApiFile: string, opts: {bind?: string, threads?:
434
464
  if (opts.injectWarpSocket) {
435
465
  warpsocket = opts.injectWarpSocket;
436
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
+ }
437
473
  await warpsocket.start({
438
474
  bind: opts.bind || '0.0.0.0:8080',
439
475
  threads: opts.threads,
440
476
  workerPath: WSHANDLER_FILE,
441
- workerArg: mainApiFile,
477
+ workerArg: { apiFile: mainApiFile, password },
442
478
  });
443
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,20 +144,36 @@ 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) {
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
- }
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>;
114
153
  let proxies = socketProxies.get(socketId);
115
154
  if (!proxies) socketProxies.set(socketId, proxies = new Map());
116
155
  if (logLevel >= 3) console.log('[lowlander] Setting proxy id', requestId, 'for socket', socketId);
117
- proxies.set(requestId, response.api);
156
+ proxies.set(requestId, serverProxyResponse.api);
157
+
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;
118
162
 
119
- pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy, response.value, virtualSocketIds);
163
+ const virtualSocketId = warpsocket.createVirtualSocket(socketId, DataPack.createUint8Array(requestId, SERVER_MESSAGES.model_data));
164
+ virtualSocketIds.push(virtualSocketId);
165
+ pushModel(virtualSocketId, instance, 0, StreamType, 1);
166
+
167
+ const cacheMs = StreamType.cache !== undefined ? StreamType.cache * 1000 : undefined;
168
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
169
+ } else {
170
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy, serverProxyResponse.value, virtualSocketIds);
171
+ }
120
172
 
121
- } else if (response instanceof StreamTypeBase) {
122
- const StreamType = response.constructor as typeof StreamTypeBase<any>;
123
- 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;
124
177
 
125
178
  // Create a virtual socket for the model updates, prefixed by requestId + 'd'
126
179
  const virtualSocketId = warpsocket.createVirtualSocket(socketId, DataPack.createUint8Array(requestId, SERVER_MESSAGES.model_data));