lowlander 0.2.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/AGENTS.md +2 -0
- package/LICENSE +15 -0
- package/README.md +355 -0
- package/ROADMAP.md +13 -0
- package/bun.lock +281 -0
- package/client/client.ts +495 -0
- package/examples/helloworld/client/assets/style.css +45 -0
- package/examples/helloworld/client/index.html +22 -0
- package/examples/helloworld/client/js/admin.ts +94 -0
- package/examples/helloworld/client/js/base.ts +83 -0
- package/examples/helloworld/package.json +8 -0
- package/examples/helloworld/server/api.ts +154 -0
- package/examples/helloworld/server/main.ts +27 -0
- package/package.json +50 -0
- package/server/protocol.ts +22 -0
- package/server/server.ts +437 -0
- package/server/wshandler.ts +138 -0
- package/tests/fake-warpsocket.ts +452 -0
- package/tests/helloworld.test.ts +151 -0
- package/tsconfig.client.json +18 -0
- package/tsconfig.json +24 -0
- package/tsconfig.server.json +17 -0
- package/tsconfig.test.json +13 -0
package/server/server.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import * as E from "edinburgh";
|
|
2
|
+
import DataPack from "edinburgh/datapack";
|
|
3
|
+
import * as realWarpsocket from 'warpsocket';
|
|
4
|
+
|
|
5
|
+
// Get log level from environment variable
|
|
6
|
+
// 0: no logging (default)
|
|
7
|
+
// 1: connections & lifecycle (connect/disconnect/reconnect, worker startup)
|
|
8
|
+
// 2: RPC calls & responses (method calls, incoming responses, errors)
|
|
9
|
+
// 3: model streaming & internals (onSave, model changes, stream processing)
|
|
10
|
+
export const logLevel = parseInt(process.env.LOWLANDER_LOG_LEVEL || "0") || 0;
|
|
11
|
+
|
|
12
|
+
/** @internal Warpsocket implementation; swapped to FakeWarpSocket in test mode. */
|
|
13
|
+
export let warpsocket: typeof realWarpsocket = realWarpsocket;
|
|
14
|
+
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import { dirname, resolve } from 'path';
|
|
17
|
+
|
|
18
|
+
const WSHANDLER_FILE = resolve(dirname(fileURLToPath(import.meta.url)), 'wshandler.js');
|
|
19
|
+
|
|
20
|
+
/** @internal WarpSocket channel id prefix for model streams */
|
|
21
|
+
const CHANNEL_TYPE_MODEL = 1;
|
|
22
|
+
|
|
23
|
+
/** @internal Registry mapping model classes to their stream types */
|
|
24
|
+
const streamTypesPerModel: Map<ModelClass, typeof StreamTypeBase<unknown>[]> = new Map();
|
|
25
|
+
|
|
26
|
+
/** @internal Type alias for Edinburgh model classes */
|
|
27
|
+
type ModelClass = typeof E.Model<unknown>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Base class for stream types created by {@link createStreamType}.
|
|
31
|
+
* @typeParam T - The projected model type
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export abstract class StreamTypeBase<T> {
|
|
35
|
+
/** @internal */
|
|
36
|
+
static fields: { [key: string]: true|number };
|
|
37
|
+
/** @internal */
|
|
38
|
+
static id: number;
|
|
39
|
+
/** @internal */
|
|
40
|
+
constructor(public _instance: E.Model<any> & T) {}
|
|
41
|
+
|
|
42
|
+
toString() {
|
|
43
|
+
let streamTypes = streamTypesPerModel.get(this._instance.constructor as ModelClass) || [];
|
|
44
|
+
return `{Stream model=${this._instance.toString()} type=${streamTypes.indexOf(this.constructor as any)}}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Type-safe selector for specifying which model fields to stream to clients.
|
|
50
|
+
* Use `true` to include a field, or an object to select nested fields in linked models.
|
|
51
|
+
*
|
|
52
|
+
* @typeParam T - The model type
|
|
53
|
+
*/
|
|
54
|
+
type FieldSelection<T> =
|
|
55
|
+
T extends ReadonlyArray<infer U>
|
|
56
|
+
? true | FieldSelection<U>
|
|
57
|
+
: T extends Array<infer U>
|
|
58
|
+
? true | FieldSelection<U>
|
|
59
|
+
: T extends object
|
|
60
|
+
? true | { [K in keyof T]?: FieldSelection<T[K]> }
|
|
61
|
+
: true;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validates field selection compatibility at compile time.
|
|
65
|
+
* @internal
|
|
66
|
+
*/
|
|
67
|
+
type ValidateSelection<T, S> =
|
|
68
|
+
T extends ReadonlyArray<infer U>
|
|
69
|
+
? S extends true ? true : ValidateSelection<U, S>
|
|
70
|
+
: T extends Array<infer U>
|
|
71
|
+
? S extends true ? true : ValidateSelection<U, S>
|
|
72
|
+
: T extends object
|
|
73
|
+
? S extends true
|
|
74
|
+
? true
|
|
75
|
+
: S extends object
|
|
76
|
+
? { [K in keyof S]-?: K extends keyof T ? ValidateSelection<T[K], S[K]> : never }
|
|
77
|
+
: never
|
|
78
|
+
: S extends true ? true : never;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Computes the resulting type after applying a field selection.
|
|
82
|
+
* @internal
|
|
83
|
+
*/
|
|
84
|
+
type Project<T, S> =
|
|
85
|
+
S extends true
|
|
86
|
+
? T
|
|
87
|
+
: T extends ReadonlyArray<infer U>
|
|
88
|
+
? ReadonlyArray<Project<U, S>>
|
|
89
|
+
: T extends Array<infer U>
|
|
90
|
+
? Array<Project<U, S>>
|
|
91
|
+
: T extends object
|
|
92
|
+
? { [K in Extract<keyof S, keyof T>]: Project<T[K], S[K & keyof T]> }
|
|
93
|
+
: T;
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns a stable numeric ID for a key tuple, allocating a new one if needed.
|
|
98
|
+
* Safe for multiple WarpSocket threads.
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
function getIdForData(namespace: string, ...data: any): number {
|
|
102
|
+
const dataKeyPack = new DataPack().write(namespace);
|
|
103
|
+
for(const d of data) dataKeyPack.write(d);
|
|
104
|
+
const dataKey = dataKeyPack.toUint8Array();
|
|
105
|
+
|
|
106
|
+
const idPack = warpsocket.getKey(dataKey);
|
|
107
|
+
if (idPack) {
|
|
108
|
+
return new DataPack(idPack).readNumber();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const countKey = new DataPack().write(namespace).toUint8Array();
|
|
112
|
+
while(true) {
|
|
113
|
+
// Insert a new stream type
|
|
114
|
+
const countPack = warpsocket.getKey(countKey);
|
|
115
|
+
const newCount = countPack ? new DataPack(countPack).readNumber() + 1 : 1;
|
|
116
|
+
const newCountPack = new DataPack().write(newCount).toUint8Array();
|
|
117
|
+
if (warpsocket.setKeyIf(countKey, newCountPack, countPack)) {
|
|
118
|
+
if (warpsocket.setKeyIf(dataKey, newCountPack, undefined)) {
|
|
119
|
+
return newCount; // Success
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Raced, try again
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Creates a stream type for reactive model streaming to clients with automatic updates.
|
|
128
|
+
*
|
|
129
|
+
* Specify which fields to include; when they change, updates are pushed to subscribed clients.
|
|
130
|
+
* Supports nested linked models and type-safe field selection.
|
|
131
|
+
*
|
|
132
|
+
* @typeParam T - The model type
|
|
133
|
+
* @typeParam S - The field selection
|
|
134
|
+
*
|
|
135
|
+
* @param Model - The Edinburgh model class
|
|
136
|
+
* @param selection - Field selection: `true` for simple fields, nested object for linked models
|
|
137
|
+
* @returns Stream type class to instantiate in API functions
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* @registerModel
|
|
142
|
+
* class Person extends Model {
|
|
143
|
+
* name = field(string);
|
|
144
|
+
* age = field(number);
|
|
145
|
+
* password = field(string);
|
|
146
|
+
* friends = field(array(link(Person)));
|
|
147
|
+
* }
|
|
148
|
+
*
|
|
149
|
+
* // Exclude password, include friends' names
|
|
150
|
+
* const PersonStream = createStreamType(Person, {
|
|
151
|
+
* name: true,
|
|
152
|
+
* age: true,
|
|
153
|
+
* friends: { name: true }
|
|
154
|
+
* });
|
|
155
|
+
*
|
|
156
|
+
* export function streamPerson() {
|
|
157
|
+
* const person = Person.byName.get('Alice')!;
|
|
158
|
+
* return new PersonStream(person);
|
|
159
|
+
* }
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export function createStreamType<T, S extends FieldSelection<T>>(
|
|
163
|
+
Model: ModelClass & (new (...args: any[]) => T),
|
|
164
|
+
selection: S & ValidateSelection<T, S>
|
|
165
|
+
) {
|
|
166
|
+
let streamTypes = streamTypesPerModel.get(Model);
|
|
167
|
+
if (!streamTypes) streamTypesPerModel.set(Model, streamTypes = []);
|
|
168
|
+
|
|
169
|
+
const streamTypeId = getIdForData("streamType", Model.tableName, selection);
|
|
170
|
+
|
|
171
|
+
const fields: Record<string, true|number> = {};
|
|
172
|
+
for(const prop of Array.from(Object.keys(selection)).sort() as (string & keyof S)[]) {
|
|
173
|
+
if (!Model.fields.hasOwnProperty(prop)) {
|
|
174
|
+
throw new Error(`Property ${prop} does not exist in model ${Model.name}`);
|
|
175
|
+
}
|
|
176
|
+
const LinkedModel = Model.fields[prop].type.getLinkedModel() as ModelClass | undefined;
|
|
177
|
+
|
|
178
|
+
if (selection[prop] === true) {
|
|
179
|
+
if (LinkedModel) throw new Error(`Property ${prop} is a link; must specify sub-selection`);
|
|
180
|
+
fields[prop] = true; // Include field without tracking links
|
|
181
|
+
} else {
|
|
182
|
+
if (!LinkedModel) throw new Error(`Property ${prop} is not a link; cannot specify sub-selection`);
|
|
183
|
+
const SubStreamType = createStreamType(LinkedModel as any, selection[prop] as any)
|
|
184
|
+
fields[prop] = SubStreamType.id;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
class StreamType extends StreamTypeBase<Project<T, S>> {
|
|
188
|
+
static fields = fields;
|
|
189
|
+
static id = streamTypeId;
|
|
190
|
+
}
|
|
191
|
+
streamTypes.push(StreamType);
|
|
192
|
+
return StreamType;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Writes `value` to `pack`, replacing Model instances with XOR'd hash refs. */
|
|
196
|
+
function writeModelField(pack: DataPack, value: any, streamTypeId: number): void {
|
|
197
|
+
if (typeof value !== 'object' || value == null) {
|
|
198
|
+
pack.write(value);
|
|
199
|
+
} else if (value instanceof E.Model) {
|
|
200
|
+
pack.writeCustom('model', value.getPrimaryKeyHash() + streamTypeId);
|
|
201
|
+
} else if (Array.isArray(value)) {
|
|
202
|
+
pack.writeCollection('array', () => {
|
|
203
|
+
for (const item of value) writeModelField(pack, item, streamTypeId);
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
pack.writeCollection('object', () => {
|
|
207
|
+
for (const key of Object.keys(value)) {
|
|
208
|
+
pack.write(key);
|
|
209
|
+
writeModelField(pack, value[key], streamTypeId);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Tracks link deltas for subscription management. */
|
|
216
|
+
function updateLinkDeltas(value: any, linkDeltas: Map<E.Model<unknown>, Map<number,number>>, streamTypeId: number, delta: number): void {
|
|
217
|
+
if (typeof value !== 'object' || value == null) return;
|
|
218
|
+
if (Array.isArray(value)) {
|
|
219
|
+
for (const item of value) updateLinkDeltas(item, linkDeltas, streamTypeId, delta);
|
|
220
|
+
} else if (value instanceof E.Model) {
|
|
221
|
+
let map = linkDeltas.get(value);
|
|
222
|
+
if (!map) linkDeltas.set(value, map = new Map());
|
|
223
|
+
const v = (map.get(streamTypeId) || 0) + delta;
|
|
224
|
+
if (v) map.set(streamTypeId, v);
|
|
225
|
+
else map.delete(streamTypeId);
|
|
226
|
+
} else {
|
|
227
|
+
for (const key of Object.keys(value)) {
|
|
228
|
+
updateLinkDeltas(value[key], linkDeltas, streamTypeId, delta);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
E.setOnSaveCallback((commitId: number, items: Map<E.Model<any>, E.Change>) => {
|
|
234
|
+
if (logLevel >= 3) console.log('[lowlander] onSave', commitId);
|
|
235
|
+
for(const [model, changed] of items.entries()) {
|
|
236
|
+
|
|
237
|
+
const streamTypes = streamTypesPerModel.get(model.constructor);
|
|
238
|
+
if (logLevel >= 3) console.log('[lowlander] Model changed:', model, changed, `streams=${streamTypes?.length}`);
|
|
239
|
+
if (!streamTypes) continue;
|
|
240
|
+
|
|
241
|
+
for(const StreamType of streamTypes) {
|
|
242
|
+
const channelName = DataPack.createUint8Array(CHANNEL_TYPE_MODEL, model.getPrimaryKeyHash() + StreamType.id);
|
|
243
|
+
|
|
244
|
+
if (logLevel >= 3) console.log('[lowlander] Processing stream type', StreamType.name, 'channel', channelName);
|
|
245
|
+
|
|
246
|
+
// Don't bother constructing a change message if nobody is listening
|
|
247
|
+
if (!warpsocket.hasSubscriptions(channelName)) continue;
|
|
248
|
+
|
|
249
|
+
// When an instance is first created, we can't possibly have any references to it yet, so we don't need to emit it.
|
|
250
|
+
if (changed !== 'created') {
|
|
251
|
+
sendModel(channelName, model, commitId, StreamType, changed);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Sends (updated) data for `model` to `target`.
|
|
260
|
+
* `target` is a virtual socket with a requestId+'d' user prefix, or a channel that subscribes such virtual sockets.
|
|
261
|
+
*/
|
|
262
|
+
export function sendModel(target: Uint8Array | number | number[], model: E.Model<any>, commitId: number, StreamType: typeof StreamTypeBase<any>, changed?: E.Change) {
|
|
263
|
+
let pack = new DataPack();
|
|
264
|
+
pack.write(model.getPrimaryKeyHash()! + StreamType.id);
|
|
265
|
+
pack.write(commitId);
|
|
266
|
+
|
|
267
|
+
let mustSend = false;
|
|
268
|
+
|
|
269
|
+
if (changed === 'deleted') {
|
|
270
|
+
pack.write(null);
|
|
271
|
+
mustSend = true;
|
|
272
|
+
}
|
|
273
|
+
else { // changed is an object or 'created'
|
|
274
|
+
const linkDeltas = new Map<E.Model<unknown>, Map<number,number>>();
|
|
275
|
+
|
|
276
|
+
pack.writeCollection('object', (addRecord) => {
|
|
277
|
+
for(const fieldName in StreamType.fields) {
|
|
278
|
+
if (typeof changed === 'object' && !changed.hasOwnProperty(fieldName)) continue;
|
|
279
|
+
let streamIndex = StreamType.fields[fieldName];
|
|
280
|
+
|
|
281
|
+
const fieldValue = (model as any)[fieldName];
|
|
282
|
+
mustSend = true;
|
|
283
|
+
|
|
284
|
+
if (typeof streamIndex === 'number') {
|
|
285
|
+
pack.write(fieldName);
|
|
286
|
+
writeModelField(pack, fieldValue, streamIndex);
|
|
287
|
+
updateLinkDeltas(fieldValue, linkDeltas, streamIndex, 1);
|
|
288
|
+
if (typeof changed === 'object') updateLinkDeltas(changed[fieldName], linkDeltas, streamIndex, -1);
|
|
289
|
+
} else {
|
|
290
|
+
addRecord(fieldName, fieldValue);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
for(const linkedModel of linkDeltas.keys()) {
|
|
296
|
+
let streamIndexMap = linkDeltas.get(linkedModel)!;
|
|
297
|
+
const subStreamTypes = streamTypesPerModel.get(linkedModel.constructor)!;
|
|
298
|
+
for(const subStreamType of subStreamTypes) {
|
|
299
|
+
const delta = streamIndexMap.get(subStreamType.id);
|
|
300
|
+
if (delta) { // Only in case delta is set and non-zero
|
|
301
|
+
pushModel(target, linkedModel, commitId, subStreamType, delta);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// else: Do nothing in case of changed=="created", as there can't be any subscribers yet at this time.
|
|
307
|
+
|
|
308
|
+
// If at least one field was updated, send the packet
|
|
309
|
+
if (mustSend) {
|
|
310
|
+
warpsocket.send(target, pack.toUint8Array(false));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Subscribes `target` to this model, and sends initial data.
|
|
316
|
+
* `target` is a virtual socket with a requestId+'d' user prefix, or a channel that subscribes such virtual sockets.
|
|
317
|
+
*/
|
|
318
|
+
export function pushModel(target: number | Uint8Array | number[], model: E.Model<any>, commitId: number, SubStreamType: typeof StreamTypeBase<any>, delta: number) {
|
|
319
|
+
const subChannel = DataPack.createUint8Array(CHANNEL_TYPE_MODEL, model.getPrimaryKeyHash() + SubStreamType.id);
|
|
320
|
+
|
|
321
|
+
let changedSocketIds = warpsocket.subscribe(target, subChannel, delta);
|
|
322
|
+
if (changedSocketIds.length > 0) {
|
|
323
|
+
sendModel(changedSocketIds, model, commitId, SubStreamType, delta > 0 ? 'created' : 'deleted');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Wraps a server-side API object to create a stateful, type-safe proxy accessible from clients.
|
|
329
|
+
* Use for authentication, sessions, or any stateful context that persists across RPC calls.
|
|
330
|
+
*
|
|
331
|
+
* @typeParam API - The server-side API object type
|
|
332
|
+
* @typeParam RETURN - The value type returned to the client
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* ```ts
|
|
336
|
+
* export class UserAPI {
|
|
337
|
+
* constructor(public user: User) {}
|
|
338
|
+
* getSecret() { return this.user.secret; }
|
|
339
|
+
* }
|
|
340
|
+
*
|
|
341
|
+
* export async function authenticate(token: string) {
|
|
342
|
+
* const user = await validateToken(token);
|
|
343
|
+
* return new ServerProxy(new UserAPI(user), user.name);
|
|
344
|
+
* }
|
|
345
|
+
*
|
|
346
|
+
* // Client: auth.value is user name, auth.serverProxy.getSecret() calls UserAPI method
|
|
347
|
+
* ```
|
|
348
|
+
*/
|
|
349
|
+
export class ServerProxy<API extends object, RETURN> {
|
|
350
|
+
/**
|
|
351
|
+
* @param api - Server-side API object exposed to the client
|
|
352
|
+
* @param value - Value returned immediately to the client
|
|
353
|
+
*/
|
|
354
|
+
constructor(public api: API, public value?: RETURN) {}
|
|
355
|
+
toString() {
|
|
356
|
+
return `{ServerProxy proxy=${this.api.constructor?.name} value=${this.value}}`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Server-side socket for pushing data to a client. Server functions with `Socket<T>` parameters
|
|
362
|
+
* receive client callbacks on the client side.
|
|
363
|
+
*
|
|
364
|
+
* @typeParam T - Data type sent through the socket
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* ```ts
|
|
368
|
+
* // Server
|
|
369
|
+
* export function streamNumbers(socket: Socket<number>) {
|
|
370
|
+
* setInterval(() => {
|
|
371
|
+
* if (!socket.send(Math.random())) clearInterval(interval);
|
|
372
|
+
* }, 1000);
|
|
373
|
+
* }
|
|
374
|
+
*
|
|
375
|
+
* // Client
|
|
376
|
+
* api.streamNumbers(num => console.log(num));
|
|
377
|
+
* ```
|
|
378
|
+
*/
|
|
379
|
+
export class Socket<T> {
|
|
380
|
+
/** @internal */
|
|
381
|
+
constructor(public virtualSocketId: number) {}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Sends data to the client.
|
|
385
|
+
* @param data - Data to send (automatically serialized)
|
|
386
|
+
* @returns `true` if sent, `false` if socket is closed
|
|
387
|
+
*/
|
|
388
|
+
send(data: T) {
|
|
389
|
+
const buffer = data instanceof Uint8Array ? data : new DataPack().write(data).toUint8Array();
|
|
390
|
+
return warpsocket.send(this.virtualSocketId, buffer);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** @internal */
|
|
394
|
+
subscribe(channel: Uint8Array, delta=1) {
|
|
395
|
+
if (!(channel instanceof Uint8Array)) {
|
|
396
|
+
channel = new DataPack().write(channel).toUint8Array()
|
|
397
|
+
}
|
|
398
|
+
warpsocket.subscribe(this.virtualSocketId, channel, delta);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
toString() {
|
|
402
|
+
return `{Socket id=${this.virtualSocketId}}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
406
|
+
return this.toString();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Starts the Lowlander WebSocket server.
|
|
412
|
+
*
|
|
413
|
+
* @param mainApiFile - Absolute path to the compiled API file exporting server functions
|
|
414
|
+
* @param opts.bind - Address and port (default: '0.0.0.0:8080')
|
|
415
|
+
* @param opts.threads - Worker thread count (default: auto)
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* import { start } from 'lowlander/server';
|
|
420
|
+
* import { fileURLToPath } from 'url';
|
|
421
|
+
* import { resolve, dirname } from 'path';
|
|
422
|
+
*
|
|
423
|
+
* const API_FILE = resolve(dirname(fileURLToPath(import.meta.url)), 'api.js');
|
|
424
|
+
* start(API_FILE, { bind: '0.0.0.0:8080' });
|
|
425
|
+
* ```
|
|
426
|
+
*/
|
|
427
|
+
export async function start(mainApiFile: string, opts: {bind?: string, threads?: number, injectWarpSocket?: typeof realWarpsocket} = {}): Promise<void> {
|
|
428
|
+
if (opts.injectWarpSocket) {
|
|
429
|
+
warpsocket = opts.injectWarpSocket;
|
|
430
|
+
}
|
|
431
|
+
await warpsocket.start({
|
|
432
|
+
bind: opts.bind || '0.0.0.0:8080',
|
|
433
|
+
threads: opts.threads,
|
|
434
|
+
workerPath: WSHANDLER_FILE,
|
|
435
|
+
workerArg: mainApiFile,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import DataPack from 'edinburgh/datapack';
|
|
2
|
+
import { warpsocket, Socket, StreamTypeBase, pushModel, ServerProxy, logLevel } from './server.js';
|
|
3
|
+
import { SERVER_MESSAGES, CLIENT_MESSAGES } from './protocol.js';
|
|
4
|
+
import * as E from 'edinburgh';
|
|
5
|
+
|
|
6
|
+
let mainApi: object | undefined;
|
|
7
|
+
|
|
8
|
+
export const socketProxies = new Map<number, Map<number, any>>(); // {socketId: {proxyId: proxyObject}}
|
|
9
|
+
|
|
10
|
+
export interface Request {
|
|
11
|
+
socketId: number;
|
|
12
|
+
requestId: number;
|
|
13
|
+
token: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function handleOpen(socketId: number, ip: string) {
|
|
17
|
+
if (logLevel >= 1) console.log('[lowlander] Client connected', socketId, ip);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function send(socketIdOrChannel: number|string|Uint8Array, ...data: any) {
|
|
21
|
+
warpsocket.send(socketIdOrChannel, DataPack.createUint8Array(...data));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sendError(socketId: number, requestId: number, message: string) {
|
|
25
|
+
console.warn('Sending error', message);
|
|
26
|
+
send(socketId, requestId, SERVER_MESSAGES.error, message);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function handleStart(apiFile: any) {
|
|
30
|
+
if (logLevel >= 1) console.log('[lowlander] Worker started, loading', apiFile);
|
|
31
|
+
mainApi = await import(apiFile);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export async function handleBinaryMessage(message: Uint8Array, socketId: number) {
|
|
36
|
+
const pack = new DataPack(message);
|
|
37
|
+
const requestId = pack.readPositiveInt();
|
|
38
|
+
const type = pack.readNumber();
|
|
39
|
+
|
|
40
|
+
if (type === CLIENT_MESSAGES.cancel) {
|
|
41
|
+
// Delete server proxy object, if any
|
|
42
|
+
const cancelRequestId = pack.readPositiveInt();
|
|
43
|
+
const proxies = socketProxies.get(socketId);
|
|
44
|
+
if (proxies) proxies.delete(cancelRequestId);
|
|
45
|
+
|
|
46
|
+
// Delete any virtual sockets created for this request
|
|
47
|
+
for(const virtualSocketId of pack.read() || []) {
|
|
48
|
+
// The second argument makes sure we're not deleting virtual
|
|
49
|
+
// sockets created by other requests
|
|
50
|
+
warpsocket.deleteVirtualSocket(virtualSocketId, socketId);
|
|
51
|
+
}
|
|
52
|
+
} else if (type === CLIENT_MESSAGES.call) {
|
|
53
|
+
const proxyId = pack.read();
|
|
54
|
+
let api: any;
|
|
55
|
+
if (typeof proxyId === 'number') {
|
|
56
|
+
const proxies = socketProxies.get(socketId);
|
|
57
|
+
api = proxies?.get(proxyId);
|
|
58
|
+
if (!api) {
|
|
59
|
+
return sendError(socketId, requestId, `Proxy ${proxyId} not found`);
|
|
60
|
+
}
|
|
61
|
+
} else if (proxyId === undefined) {
|
|
62
|
+
if (!mainApi) {
|
|
63
|
+
return sendError(socketId, requestId, 'Server not ready');
|
|
64
|
+
}
|
|
65
|
+
api = mainApi;
|
|
66
|
+
} else {
|
|
67
|
+
return sendError(socketId, requestId, 'Invalid proxyId');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Obtain function reference
|
|
71
|
+
const methodName = pack.readString();
|
|
72
|
+
let func = (api as any)[methodName];
|
|
73
|
+
if (typeof func !== 'function' || methodName.startsWith('_')) {
|
|
74
|
+
return sendError(socketId, requestId, `Method not found: ${methodName}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Parse args
|
|
78
|
+
const virtualSocketIds: number[] = [];
|
|
79
|
+
const params = pack.read({
|
|
80
|
+
cb: function(callbackIndex: number) {
|
|
81
|
+
const virtualSocketId = warpsocket.createVirtualSocket(socketId, DataPack.createUint8Array(requestId, callbackIndex));
|
|
82
|
+
virtualSocketIds.push(virtualSocketId);
|
|
83
|
+
return new Socket(virtualSocketId);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
if (!Array.isArray(params)) {
|
|
87
|
+
return sendError(socketId, requestId, `Params must be an array`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
let pendingSend: (() => void) | undefined;
|
|
92
|
+
await E.transact(async () => {
|
|
93
|
+
let response = await func.apply(api, params);
|
|
94
|
+
if (logLevel >= 2) console.log('[lowlander] Called', methodName, 'with', params, '->', typeof response === 'object' && response ? response.toString() : JSON.stringify(response));
|
|
95
|
+
|
|
96
|
+
// Result processing/sending should be within the transaction, as it may involve (lazy) loading models
|
|
97
|
+
|
|
98
|
+
if (response instanceof ServerProxy) {
|
|
99
|
+
let proxies = socketProxies.get(socketId);
|
|
100
|
+
if (!proxies) socketProxies.set(socketId, proxies = new Map());
|
|
101
|
+
if (logLevel >= 3) console.log('[lowlander] Setting proxy id', requestId, 'for socket', socketId);
|
|
102
|
+
proxies.set(requestId, response.api);
|
|
103
|
+
|
|
104
|
+
pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response_proxy, response.value, virtualSocketIds);
|
|
105
|
+
|
|
106
|
+
} else if (response instanceof StreamTypeBase) {
|
|
107
|
+
const StreamType = response.constructor as typeof StreamTypeBase<any>;
|
|
108
|
+
const instance = response._instance;
|
|
109
|
+
|
|
110
|
+
// Create a virtual socket for the model updates, prefixed by requestId + 'd'
|
|
111
|
+
const virtualSocketId = warpsocket.createVirtualSocket(socketId, DataPack.createUint8Array(requestId, SERVER_MESSAGES.model_data));
|
|
112
|
+
virtualSocketIds.push(virtualSocketId);
|
|
113
|
+
|
|
114
|
+
// Push the model (and any linked models) to the client
|
|
115
|
+
pushModel(virtualSocketId, instance, 0, StreamType, 1);
|
|
116
|
+
|
|
117
|
+
// Then respond, indicating which row should be top level
|
|
118
|
+
pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id);
|
|
119
|
+
} else {
|
|
120
|
+
// A regular result
|
|
121
|
+
pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response, response, virtualSocketIds);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
// Send response after transaction has committed
|
|
125
|
+
pendingSend!();
|
|
126
|
+
} catch (error: any) {
|
|
127
|
+
console.error('RPC error', error);
|
|
128
|
+
sendError(socketId, requestId, error.message || 'Internal error');
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
sendError(socketId, requestId, `Unknown message type: ${type}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function handleClose(socketId: number) {
|
|
136
|
+
if (logLevel >= 1) console.log('[lowlander] Client disconnected', socketId);
|
|
137
|
+
socketProxies.delete(socketId);
|
|
138
|
+
}
|