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/client/client.ts
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import type { Socket, ServerProxy, StreamTypeBase } from '../server/server.js';
|
|
2
|
+
import A from 'aberdeen';
|
|
3
|
+
import DataPack from 'edinburgh/datapack';
|
|
4
|
+
import { SERVER_MESSAGES, CLIENT_MESSAGES } from '../server/protocol.js';
|
|
5
|
+
import type { PromiseProxy } from 'aberdeen';
|
|
6
|
+
|
|
7
|
+
/** Set to 1-3 for increasing verbosity. */
|
|
8
|
+
export let logLevel = 0;
|
|
9
|
+
|
|
10
|
+
// Sentinel key in commitIds entries: on initial creation, only DEFAULT_COMMIT is set
|
|
11
|
+
// (applies to all keys). Per-key entries are added on subsequent updates and take
|
|
12
|
+
// precedence. On deletion, DEFAULT_COMMIT guards against stale re-creates.
|
|
13
|
+
const DEFAULT_COMMIT = Symbol();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Transforms server-side `Socket<T>` arguments to client-side callback functions `(data: T) => void`.
|
|
17
|
+
*
|
|
18
|
+
* @typeParam A - The server-side argument type
|
|
19
|
+
*/
|
|
20
|
+
type ClientProxyArg<A> = A extends Socket<infer U>
|
|
21
|
+
? (data: U) => void
|
|
22
|
+
: A;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Recursively transforms all server-side function arguments for client-side use.
|
|
26
|
+
*
|
|
27
|
+
* @typeParam Args - Tuple of server-side argument types
|
|
28
|
+
*/
|
|
29
|
+
type ClientProxyArgs<Args extends any[]> = Args extends [infer A, ...infer Rest]
|
|
30
|
+
? [ClientProxyArg<A>, ...ClientProxyArgs<Rest>]
|
|
31
|
+
: [];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Transforms server-side return types for client-side use.
|
|
35
|
+
*
|
|
36
|
+
* - Strips `Promise` wrappers
|
|
37
|
+
* - `ServerProxy<API, RETURN>` → `PromiseProxy<RETURN> & {serverProxy: ClientProxyObject<API>}`
|
|
38
|
+
* - Other types → `PromiseProxy<R>`
|
|
39
|
+
*
|
|
40
|
+
* @typeParam R - The server-side return type
|
|
41
|
+
*/
|
|
42
|
+
type ClientProxyReturn<R> = R extends Promise<infer U>
|
|
43
|
+
? ClientProxyReturn<U>
|
|
44
|
+
: R extends ServerProxy<infer API, infer RETURN>
|
|
45
|
+
? PromiseProxy<RETURN> & {promise: Promise<RETURN>, serverProxy: ClientProxyObject<API>}
|
|
46
|
+
: R extends StreamTypeBase<infer T>
|
|
47
|
+
? PromiseProxy<T> & {promise: Promise<T>}
|
|
48
|
+
: PromiseProxy<R> & {promise: Promise<R>};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Transforms server-side function signatures for client-side proxy use.
|
|
52
|
+
* This type correctly handles function overloads by explicitly matching
|
|
53
|
+
* up to 8 signatures and creating an intersection of the transformed types.
|
|
54
|
+
*
|
|
55
|
+
* @typeParam T - The server-side function type, which may be overloaded.
|
|
56
|
+
*/
|
|
57
|
+
// Yeah, this is ugly beyond belief, but TypeScript doesn't have a
|
|
58
|
+
// better way to handle function overloads in conditional types.
|
|
59
|
+
// The non-ugly version would be:
|
|
60
|
+
//
|
|
61
|
+
// type ClientProxyFunction<T> = T extends (...args: infer Args) => infer Return
|
|
62
|
+
// ? (...args: ClientProxyArgs<Args>) => ClientProxyReturn<Return>
|
|
63
|
+
// : never;
|
|
64
|
+
type ClientProxyFunction<T> = T extends {
|
|
65
|
+
(...args: infer A1): infer R1;
|
|
66
|
+
(...args: infer A2): infer R2;
|
|
67
|
+
(...args: infer A3): infer R3;
|
|
68
|
+
(...args: infer A4): infer R4;
|
|
69
|
+
(...args: infer A5): infer R5;
|
|
70
|
+
(...args: infer A6): infer R6;
|
|
71
|
+
(...args: infer A7): infer R7;
|
|
72
|
+
(...args: infer A8): infer R8;
|
|
73
|
+
} ?
|
|
74
|
+
& ((...args: ClientProxyArgs<A1>) => ClientProxyReturn<R1>)
|
|
75
|
+
& ((...args: ClientProxyArgs<A2>) => ClientProxyReturn<R2>)
|
|
76
|
+
& ((...args: ClientProxyArgs<A3>) => ClientProxyReturn<R3>)
|
|
77
|
+
& ((...args: ClientProxyArgs<A4>) => ClientProxyReturn<R4>)
|
|
78
|
+
& ((...args: ClientProxyArgs<A5>) => ClientProxyReturn<R5>)
|
|
79
|
+
& ((...args: ClientProxyArgs<A6>) => ClientProxyReturn<R6>)
|
|
80
|
+
& ((...args: ClientProxyArgs<A7>) => ClientProxyReturn<R7>)
|
|
81
|
+
& ((...args: ClientProxyArgs<A8>) => ClientProxyReturn<R8>)
|
|
82
|
+
: T extends {
|
|
83
|
+
(...args: infer A1): infer R1;
|
|
84
|
+
(...args: infer A2): infer R2;
|
|
85
|
+
(...args: infer A3): infer R3;
|
|
86
|
+
(...args: infer A4): infer R4;
|
|
87
|
+
(...args: infer A5): infer R5;
|
|
88
|
+
(...args: infer A6): infer R6;
|
|
89
|
+
(...args: infer A7): infer R7;
|
|
90
|
+
} ?
|
|
91
|
+
& ((...args: ClientProxyArgs<A1>) => ClientProxyReturn<R1>)
|
|
92
|
+
& ((...args: ClientProxyArgs<A2>) => ClientProxyReturn<R2>)
|
|
93
|
+
& ((...args: ClientProxyArgs<A3>) => ClientProxyReturn<R3>)
|
|
94
|
+
& ((...args: ClientProxyArgs<A4>) => ClientProxyReturn<R4>)
|
|
95
|
+
& ((...args: ClientProxyArgs<A5>) => ClientProxyReturn<R5>)
|
|
96
|
+
& ((...args: ClientProxyArgs<A6>) => ClientProxyReturn<R6>)
|
|
97
|
+
& ((...args: ClientProxyArgs<A7>) => ClientProxyReturn<R7>)
|
|
98
|
+
: T extends {
|
|
99
|
+
(...args: infer A1): infer R1;
|
|
100
|
+
(...args: infer A2): infer R2;
|
|
101
|
+
(...args: infer A3): infer R3;
|
|
102
|
+
(...args: infer A4): infer R4;
|
|
103
|
+
(...args: infer A5): infer R5;
|
|
104
|
+
(...args: infer A6): infer R6;
|
|
105
|
+
} ?
|
|
106
|
+
& ((...args: ClientProxyArgs<A1>) => ClientProxyReturn<R1>)
|
|
107
|
+
& ((...args: ClientProxyArgs<A2>) => ClientProxyReturn<R2>)
|
|
108
|
+
& ((...args: ClientProxyArgs<A3>) => ClientProxyReturn<R3>)
|
|
109
|
+
& ((...args: ClientProxyArgs<A4>) => ClientProxyReturn<R4>)
|
|
110
|
+
& ((...args: ClientProxyArgs<A5>) => ClientProxyReturn<R5>)
|
|
111
|
+
& ((...args: ClientProxyArgs<A6>) => ClientProxyReturn<R6>)
|
|
112
|
+
: T extends {
|
|
113
|
+
(...args: infer A1): infer R1;
|
|
114
|
+
(...args: infer A2): infer R2;
|
|
115
|
+
(...args: infer A3): infer R3;
|
|
116
|
+
(...args: infer A4): infer R4;
|
|
117
|
+
(...args: infer A5): infer R5;
|
|
118
|
+
} ?
|
|
119
|
+
& ((...args: ClientProxyArgs<A1>) => ClientProxyReturn<R1>)
|
|
120
|
+
& ((...args: ClientProxyArgs<A2>) => ClientProxyReturn<R2>)
|
|
121
|
+
& ((...args: ClientProxyArgs<A3>) => ClientProxyReturn<R3>)
|
|
122
|
+
& ((...args: ClientProxyArgs<A4>) => ClientProxyReturn<R4>)
|
|
123
|
+
& ((...args: ClientProxyArgs<A5>) => ClientProxyReturn<R5>)
|
|
124
|
+
: T extends {
|
|
125
|
+
(...args: infer A1): infer R1;
|
|
126
|
+
(...args: infer A2): infer R2;
|
|
127
|
+
(...args: infer A3): infer R3;
|
|
128
|
+
(...args: infer A4): infer R4;
|
|
129
|
+
} ?
|
|
130
|
+
& ((...args: ClientProxyArgs<A1>) => ClientProxyReturn<R1>)
|
|
131
|
+
& ((...args: ClientProxyArgs<A2>) => ClientProxyReturn<R2>)
|
|
132
|
+
& ((...args: ClientProxyArgs<A3>) => ClientProxyReturn<R3>)
|
|
133
|
+
& ((...args: ClientProxyArgs<A4>) => ClientProxyReturn<R4>)
|
|
134
|
+
: T extends {
|
|
135
|
+
(...args: infer A1): infer R1;
|
|
136
|
+
(...args: infer A2): infer R2;
|
|
137
|
+
(...args: infer A3): infer R3;
|
|
138
|
+
} ?
|
|
139
|
+
& ((...args: ClientProxyArgs<A1>) => ClientProxyReturn<R1>)
|
|
140
|
+
& ((...args: ClientProxyArgs<A2>) => ClientProxyReturn<R2>)
|
|
141
|
+
& ((...args: ClientProxyArgs<A3>) => ClientProxyReturn<R3>)
|
|
142
|
+
: T extends {
|
|
143
|
+
(...args: infer A1): infer R1;
|
|
144
|
+
(...args: infer A2): infer R2;
|
|
145
|
+
} ?
|
|
146
|
+
& ((...args: ClientProxyArgs<A1>) => ClientProxyReturn<R1>)
|
|
147
|
+
& ((...args: ClientProxyArgs<A2>) => ClientProxyReturn<R2>)
|
|
148
|
+
: T extends (...args: infer A) => infer R
|
|
149
|
+
? (...args: ClientProxyArgs<A>) => ClientProxyReturn<R>
|
|
150
|
+
: never;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Transforms server-side API objects to client-side proxy objects with type-safe RPC methods.
|
|
154
|
+
*
|
|
155
|
+
* @typeParam T - The server-side API object type
|
|
156
|
+
*/
|
|
157
|
+
export type ClientProxyObject<T> = {
|
|
158
|
+
[K in keyof T]: ClientProxyFunction<T[K]>
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* WebSocket connection to a Lowlander server with type-safe RPC, automatic reconnection,
|
|
164
|
+
* and reactive updates.
|
|
165
|
+
*
|
|
166
|
+
* @typeParam T - The server-side API type (import from your server API file)
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* import type * as API from './server/api.js';
|
|
171
|
+
* const conn = new Connection<typeof API>('ws://localhost:8080/');
|
|
172
|
+
*
|
|
173
|
+
* // Simple RPC - returns PromiseProxy
|
|
174
|
+
* const sum = conn.api.add(1, 2);
|
|
175
|
+
*
|
|
176
|
+
* // Server proxy for stateful APIs
|
|
177
|
+
* const auth = conn.api.authenticate('token');
|
|
178
|
+
* const secret = auth.serverProxy.getSecret();
|
|
179
|
+
*
|
|
180
|
+
* // Streaming with callbacks
|
|
181
|
+
* conn.api.streamData(data => console.log(data));
|
|
182
|
+
*
|
|
183
|
+
* // Use within Aberdeen reactive scopes
|
|
184
|
+
* $(() => {
|
|
185
|
+
* dump(conn.isOnline());
|
|
186
|
+
* dump(sum);
|
|
187
|
+
* });
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
export class Connection<T> {
|
|
191
|
+
|
|
192
|
+
private ws?: WebSocket;
|
|
193
|
+
private activeRequests = new Map<number, ActiveRequest>();
|
|
194
|
+
private requestCounter = 0;
|
|
195
|
+
private reconnectAttempts = 0;
|
|
196
|
+
/** @internal */
|
|
197
|
+
public _proxyCounter = 0;
|
|
198
|
+
private onlineProxy = A.proxy(false);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Type-safe proxy to the server-side API. Methods return `PromiseProxy` objects
|
|
202
|
+
* that work reactively in Aberdeen scopes. `ServerProxy` returns include a
|
|
203
|
+
* `.serverProxy` property for accessing stateful server APIs.
|
|
204
|
+
*/
|
|
205
|
+
public api: ClientProxyObject<T>;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param url - WebSocket URL (e.g., 'ws://localhost:8080/'), or a fake WebSocket object for testing
|
|
209
|
+
*/
|
|
210
|
+
constructor(public url: string | (() => WebSocket)) {
|
|
211
|
+
this.api = new Proxy({connection: this, requestId: undefined} as ProxyTargetType, proxyHandlers);
|
|
212
|
+
this.connect();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Returns the current connection status. Reactive in Aberdeen scopes.
|
|
217
|
+
*/
|
|
218
|
+
isOnline(): boolean { return this.onlineProxy.value; }
|
|
219
|
+
|
|
220
|
+
private connect() {
|
|
221
|
+
const ws: WebSocket = this.ws = typeof this.url === 'string'
|
|
222
|
+
? new WebSocket(this.url)
|
|
223
|
+
: this.url();
|
|
224
|
+
ws.binaryType = "arraybuffer";
|
|
225
|
+
if (logLevel >= 1) console.log(`[lowlander] Connecting to WebSocket at ${typeof this.url === 'string' ? this.url : '[custom WebSocket]'}`);
|
|
226
|
+
|
|
227
|
+
ws.onopen = () => {
|
|
228
|
+
if (ws !== this.ws) return; // No longer the current connection
|
|
229
|
+
if (logLevel >= 1) console.log('[lowlander] WebSocket connected');
|
|
230
|
+
this.onlineProxy.value = true;
|
|
231
|
+
this.reconnectAttempts = 0;
|
|
232
|
+
for(const request of this.activeRequests.values()) {
|
|
233
|
+
request.resultProxy.busy = true;
|
|
234
|
+
this.ws!.send(request.requestBuffer);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
ws.onclose = () => {
|
|
239
|
+
if (ws !== this.ws) return; // No longer the current connection
|
|
240
|
+
if (logLevel >= 1) console.log('[lowlander] WebSocket disconnected');
|
|
241
|
+
this.reconnect();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
ws.onerror = (error: any) => {
|
|
245
|
+
if (ws !== this.ws) return; // No longer the current connection
|
|
246
|
+
console.error('WebSocket error:', error);
|
|
247
|
+
this.reconnect();
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
ws.onmessage = (event: any) => {
|
|
251
|
+
if (ws !== this.ws) return; // No longer the current connection
|
|
252
|
+
const pack = new DataPack(new Uint8Array(event.data));
|
|
253
|
+
// console.log(`onmessage: ${pack}`);
|
|
254
|
+
const requestId = pack.readPositiveInt();
|
|
255
|
+
|
|
256
|
+
const request = this.activeRequests.get(requestId);
|
|
257
|
+
if (!request) return; // Raced
|
|
258
|
+
const result = A.unproxy(request.resultProxy);
|
|
259
|
+
|
|
260
|
+
const type = pack.read();
|
|
261
|
+
if (typeof type === 'number') {
|
|
262
|
+
// It's a callback invocation
|
|
263
|
+
const callback = request.callbacks?.[type];
|
|
264
|
+
const args: any[] = [];
|
|
265
|
+
while(pack.readAvailable()) {
|
|
266
|
+
args.push(pack.read());
|
|
267
|
+
}
|
|
268
|
+
callback!(...args);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// This packet type does not represent the result for a request
|
|
273
|
+
if (type === SERVER_MESSAGES.model_data) {
|
|
274
|
+
request.database ||= new Map();
|
|
275
|
+
request.commitIds ||= new Map();
|
|
276
|
+
const dbKeyHash = pack.readNumber();
|
|
277
|
+
const commitId = pack.readNumber();
|
|
278
|
+
const delta = pack.read({model: function(linkHash: number) {
|
|
279
|
+
const linkedModel = request.database!.get(linkHash);
|
|
280
|
+
if (!linkedModel) console.error('Unknown linked model hash ' + linkHash);
|
|
281
|
+
return linkedModel;
|
|
282
|
+
}});
|
|
283
|
+
if (logLevel >= 3) console.log('[lowlander] incoming model_data', requestId, dbKeyHash, commitId, delta);
|
|
284
|
+
// Schedule cleanup: after 15s, all out-of-order messages for this commitId
|
|
285
|
+
// must have arrived, so we can prune tracking entries at or below it.
|
|
286
|
+
setTimeout(() => this.pruneCommitIds(request, commitId), 15000);
|
|
287
|
+
let prevCommitIds = request.commitIds.get(dbKeyHash);
|
|
288
|
+
if (!delta) {
|
|
289
|
+
// Stale delete: some key was already updated past this commitId
|
|
290
|
+
if (prevCommitIds && commitId < Math.max(...prevCommitIds.values())) return;
|
|
291
|
+
request.database.delete(dbKeyHash);
|
|
292
|
+
// Record delete's commitId so stale creates arriving later are rejected
|
|
293
|
+
request.commitIds.set(dbKeyHash, new Map([[DEFAULT_COMMIT, commitId]]));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
let org = request.database.get(dbKeyHash);
|
|
297
|
+
if (org) {
|
|
298
|
+
// Update existing object
|
|
299
|
+
if (!prevCommitIds) {
|
|
300
|
+
prevCommitIds = new Map();
|
|
301
|
+
request.commitIds.set(dbKeyHash, prevCommitIds);
|
|
302
|
+
}
|
|
303
|
+
for (const key of Object.keys(delta)) {
|
|
304
|
+
if (commitId < (prevCommitIds.get(key) ?? prevCommitIds.get(DEFAULT_COMMIT) ?? -1)) continue;
|
|
305
|
+
if (delta[key] && typeof delta[key] === 'object') {
|
|
306
|
+
A.copy(org, key, delta[key]);
|
|
307
|
+
} else {
|
|
308
|
+
org[key] = delta[key];
|
|
309
|
+
}
|
|
310
|
+
prevCommitIds.set(key, commitId);
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
// Create new object
|
|
314
|
+
if (prevCommitIds && commitId < (prevCommitIds.get(DEFAULT_COMMIT) ?? -1)) return; // Stale create
|
|
315
|
+
request.database.set(dbKeyHash, A.proxy(delta));
|
|
316
|
+
request.commitIds.set(dbKeyHash, new Map([[DEFAULT_COMMIT, commitId]]));
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Each request should get one of these packet types as a response
|
|
322
|
+
if (type === SERVER_MESSAGES.error) {
|
|
323
|
+
const errorMessage = pack.readString();
|
|
324
|
+
request.resultProxy.error = new Error(errorMessage);
|
|
325
|
+
if (logLevel >= 2) console.log(`[lowlander] incoming error requestId=${requestId} message=${errorMessage}`);
|
|
326
|
+
|
|
327
|
+
} else if (type === SERVER_MESSAGES.response || type === SERVER_MESSAGES.response_proxy) {
|
|
328
|
+
request.resultProxy.value = pack.read();
|
|
329
|
+
request.virtualSocketIds = pack.read() as number[] | undefined;
|
|
330
|
+
request.hasServerProxy = type === SERVER_MESSAGES.response_proxy;
|
|
331
|
+
if (logLevel >= 2) console.log(`[lowlander] incoming response requestId=${requestId} value=${result.value} virtualSocketIds=${request.virtualSocketIds} hasServerProxy=${request.hasServerProxy}`);
|
|
332
|
+
|
|
333
|
+
} else if (type === SERVER_MESSAGES.response_model) {
|
|
334
|
+
request.virtualSocketIds = pack.read() as number[]; // There must be at least one, for the model stream
|
|
335
|
+
const dbKey = pack.readNumber();
|
|
336
|
+
const obj = request.database?.get(dbKey);
|
|
337
|
+
if (logLevel >= 2) console.log(`[lowlander] incoming response_model requestId=${requestId} dbKey=${dbKey} obj=${obj}`);
|
|
338
|
+
if (obj) {
|
|
339
|
+
request.resultProxy.value = A.proxy(obj);
|
|
340
|
+
} else {
|
|
341
|
+
request.resultProxy.error = new Error('Unknown database key ' + dbKey);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
} else {
|
|
345
|
+
throw new Error('Unknown message type ' + type);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Common cleanup for all response types
|
|
349
|
+
|
|
350
|
+
if (!request.hasServerProxy && !request.virtualSocketIds?.length) {
|
|
351
|
+
this.activeRequests.delete(requestId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!request.hasServerProxy) {
|
|
355
|
+
delete (request.resultProxy as any).serverProxy;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
request.resultProxy.busy = false;
|
|
359
|
+
|
|
360
|
+
if (request.resolve) {
|
|
361
|
+
// This does not happen on reconnect
|
|
362
|
+
if (result.error != null) {
|
|
363
|
+
console.error(result.error);
|
|
364
|
+
request.reject!(result.error);
|
|
365
|
+
} else {
|
|
366
|
+
request.resolve(result.value);
|
|
367
|
+
}
|
|
368
|
+
delete request.resolve;
|
|
369
|
+
delete request.reject;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private reconnect() {
|
|
375
|
+
this.ws = undefined;
|
|
376
|
+
|
|
377
|
+
this.onlineProxy.value = false;
|
|
378
|
+
|
|
379
|
+
if (typeof this.url !== 'string') return; // No reconnect in test mode
|
|
380
|
+
|
|
381
|
+
// Reconnect with exponential backoff
|
|
382
|
+
const delay = Math.min(500 * Math.pow(2, this.reconnectAttempts), 20000);
|
|
383
|
+
this.reconnectAttempts++;
|
|
384
|
+
if (logLevel >= 1) console.log(`[lowlander] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
385
|
+
setTimeout(this.connect.bind(this), delay);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private pruneCommitIds(request: ActiveRequest, maxCommitId: number) {
|
|
389
|
+
if (!request.commitIds) return;
|
|
390
|
+
for (const [hash, entry] of request.commitIds) {
|
|
391
|
+
for (const [key, cid] of entry) {
|
|
392
|
+
if (cid <= maxCommitId) entry.delete(key);
|
|
393
|
+
}
|
|
394
|
+
if (!entry.size) request.commitIds.delete(hash);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** @internal */
|
|
399
|
+
public _createMethodStub(methodName: string, proxyId?: number) {
|
|
400
|
+
return (...params: any[]) => {
|
|
401
|
+
const result = {busy: true} as PromiseProxy<any> & {promise: Promise<any>} & {serverProxy: any};
|
|
402
|
+
const resultProxy = A.proxy(result);
|
|
403
|
+
|
|
404
|
+
const requestId = ++this.requestCounter;
|
|
405
|
+
|
|
406
|
+
const pack = new DataPack();
|
|
407
|
+
pack.write(requestId).write(CLIENT_MESSAGES.call).write(proxyId).write(methodName);
|
|
408
|
+
|
|
409
|
+
let callbacks;
|
|
410
|
+
pack.writeCollection("array", () => {
|
|
411
|
+
for(const param of params) {
|
|
412
|
+
if (typeof param === 'function') {
|
|
413
|
+
callbacks ||= [];
|
|
414
|
+
pack.writeCustom('cb', callbacks.length);
|
|
415
|
+
callbacks.push(param);
|
|
416
|
+
} else {
|
|
417
|
+
pack.write(param);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const request: ActiveRequest = { resultProxy, requestBuffer: pack.toUint8Array(true), requestId, connection: this, callbacks };
|
|
423
|
+
result.serverProxy = new Proxy(request, proxyHandlers);
|
|
424
|
+
result.promise = new Promise((resolve, reject) => {
|
|
425
|
+
request.resolve = resolve;
|
|
426
|
+
request.reject = reject;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
this.activeRequests.set(requestId, request);
|
|
430
|
+
|
|
431
|
+
if (logLevel >= 2) console.log(`[lowlander] outgoing call requestId=${requestId} method=${methodName} params=`, params);
|
|
432
|
+
|
|
433
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
434
|
+
this.ws.send(request.requestBuffer);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
A.clean(() => {
|
|
438
|
+
this.activeRequests.delete(requestId);
|
|
439
|
+
if (request.virtualSocketIds?.length || request.hasServerProxy) {
|
|
440
|
+
if (logLevel >= 2) console.log(`[lowlander] outgoing cancel requestId=${request.requestId} virtualSocketIds=${request.virtualSocketIds} hasServerProxy=${request.hasServerProxy}`);
|
|
441
|
+
const data = DataPack.createUint8Array(
|
|
442
|
+
++this.requestCounter,
|
|
443
|
+
CLIENT_MESSAGES.cancel,
|
|
444
|
+
request.requestId,
|
|
445
|
+
request.virtualSocketIds,
|
|
446
|
+
);
|
|
447
|
+
this.ws?.send(data);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
return resultProxy;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const proxyHandlers: ProxyHandler<any> = {
|
|
457
|
+
get(target: ProxyTargetType, prop: string) {
|
|
458
|
+
if (target.serverProxyCache?.has(prop)) {
|
|
459
|
+
return target.serverProxyCache.get(prop);
|
|
460
|
+
}
|
|
461
|
+
if (prop === 'then' || prop === 'catch' || prop === 'finally') {
|
|
462
|
+
// When someone awaits the proxy, we should not treat it as a remote method call
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
if (prop === 'constructor') {
|
|
466
|
+
return {name: 'ServerProxy-' + target.requestId};
|
|
467
|
+
}
|
|
468
|
+
const result = target.connection._createMethodStub(prop, target.requestId);
|
|
469
|
+
if (!target.serverProxyCache) target.serverProxyCache = new Map();
|
|
470
|
+
target.serverProxyCache.set(prop, result);
|
|
471
|
+
return result;
|
|
472
|
+
},
|
|
473
|
+
has(_target: ProxyTargetType, prop: string | symbol) {
|
|
474
|
+
return typeof prop === 'string' || prop === A.NO_COPY;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
interface ProxyTargetType {
|
|
479
|
+
connection: Connection<any>;
|
|
480
|
+
requestId: number | undefined;
|
|
481
|
+
serverProxyCache?: Map<string, () => PromiseProxy<any>>;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
interface ActiveRequest extends ProxyTargetType {
|
|
485
|
+
resultProxy: PromiseProxy<any>;
|
|
486
|
+
callbacks?: ((...args: any[]) => void)[];
|
|
487
|
+
virtualSocketIds?: number[];
|
|
488
|
+
database?: Map<number, Record<any,any>>; // For model streams
|
|
489
|
+
commitIds?: Map<number, Map<string | symbol, number>>; // dbKeyHash -> (key|DEFAULT_COMMIT) -> commitId
|
|
490
|
+
requestBuffer: Uint8Array;
|
|
491
|
+
requestId: number; // Client-generated unique ID for this request
|
|
492
|
+
hasServerProxy?: boolean; // When true, the requestId has an object associated on the server side (which needs to be dropped on 'clean')
|
|
493
|
+
resolve?: (value: any) => void;
|
|
494
|
+
reject?: (error: any) => void;
|
|
495
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
html,
|
|
2
|
+
body {
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
}
|
|
5
|
+
body {
|
|
6
|
+
background-color: #0a0f0a;
|
|
7
|
+
background-image:
|
|
8
|
+
linear-gradient(rgba(0, 120, 0, 0.1) 1px, transparent 1px),
|
|
9
|
+
linear-gradient(90deg, rgba(0, 120, 0, 0.1) 1px, transparent 1px);
|
|
10
|
+
background-size: 20px 20px;
|
|
11
|
+
color: #00ff00;
|
|
12
|
+
font-family: 'Courier New', Courier, monospace;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
padding: 20px;
|
|
15
|
+
}
|
|
16
|
+
h2 {
|
|
17
|
+
border-bottom: 1px solid rgba(0, 255, 0, 0.3);
|
|
18
|
+
padding-bottom: 5px;
|
|
19
|
+
margin-top: 25px;
|
|
20
|
+
}
|
|
21
|
+
ul {
|
|
22
|
+
list-style-type: none;
|
|
23
|
+
padding-left: 20px;
|
|
24
|
+
}
|
|
25
|
+
input, button {
|
|
26
|
+
border: 1px solid #00ff00;
|
|
27
|
+
padding: 8px 12px;
|
|
28
|
+
color: #00ff00;
|
|
29
|
+
font-family: inherit;
|
|
30
|
+
background-color: rgba(0, 255, 0, 0.1);
|
|
31
|
+
}
|
|
32
|
+
input:focus, button:hover {
|
|
33
|
+
outline: none;
|
|
34
|
+
}
|
|
35
|
+
button {
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
background-color: green;
|
|
38
|
+
color: white;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.busy {
|
|
42
|
+
cursor: default;
|
|
43
|
+
pointer-events: none;
|
|
44
|
+
opacity: 0.6;
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" dir="ltr" class="mdui-theme-dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Lowlander Hello World Example</title>
|
|
6
|
+
<meta
|
|
7
|
+
name="viewport"
|
|
8
|
+
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
|
9
|
+
/>
|
|
10
|
+
<meta name="format-detection" content="telephone=no" />
|
|
11
|
+
<meta name="msapplication-tap-highlight" content="no" />
|
|
12
|
+
<meta name="theme-color" content="#31d53d" />
|
|
13
|
+
<link
|
|
14
|
+
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
|
15
|
+
rel="stylesheet"
|
|
16
|
+
/>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<script src="./js/base.ts" type="module"></script>
|
|
20
|
+
</body>
|
|
21
|
+
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import 'mdui/mdui.css';
|
|
2
|
+
import 'mdui/components/button.js';
|
|
3
|
+
import 'mdui/components/dialog.js';
|
|
4
|
+
import 'mdui/components/tabs.js';
|
|
5
|
+
import 'mdui/components/tab.js';
|
|
6
|
+
import 'mdui/components/tab-panel.js';
|
|
7
|
+
import type { Dialog } from 'mdui/components/dialog.js';
|
|
8
|
+
|
|
9
|
+
import A from "aberdeen";
|
|
10
|
+
import { ClientProxyObject } from 'lowlander/client';
|
|
11
|
+
import type * as ServerAPI from '../../server/api.js';
|
|
12
|
+
type API = ClientProxyObject<typeof ServerAPI>;
|
|
13
|
+
|
|
14
|
+
const TOPICS = ['sockets', 'channels', 'workers', 'kv'] as const;
|
|
15
|
+
|
|
16
|
+
function formatDecodedBytes(value: Uint8Array) {
|
|
17
|
+
let text = '';
|
|
18
|
+
for (const byte of value) {
|
|
19
|
+
const codePoint = byte;
|
|
20
|
+
const char = String.fromCodePoint(codePoint);
|
|
21
|
+
const printableAscii = codePoint >= 0x20 && codePoint <= 0x7e;
|
|
22
|
+
const whitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
|
|
23
|
+
text += printableAscii || whitespace ? char : `<${codePoint}>`;
|
|
24
|
+
}
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const tableStyle = A.insertCss({
|
|
29
|
+
'&': 'border-collapse:collapse text-align:left box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);',
|
|
30
|
+
'tr:nth-child(even)': 'background-color:#0002;',
|
|
31
|
+
'td,th': 'padding: $1 $2; border-left: 1px solid #0002;',
|
|
32
|
+
'td:first-child,th:first-child': 'border-left:none',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export function showAdminModal(api: API) {
|
|
36
|
+
|
|
37
|
+
const refreshes = A.proxy(0);
|
|
38
|
+
const closed = A.proxy(false);
|
|
39
|
+
A.mount(document.body, () => {
|
|
40
|
+
if (A.peek(closed, 'value') || closed.value) return;
|
|
41
|
+
A('mdui-dialog open= close-on-overlay-click= close-on-esc= headline="Lowlander Admin panel" closed=', () => closed.value = true, () => {
|
|
42
|
+
A('span slot=description mdui-tabs value=', TOPICS[0], () => {
|
|
43
|
+
for(const topic of TOPICS) {
|
|
44
|
+
A('mdui-tab value=',topic, 'text=',topic);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for(const topic of TOPICS) {
|
|
48
|
+
A('mdui-tab-panel slot=panel value=', topic, () => {
|
|
49
|
+
const sockets = api.getDebugState(topic as any);
|
|
50
|
+
refreshes.value;
|
|
51
|
+
A(() => {
|
|
52
|
+
const data = A.unproxy(sockets.value) as undefined | Record<string, Record<string, any>> | Record<string, any>[];
|
|
53
|
+
if (!data) return;
|
|
54
|
+
const keySet = new Set<string>();
|
|
55
|
+
for (const [, obj] of Object.entries(data)) {
|
|
56
|
+
if (obj && typeof obj === 'object' && !(obj instanceof Uint8Array))
|
|
57
|
+
for (const k of Object.keys(obj)) keySet.add(k);
|
|
58
|
+
}
|
|
59
|
+
const cols = [...keySet];
|
|
60
|
+
A('table', tableStyle, () => {
|
|
61
|
+
A('tr', () => {
|
|
62
|
+
A('th text=#');
|
|
63
|
+
for (const k of cols) A('th text=', k);
|
|
64
|
+
});
|
|
65
|
+
for (const [idx, obj] of Object.entries(data)) {
|
|
66
|
+
A('tr', () => {
|
|
67
|
+
A('td text=', idx);
|
|
68
|
+
for (const k of cols) {
|
|
69
|
+
const value = obj?.[k];
|
|
70
|
+
let text = '';
|
|
71
|
+
if (value instanceof Uint8Array) {
|
|
72
|
+
text = formatDecodedBytes(value);
|
|
73
|
+
}
|
|
74
|
+
else if (value !== null && value !== undefined) {
|
|
75
|
+
text = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
76
|
+
? String(value)
|
|
77
|
+
: JSON.stringify(value);
|
|
78
|
+
}
|
|
79
|
+
A('td text=', text);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const dialog = A() as Dialog;
|
|
90
|
+
A('mdui-button slot=action variant=text text=Update click=', () => refreshes.value++);
|
|
91
|
+
A('mdui-button slot=action variant=text text=Close click=', () => dialog.open = false);
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
}
|