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,452 @@
1
+ /**
2
+ * In-process fake implementation of warpsocket for testing.
3
+ *
4
+ * Provides the full warpsocket API (send, subscribe, virtual sockets, KV store)
5
+ * backed by in-memory data structures. Message delivery uses setTimeout(fn, 0)
6
+ * so it integrates with Aberdeen's fakedom passTime().
7
+ */
8
+ import * as pathMod from 'node:path';
9
+
10
+ import type { WorkerInterface } from 'warpsocket';
11
+ let workerModule: WorkerInterface;
12
+
13
+ interface VirtualSocket {
14
+ targetSocketId: number;
15
+ userPrefix?: Uint8Array;
16
+ }
17
+
18
+ interface FakeClientSocket {
19
+ socketId: number;
20
+ onmessage?: ((event: {data: ArrayBuffer}) => void) | null;
21
+ onopen?: (() => void) | null;
22
+ onclose?: (() => void) | null;
23
+ onerror?: ((error: any) => void) | null;
24
+ readyState: number;
25
+ binaryType: string;
26
+ send(data: Uint8Array | ArrayBuffer): void;
27
+ close(): void;
28
+ }
29
+
30
+ type ChannelDebug = { channel: Uint8Array, subscribers: { [socketId: number]: number } };
31
+ type SocketDebug = { ip: string, workerId: number } | { targetSocketId: number, userPrefix?: Uint8Array };
32
+ type WorkerDebug = { hasTextHandler: boolean, hasBinaryHandler: boolean, hasCloseHandler: boolean, hasOpenHandler: boolean };
33
+ type KVDebug = { key: Uint8Array, value: Uint8Array };
34
+
35
+ let nextSocketId = 1;
36
+ let nextVirtualSocketId = 100000; // High range to avoid collisions
37
+ let virtualSockets = new Map<number, VirtualSocket>();
38
+ let channels = new Map<string, Map<number, number>>(); // channelKey -> socketId -> refCount
39
+ let channelBytes = new Map<string, Uint8Array>(); // channelKey -> original channel name bytes
40
+ let kv = new Map<string, Uint8Array>();
41
+ let kvKeyBytes = new Map<string, Uint8Array>(); // keyStr -> original key bytes
42
+ let clientSockets = new Map<number, FakeClientSocket>();
43
+
44
+ /** Convert any accepted type to Uint8Array */
45
+ function toBytes(input: Uint8Array | ArrayBuffer | string): Uint8Array {
46
+ if (input instanceof Uint8Array) return input;
47
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
48
+ return new TextEncoder().encode(input);
49
+ }
50
+
51
+ function keyStr(key: Uint8Array | ArrayBuffer | string): string {
52
+ if (typeof key === 'string') return key;
53
+ const bytes = key instanceof Uint8Array ? key : new Uint8Array(key);
54
+ return Array.from(bytes).join(',');
55
+ }
56
+
57
+ function channelKey(name: Uint8Array | ArrayBuffer | string): string {
58
+ return 'ch:' + keyStr(name);
59
+ }
60
+
61
+ export function send(target: number | number[] | Uint8Array | ArrayBuffer | string | (number | Uint8Array | ArrayBuffer | string)[], data: Uint8Array | ArrayBuffer | string): number {
62
+ const buf = toBytes(data);
63
+
64
+ if (typeof target === 'number') {
65
+ return sendToSocket(target, buf);
66
+ } else if (typeof target === 'string' || target instanceof Uint8Array || target instanceof ArrayBuffer) {
67
+ return sendToChannel(target, buf);
68
+ } else if (Array.isArray(target)) {
69
+ let count = 0;
70
+ for (const item of target) {
71
+ if (typeof item === 'number') {
72
+ count += sendToSocket(item, buf);
73
+ } else {
74
+ count += sendToChannel(item, buf);
75
+ }
76
+ }
77
+ return count;
78
+ }
79
+ return 0;
80
+ }
81
+
82
+ function sendToSocket(id: number, data: Uint8Array): number {
83
+ const vs = virtualSockets.get(id);
84
+ if (vs) {
85
+ let finalData: Uint8Array;
86
+ if (vs.userPrefix) {
87
+ finalData = new Uint8Array(vs.userPrefix.length + data.length);
88
+ finalData.set(vs.userPrefix, 0);
89
+ finalData.set(data, vs.userPrefix.length);
90
+ } else {
91
+ finalData = data;
92
+ }
93
+ return sendToSocket(vs.targetSocketId, finalData);
94
+ }
95
+
96
+ const client = clientSockets.get(id);
97
+ if (!client || client.readyState !== 1) return 0;
98
+
99
+ const copy = new Uint8Array(data).slice();
100
+ setTimeout(() => {
101
+ if (client.readyState === 1 && client.onmessage) {
102
+ client.onmessage({data: copy.buffer});
103
+ }
104
+ }, 0);
105
+ return 1;
106
+ }
107
+
108
+ function sendToChannel(channelName: Uint8Array | ArrayBuffer | string, data: Uint8Array): number {
109
+ const key = channelKey(channelName);
110
+ const subscribers = channels.get(key);
111
+ if (!subscribers) return 0;
112
+ let count = 0;
113
+ for (const vsId of subscribers.keys()) {
114
+ count += sendToSocket(vsId, data);
115
+ }
116
+ return count;
117
+ }
118
+
119
+ /**
120
+ * Subscribes one or more WebSocket connections to a channel, or copies subscriptions from one channel to another.
121
+ * Multiple subscriptions to the same channel by the same connection are reference-counted.
122
+ *
123
+ * @param socketIdOrChannelName - Can be:
124
+ * - A single socket ID (number): applies delta to that socket's subscription
125
+ * - An array of socket IDs (number[]): applies delta to all sockets' subscriptions
126
+ * - A channel name (Buffer/ArrayBuffer/string): applies delta to all subscribers of this source channel
127
+ * - An array mixing socket IDs and channel names: applies delta to sockets and source channel subscribers
128
+ * @param channelName - The target channel name (Buffer, ArrayBuffer, or string).
129
+ * @param delta - Optional. The amount to change the subscription count by (default: 1).
130
+ * Positive values add subscriptions, negative values remove them. When the count reaches zero, the subscription is removed.
131
+ * @returns An array of socket IDs that were affected by the operation:
132
+ * - For positive delta: socket IDs that became newly subscribed (reference count went from 0 to positive)
133
+ * - For negative delta: socket IDs that became completely unsubscribed (reference count reached 0)
134
+ */
135
+ export function subscribe(target: number | number[] | Uint8Array | ArrayBuffer | string | (number | Uint8Array | ArrayBuffer | string)[], channelName: Uint8Array | ArrayBuffer | string, delta: number = 1): number[] {
136
+ const key = channelKey(channelName);
137
+ let subscribers = channels.get(key);
138
+ if (!subscribers) {
139
+ channels.set(key, subscribers = new Map());
140
+ channelBytes.set(key, toBytes(channelName));
141
+ }
142
+
143
+ // Resolve targets to socket IDs
144
+ let socketIds: number[];
145
+ if (typeof target === 'number') {
146
+ socketIds = [target];
147
+ } else if (Array.isArray(target)) {
148
+ socketIds = [];
149
+ for (const item of target) {
150
+ if (typeof item === 'number') {
151
+ socketIds.push(item);
152
+ } else {
153
+ // Channel name — gather its subscribers
154
+ const srcSubs = channels.get(channelKey(item));
155
+ if (srcSubs) for (const sid of srcSubs.keys()) socketIds.push(sid);
156
+ }
157
+ }
158
+ } else {
159
+ // target is a channel name (Uint8Array | ArrayBuffer | string)
160
+ const srcSubs = channels.get(channelKey(target));
161
+ socketIds = srcSubs ? [...srcSubs.keys()] : [];
162
+ }
163
+
164
+ const changed: number[] = [];
165
+ for (const sid of socketIds) {
166
+ const current = subscribers.get(sid) || 0;
167
+ const next = current + delta;
168
+ if (delta > 0) {
169
+ subscribers.set(sid, next);
170
+ if (current === 0) changed.push(sid);
171
+ } else {
172
+ if (next <= 0) {
173
+ subscribers.delete(sid);
174
+ if (current > 0) changed.push(sid);
175
+ } else {
176
+ subscribers.set(sid, next);
177
+ }
178
+ }
179
+ }
180
+ if (!subscribers.size) {
181
+ channels.delete(key);
182
+ channelBytes.delete(key);
183
+ }
184
+ return changed;
185
+ }
186
+
187
+ /**
188
+ * Exactly the same as `subscribe`, only with a negative delta (defaulting to 1, which means a single unsubscribe, or a subscribe with delta -1).
189
+ */
190
+ export function unsubscribe(socketIdOrChannelName: number | number[] | Uint8Array | ArrayBuffer | string | (number | Uint8Array | ArrayBuffer | string)[], channelName: Uint8Array | ArrayBuffer | string, delta: number = 1): number[] {
191
+ return subscribe(socketIdOrChannelName, channelName, -delta);
192
+ }
193
+
194
+ /**
195
+ * **DEPRECATED:** Use subscribe(fromChannelName, toChannelName) instead.
196
+ *
197
+ * Copies all subscribers from one channel to another channel. Uses reference counting - if a subscriber
198
+ * is already subscribed to the destination channel, their reference count will be incremented instead
199
+ * of creating duplicate subscriptions.
200
+ * @param fromChannelName - The source channel name (Buffer, ArrayBuffer, or string).
201
+ * @param toChannelName - The destination channel name (Buffer, ArrayBuffer, or string).
202
+ * @returns An array of socket IDs that were newly added to the destination channel. Sockets that were
203
+ * already subscribed (and had their reference count incremented) are not included.
204
+ */
205
+ export function copySubscriptions(fromChannelName: Uint8Array | ArrayBuffer | string, toChannelName: Uint8Array | ArrayBuffer | string): number[] {
206
+ return subscribe(fromChannelName, toChannelName);
207
+ }
208
+
209
+ export function hasSubscriptions(channelName: Uint8Array | ArrayBuffer | string): boolean {
210
+ const key = channelKey(channelName);
211
+ const subscribers = channels.get(key);
212
+ return !!subscribers && subscribers.size > 0;
213
+ }
214
+
215
+ export function createVirtualSocket(socketId: number, userPrefix?: Uint8Array | ArrayBuffer | string): number {
216
+ const vsId = nextVirtualSocketId++;
217
+ virtualSockets.set(vsId, {
218
+ targetSocketId: socketId,
219
+ userPrefix: userPrefix != null ? toBytes(userPrefix) : undefined,
220
+ });
221
+ return vsId;
222
+ }
223
+
224
+ export function deleteVirtualSocket(virtualSocketId: number, expectedTargetSocketId?: number): boolean {
225
+ const vs = virtualSockets.get(virtualSocketId);
226
+ if (!vs) return false;
227
+ if (expectedTargetSocketId !== undefined && vs.targetSocketId !== expectedTargetSocketId) return false;
228
+ virtualSockets.delete(virtualSocketId);
229
+ // Unsubscribe from all channels
230
+ for (const [key, subscribers] of channels) {
231
+ subscribers.delete(virtualSocketId);
232
+ if (!subscribers.size) {
233
+ channels.delete(key);
234
+ channelBytes.delete(key);
235
+ }
236
+ }
237
+ return true;
238
+ }
239
+
240
+ export function getKey(key: Uint8Array | ArrayBuffer | string): Uint8Array | undefined {
241
+ return kv.get(keyStr(key));
242
+ }
243
+
244
+ export function setKey(key: Uint8Array | ArrayBuffer | string, value?: Uint8Array | ArrayBuffer | string): Uint8Array | undefined {
245
+ const k = keyStr(key);
246
+ const prev = kv.get(k);
247
+ if (value == null) {
248
+ kv.delete(k);
249
+ kvKeyBytes.delete(k);
250
+ } else {
251
+ kv.set(k, toBytes(value));
252
+ if (!kvKeyBytes.has(k)) kvKeyBytes.set(k, toBytes(key));
253
+ }
254
+ return prev;
255
+ }
256
+
257
+ export function setKeyIf(key: Uint8Array | ArrayBuffer | string, newValue?: Uint8Array | ArrayBuffer | string, checkValue?: Uint8Array | ArrayBuffer | string): boolean {
258
+ const k = keyStr(key);
259
+ const current = kv.get(k);
260
+ const checkBytes = checkValue != null ? toBytes(checkValue) : undefined;
261
+ // Compare: both undefined, or byte-equal
262
+ const matches = (current === undefined && checkBytes === undefined) || (
263
+ current !== undefined && checkBytes !== undefined &&
264
+ current.length === checkBytes.length &&
265
+ current.every((v, i) => v === checkBytes[i])
266
+ );
267
+ if (!matches) return false;
268
+ if (newValue == null) {
269
+ kv.delete(k);
270
+ kvKeyBytes.delete(k);
271
+ } else {
272
+ kv.set(k, toBytes(newValue));
273
+ if (!kvKeyBytes.has(k)) kvKeyBytes.set(k, toBytes(key));
274
+ }
275
+ return true;
276
+ }
277
+
278
+ export function getDebugState(mode: "channels"): ChannelDebug[];
279
+ export function getDebugState(mode: "channels", channelName: Uint8Array | ArrayBuffer | string): ChannelDebug | undefined;
280
+ export function getDebugState(mode: "channels", filterSocketId: number): ChannelDebug[];
281
+ export function getDebugState(mode: "sockets"): Record<number, SocketDebug>;
282
+ export function getDebugState(mode: "sockets", socketId: number): SocketDebug | undefined;
283
+ export function getDebugState(mode: "workers"): Record<number, WorkerDebug>;
284
+ export function getDebugState(mode: "workers", workerId: number): WorkerDebug | undefined;
285
+ export function getDebugState(mode: "kv"): KVDebug[];
286
+ export function getDebugState(mode: string, singleKey?: any): any {
287
+ if (mode === 'channels') {
288
+ if (singleKey !== undefined) {
289
+ if (typeof singleKey === 'number') {
290
+ // Filter: all channels this socket is subscribed to
291
+ const result: ChannelDebug[] = [];
292
+ for (const [key, subscribers] of channels) {
293
+ if (subscribers.has(singleKey)) {
294
+ const subs: { [id: number]: number } = {};
295
+ for (const [sid, count] of subscribers) subs[sid] = count;
296
+ result.push({ channel: channelBytes.get(key)!, subscribers: subs });
297
+ }
298
+ }
299
+ return result;
300
+ } else {
301
+ // Lookup single channel by name
302
+ const key = channelKey(singleKey);
303
+ const subscribers = channels.get(key);
304
+ if (!subscribers) return undefined;
305
+ const subs: { [id: number]: number } = {};
306
+ for (const [sid, count] of subscribers) subs[sid] = count;
307
+ return { channel: channelBytes.get(key)!, subscribers: subs } as ChannelDebug;
308
+ }
309
+ }
310
+ const result: ChannelDebug[] = [];
311
+ for (const [key, subscribers] of channels) {
312
+ const subs: { [id: number]: number } = {};
313
+ for (const [sid, count] of subscribers) subs[sid] = count;
314
+ result.push({ channel: channelBytes.get(key)!, subscribers: subs });
315
+ }
316
+ return result;
317
+ }
318
+ if (mode === 'sockets') {
319
+ if (typeof singleKey === 'number') {
320
+ const vs = virtualSockets.get(singleKey);
321
+ if (vs) {
322
+ const debug: any = { targetSocketId: vs.targetSocketId };
323
+ if (vs.userPrefix) debug.userPrefix = vs.userPrefix;
324
+ return debug as SocketDebug;
325
+ }
326
+ const cs = clientSockets.get(singleKey);
327
+ if (cs) return { ip: '127.0.0.1', workerId: 0 } as SocketDebug;
328
+ return undefined;
329
+ }
330
+ const result: Record<number, SocketDebug> = {};
331
+ for (const [id] of clientSockets) {
332
+ result[id] = { ip: '127.0.0.1', workerId: 0 };
333
+ }
334
+ for (const [id, vs] of virtualSockets) {
335
+ const debug: any = { targetSocketId: vs.targetSocketId };
336
+ if (vs.userPrefix) debug.userPrefix = vs.userPrefix;
337
+ result[id] = debug;
338
+ }
339
+ return result;
340
+ }
341
+ if (mode === 'workers') {
342
+ const info: WorkerDebug = {
343
+ hasTextHandler: !!workerModule?.handleTextMessage,
344
+ hasBinaryHandler: !!workerModule?.handleBinaryMessage,
345
+ hasCloseHandler: !!workerModule?.handleClose,
346
+ hasOpenHandler: !!workerModule?.handleOpen,
347
+ };
348
+ if (typeof singleKey === 'number') return singleKey === 0 ? info : undefined;
349
+ return { 0: info } as Record<number, WorkerDebug>;
350
+ }
351
+ if (mode === 'kv') {
352
+ const result: KVDebug[] = [];
353
+ for (const [k, v] of kv) {
354
+ result.push({ key: kvKeyBytes.get(k) || toBytes(k), value: v });
355
+ }
356
+ return result;
357
+ }
358
+ return {};
359
+ }
360
+
361
+ /** Create a fake client-side WebSocket that's wired to this in-process server. */
362
+ export function createClientSocket(): WebSocket {
363
+ const socketId = nextSocketId++;
364
+
365
+ // Serialize message processing per socket (like real warpsocket)
366
+ let messageQueue: Promise<void> = Promise.resolve();
367
+
368
+ const socket: FakeClientSocket = {
369
+ socketId,
370
+ onmessage: null,
371
+ onopen: null,
372
+ onclose: null,
373
+ onerror: null,
374
+ readyState: 0, // CONNECTING
375
+ binaryType: 'arraybuffer',
376
+
377
+ send(data: Uint8Array | ArrayBuffer) {
378
+ const buf = data instanceof Uint8Array ? data : new Uint8Array(data);
379
+ // Queue message processing to ensure sequential handling
380
+ messageQueue = messageQueue.then(() => new Promise<void>(resolve => {
381
+ setTimeout(async () => {
382
+ try {
383
+ await workerModule.handleBinaryMessage?.(buf, socketId);
384
+ } catch (e) {
385
+ console.error('FakeWarpSocket: handleBinaryMessage error', e);
386
+ }
387
+ resolve();
388
+ }, 0);
389
+ }));
390
+ },
391
+
392
+ close() {
393
+ if (socket.readyState >= 2) return;
394
+ socket.readyState = 2; // CLOSING
395
+ clientSockets.delete(socketId);
396
+ setTimeout(() => {
397
+ socket.readyState = 3; // CLOSED
398
+ workerModule.handleClose?.(socketId);
399
+ socket.onclose?.();
400
+ }, 0);
401
+ },
402
+ };
403
+
404
+ clientSockets.set(socketId, socket);
405
+
406
+ // Open asynchronously
407
+ setTimeout(() => {
408
+ socket.readyState = 1; // OPEN
409
+ workerModule.handleOpen?.(socketId, '127.0.0.1', {"user-agent": "FakeWarpSocket"});
410
+ socket.onopen?.();
411
+ }, 0);
412
+
413
+ return socket as any;
414
+ }
415
+
416
+ /** Reset all state (for between tests). */
417
+ export function reset() {
418
+ for (const client of clientSockets.values()) {
419
+ client.readyState = 3;
420
+ }
421
+ clientSockets.clear();
422
+ virtualSockets.clear();
423
+ channels.clear();
424
+ channelBytes.clear();
425
+ kv.clear();
426
+ kvKeyBytes.clear();
427
+ nextSocketId = 1;
428
+ nextVirtualSocketId = 100000;
429
+ }
430
+
431
+ /**
432
+ * Initializes the fake warpsocket. Ignores `threads` and `bind`.
433
+ */
434
+
435
+ export async function start(options: {
436
+ bind: string | string[];
437
+ workerPath?: string;
438
+ threads?: number;
439
+ workerArg?: any;
440
+ }): Promise<void> {
441
+ if (!options || !options.bind || !options.workerPath) {
442
+ throw new Error('options.bind and options.workerPath are required');
443
+ }
444
+
445
+ if (!pathMod.isAbsolute(options.workerPath)) {
446
+ options.workerPath = pathMod.resolve(process.cwd(), options.workerPath);
447
+ }
448
+
449
+ workerModule = await import(options.workerPath);
450
+ await workerModule.handleStart?.(options.workerArg);
451
+ }
452
+
@@ -0,0 +1,151 @@
1
+ import { expect, test, beforeAll, afterEach, beforeEach } from "bun:test";
2
+ import { passTime, assertBody, reset as resetAberdeen } from "aberdeen/test-helpers";
3
+ import * as E from "edinburgh";
4
+ import { start } from "lowlander/server";
5
+ import { Connection } from "lowlander/client";
6
+ import type * as API from "../examples/helloworld/server/api.js";
7
+ import A from "aberdeen";
8
+ import * as fakeWarpSocket from "./fake-warpsocket.js";
9
+
10
+ beforeAll(async () => {
11
+ E.init('.edinburgh_test');
12
+ E.setMaxRetryCount(100);
13
+ await start(
14
+ new URL('../examples/helloworld/server/api.ts', import.meta.url).pathname,
15
+ { injectWarpSocket: fakeWarpSocket },
16
+ );
17
+ });
18
+
19
+ beforeEach(async () => {
20
+ await connect().api.resetTestData(true).promise;
21
+ });
22
+
23
+ afterEach(async () => {
24
+ fakeWarpSocket.reset();
25
+ A.unmountAll();
26
+ await resetAberdeen();
27
+ });
28
+
29
+ function connect() {
30
+ return new Connection<typeof API>(fakeWarpSocket.createClientSocket);
31
+ }
32
+
33
+ test('simple RPC: add', async () => {
34
+ const c = connect();
35
+ const sum = c.api.add(2, 3);
36
+ await passTime();
37
+ expect(sum.value).toBe(5);
38
+ });
39
+
40
+ test('authenticate returns ServerProxy', async () => {
41
+ const c = connect();
42
+ const auth = c.api.authenticate('Frank');
43
+ await passTime(1100);
44
+ expect(auth.value).toBe('secret');
45
+ });
46
+
47
+ test('authenticate with unknown user throws', async () => {
48
+ const c = connect();
49
+ const auth = c.api.authenticate('Nobody');
50
+ auth.promise.catch(() => {}); // prevent unhandled rejection
51
+ await passTime(1100);
52
+ expect(auth.error).toBeDefined();
53
+ expect(auth.error.message).toBe('User not found');
54
+ });
55
+
56
+ test('ServerProxy: getBio', async () => {
57
+ const c = connect();
58
+ const auth = c.api.authenticate('Frank');
59
+ const bio = auth.serverProxy.getBio();
60
+ await passTime(1100);
61
+ expect(bio.value).toContain('Frank is 45 years old');
62
+ });
63
+
64
+ test('ServerProxy: toggleFriend', async () => {
65
+ const c = connect();
66
+ const auth = c.api.authenticate('Frank');
67
+ const result = auth.serverProxy.toggleFriend('Alice');
68
+ await passTime(1100);
69
+ expect(typeof result.value).toBe('boolean');
70
+ });
71
+
72
+ test('model streaming: streamModel', async () => {
73
+ const c = connect();
74
+ const model = c.api.streamModel();
75
+ await passTime();
76
+ expect(model.value).toBeDefined();
77
+ expect(model.value!.name).toBe('Test');
78
+ expect(model.value!.owner).toBeDefined();
79
+ expect(model.value!.owner.name).toBe('Frank');
80
+ });
81
+
82
+ test('two clients see same RPC result', async () => {
83
+ const c1 = connect();
84
+ const c2 = connect();
85
+ const sum1 = c1.api.add(10, 20);
86
+ const sum2 = c2.api.add(10, 20);
87
+ await passTime();
88
+ expect(sum1.value).toBe(30);
89
+ expect(sum2.value).toBe(30);
90
+ });
91
+
92
+ test('online status', async () => {
93
+ const c = connect();
94
+ expect(c.isOnline()).toBe(false);
95
+ await passTime();
96
+ expect(c.isOnline()).toBe(true);
97
+ });
98
+
99
+ test('render RPC result in DOM', async () => {
100
+ const c = connect();
101
+ const sum = c.api.add(7, 8);
102
+ A(() => {
103
+ if (sum.value !== undefined) A('span#' + sum.value);
104
+ });
105
+ assertBody('');
106
+ await passTime();
107
+ expect(sum.value).toBe(15);
108
+ await passTime();
109
+ assertBody('span{"15"}');
110
+ });
111
+
112
+ test('two connections render in same DOM', async () => {
113
+ const c1 = connect();
114
+ const c2 = connect();
115
+ const s1 = c1.api.add(1, 2);
116
+ const s2 = c2.api.add(3, 4);
117
+ A('div', () => {
118
+ A(() => {
119
+ if (s1.value !== undefined) A('span.c1#' + s1.value);
120
+ });
121
+ A(() => {
122
+ if (s2.value !== undefined) A('span.c2#' + s2.value);
123
+ });
124
+ });
125
+ await passTime();
126
+ assertBody('div{span.c1{"3"} span.c2{"7"}}');
127
+ });
128
+
129
+ test('4 connections stream, mutate, and converge', async () => {
130
+ const conns = Array.from({length: 4}, () => connect());
131
+ const models = conns.map(c => c.api.streamModel());
132
+ A('div', () => {
133
+ for (const [i, m] of models.entries()) {
134
+ A(() => {
135
+ if (m.value) A(`span.c${i}#` + m.value.owner.age);
136
+ });
137
+ }
138
+ });
139
+ await passTime();
140
+ conns[0].api.setOwnerAge(100);
141
+ await passTime();
142
+ // All 4 connections increment simultaneously, racing through retries
143
+ await Promise.all(conns.map((c, i) => c.api.incrOwnerAge(i + 1).promise));
144
+ // Await UI update
145
+ await passTime();
146
+ // All 4 should converge; total increment = 1+2+3+4 = 10, so age = 110
147
+ assertBody('div{span.c0{"110"} span.c1{"110"} span.c2{"110"} span.c3{"110"}}');
148
+ // Restore original age
149
+ conns[0].api.setOwnerAge(45);
150
+ await passTime();
151
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "outDir": "./build",
7
+ "rootDir": ".",
8
+ "paths": {
9
+ "lowlander/client": ["./client/client.ts"],
10
+ "lowlander/server": ["./server/server.ts"]
11
+ }
12
+ },
13
+ "include": ["client/**/*.ts", "examples/**/client/**/*.ts"],
14
+ "exclude": ["node_modules", "build", "tests"],
15
+ "references": [
16
+ {"path": "./tsconfig.server.json"}
17
+ ]
18
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "experimentalDecorators": true,
5
+ "module": "ESNext",
6
+ "lib": ["ES2022"],
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "sourceMap": true,
12
+ "strict": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "noFallthroughCasesInSwitch": true
16
+ },
17
+ "references": [
18
+ { "path": "./tsconfig.server.json" },
19
+ { "path": "./tsconfig.client.json" },
20
+ { "path": "./tsconfig.test.json" }
21
+ ],
22
+ "files": [],
23
+ "exclude": ["node_modules", "build"]
24
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "lib": ["ES2020", "ESNext.WeakRef"],
6
+ "types": ["bun"],
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "outDir": "./build",
10
+ "rootDir": ".",
11
+ "paths": {
12
+ "lowlander/server": ["./server/server.ts"]
13
+ }
14
+ },
15
+ "include": ["server/**/*.ts", "examples/**/server/**/*.ts", "tests/fake-warpsocket.ts"],
16
+ "exclude": ["node_modules", "build", "tests"]
17
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
5
+ "types": ["bun"],
6
+ "noEmit": true,
7
+ "paths": {
8
+ "lowlander/server": ["./server/server.ts"],
9
+ "lowlander/client": ["./client/client.ts"]
10
+ }
11
+ },
12
+ "include": ["tests/**/*.ts"]
13
+ }