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.
@@ -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
+ }