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.
- package/README.md +125 -38
- package/build/client/client.d.ts +5 -1
- package/build/client/client.js +15 -4
- package/build/client/client.js.map +1 -1
- package/build/dashboard/client/main.d.ts +1 -0
- package/build/dashboard/client/main.js +623 -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 +82 -0
- package/build/dashboard/server.d.ts.map +1 -0
- package/build/dashboard/server.js +248 -0
- package/build/dashboard/server.js.map +1 -0
- package/build/examples/helloworld/.edinburgh/commit_worker.log +3162 -0
- package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
- package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
- package/build/examples/helloworld/client/index.html +1 -1
- package/build/examples/helloworld/client/js/base.css +1 -0
- package/build/examples/helloworld/client/js/base.js +217 -71
- package/build/examples/helloworld/client/js/base.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +37 -26
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +38 -10
- 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/protocol.d.ts +1 -0
- package/build/server/protocol.d.ts.map +1 -1
- package/build/server/protocol.js +1 -0
- package/build/server/protocol.js.map +1 -1
- package/build/server/server.d.ts +9 -9
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +21 -2
- 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 +62 -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 +20 -6
- package/dashboard/build-bundle.ts +38 -0
- package/dashboard/client/index.html +12 -0
- package/dashboard/client/main.ts +566 -0
- package/dashboard/client/shim-server.ts +5 -0
- package/dashboard/index.ts +49 -0
- package/dashboard/server.ts +255 -0
- package/package.json +22 -11
- package/server/protocol.ts +1 -0
- package/server/server.ts +53 -17
- package/server/wshandler.ts +66 -13
- package/skill/SKILL.md +85 -4
- package/skill/createStreamType.md +1 -1
- 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
|
+
"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 &&
|
|
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": "
|
|
41
|
+
"typecheck": "npx tsc -b",
|
|
36
42
|
"clean": "rm -rf build dist skill",
|
|
37
|
-
"prepack": "
|
|
38
|
-
"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.
|
|
49
|
+
"warpsocket": "^0.9.1"
|
|
43
50
|
},
|
|
44
51
|
"peerDependencies": {
|
|
45
|
-
"aberdeen": "^1.
|
|
46
|
-
"edinburgh": "^0.6.
|
|
52
|
+
"aberdeen": "^1.15.0",
|
|
53
|
+
"edinburgh": "^0.6.4"
|
|
47
54
|
},
|
|
48
55
|
"devDependencies": {
|
|
49
|
-
"@types/
|
|
50
|
-
"aberdeen": "^1.
|
|
51
|
-
"
|
|
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/protocol.ts
CHANGED
|
@@ -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
|
|
58
|
-
? true |
|
|
59
|
-
|
|
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
|
|
71
|
-
? S extends true
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
90
|
-
?
|
|
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.
|
|
193
|
-
|
|
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
|
}
|
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,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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
122
|
-
const
|
|
123
|
-
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;
|
|
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));
|