@voidhash/mimic-effect 0.0.1-alpha.1

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,735 @@
1
+ /**
2
+ * @since 0.0.1
3
+ * WebSocket connection handler using Effect Platform Socket API.
4
+ */
5
+ import * as Effect from "effect/Effect";
6
+ import * as Stream from "effect/Stream";
7
+ import * as Fiber from "effect/Fiber";
8
+ import * as Scope from "effect/Scope";
9
+ import * as Duration from "effect/Duration";
10
+ import type * as Socket from "@effect/platform/Socket";
11
+ import { Transaction, Presence } from "@voidhash/mimic";
12
+
13
+ import * as Protocol from "./DocumentProtocol.js";
14
+ import { MimicServerConfigTag } from "./MimicConfig.js";
15
+ import { MimicAuthServiceTag } from "./MimicAuthService.js";
16
+ import { DocumentManagerTag } from "./DocumentManager.js";
17
+ import { PresenceManagerTag } from "./PresenceManager.js";
18
+ import type * as PresenceManager from "./PresenceManager.js";
19
+ import {
20
+ MessageParseError,
21
+ MissingDocumentIdError,
22
+ } from "./errors.js";
23
+
24
+ // =============================================================================
25
+ // Client Message Types (matching mimic-client Transport.ts)
26
+ // =============================================================================
27
+
28
+ interface SubmitMessage {
29
+ readonly type: "submit";
30
+ readonly transaction: Protocol.Transaction;
31
+ }
32
+
33
+ interface EncodedSubmitMessage {
34
+ readonly type: "submit";
35
+ readonly transaction: Transaction.EncodedTransaction;
36
+ }
37
+
38
+ interface RequestSnapshotMessage {
39
+ readonly type: "request_snapshot";
40
+ }
41
+
42
+ interface PingMessage {
43
+ readonly type: "ping";
44
+ }
45
+
46
+ interface AuthMessage {
47
+ readonly type: "auth";
48
+ readonly token: string;
49
+ }
50
+
51
+ interface PresenceSetMessage {
52
+ readonly type: "presence_set";
53
+ readonly data: unknown;
54
+ }
55
+
56
+ interface PresenceClearMessage {
57
+ readonly type: "presence_clear";
58
+ }
59
+
60
+ type ClientMessage =
61
+ | SubmitMessage
62
+ | RequestSnapshotMessage
63
+ | PingMessage
64
+ | AuthMessage
65
+ | PresenceSetMessage
66
+ | PresenceClearMessage;
67
+
68
+ type EncodedClientMessage =
69
+ | EncodedSubmitMessage
70
+ | RequestSnapshotMessage
71
+ | PingMessage
72
+ | AuthMessage
73
+ | PresenceSetMessage
74
+ | PresenceClearMessage;
75
+
76
+ // =============================================================================
77
+ // Server Message Types (matching mimic-client Transport.ts)
78
+ // =============================================================================
79
+
80
+ interface PongMessage {
81
+ readonly type: "pong";
82
+ }
83
+
84
+ interface AuthResultMessage {
85
+ readonly type: "auth_result";
86
+ readonly success: boolean;
87
+ readonly error?: string;
88
+ }
89
+
90
+ interface EncodedTransactionMessage {
91
+ readonly type: "transaction";
92
+ readonly transaction: Transaction.EncodedTransaction;
93
+ readonly version: number;
94
+ }
95
+
96
+ // Presence server messages
97
+ interface PresenceSnapshotMessage {
98
+ readonly type: "presence_snapshot";
99
+ readonly selfId: string;
100
+ readonly presences: Record<string, { data: unknown; userId?: string }>;
101
+ }
102
+
103
+ interface PresenceUpdateMessage {
104
+ readonly type: "presence_update";
105
+ readonly id: string;
106
+ readonly data: unknown;
107
+ readonly userId?: string;
108
+ }
109
+
110
+ interface PresenceRemoveMessage {
111
+ readonly type: "presence_remove";
112
+ readonly id: string;
113
+ }
114
+
115
+ type ServerMessage =
116
+ | Protocol.TransactionMessage
117
+ | Protocol.SnapshotMessage
118
+ | Protocol.ErrorMessage
119
+ | PongMessage
120
+ | AuthResultMessage
121
+ | PresenceSnapshotMessage
122
+ | PresenceUpdateMessage
123
+ | PresenceRemoveMessage;
124
+
125
+ type EncodedServerMessage =
126
+ | EncodedTransactionMessage
127
+ | Protocol.SnapshotMessage
128
+ | Protocol.ErrorMessage
129
+ | PongMessage
130
+ | AuthResultMessage
131
+ | PresenceSnapshotMessage
132
+ | PresenceUpdateMessage
133
+ | PresenceRemoveMessage;
134
+
135
+ // =============================================================================
136
+ // WebSocket Connection State
137
+ // =============================================================================
138
+
139
+ interface ConnectionState {
140
+ readonly documentId: string;
141
+ readonly connectionId: string;
142
+ readonly authenticated: boolean;
143
+ readonly userId?: string;
144
+ }
145
+
146
+ // =============================================================================
147
+ // URL Path Parsing
148
+ // =============================================================================
149
+
150
+ /**
151
+ * Extract document ID from URL path.
152
+ * Expected format: /doc/{documentId}
153
+ */
154
+ export const extractDocumentId = (
155
+ path: string
156
+ ): Effect.Effect<string, MissingDocumentIdError> => {
157
+ // Remove leading slash and split
158
+ const parts = path.replace(/^\/+/, "").split("/");
159
+
160
+ // Find the last occurrence of 'doc' in the path
161
+ const docIndex = parts.lastIndexOf("doc");
162
+ const part = parts[docIndex + 1];
163
+ if (docIndex !== -1 && part) {
164
+ return Effect.succeed(decodeURIComponent(part));
165
+ }
166
+ return Effect.fail(new MissingDocumentIdError({}));
167
+ };
168
+
169
+ // =============================================================================
170
+ // Message Parsing
171
+ // =============================================================================
172
+
173
+ /**
174
+ * Decodes an encoded client message from the wire format.
175
+ */
176
+ const decodeClientMessage = (encoded: EncodedClientMessage): ClientMessage => {
177
+ if (encoded.type === "submit") {
178
+ return {
179
+ type: "submit",
180
+ transaction: Transaction.decode(encoded.transaction),
181
+ };
182
+ }
183
+ return encoded;
184
+ };
185
+
186
+ /**
187
+ * Encodes a server message for the wire format.
188
+ */
189
+ const encodeServerMessageForWire = (message: ServerMessage): EncodedServerMessage => {
190
+ if (message.type === "transaction") {
191
+ return {
192
+ type: "transaction",
193
+ transaction: Transaction.encode(message.transaction),
194
+ version: message.version,
195
+ };
196
+ }
197
+ return message;
198
+ };
199
+
200
+ const parseClientMessage = (
201
+ data: string | Uint8Array
202
+ ): Effect.Effect<ClientMessage, MessageParseError> =>
203
+ Effect.try({
204
+ try: () => {
205
+ const text =
206
+ typeof data === "string" ? data : new TextDecoder().decode(data);
207
+ const encoded = JSON.parse(text) as EncodedClientMessage;
208
+ return decodeClientMessage(encoded);
209
+ },
210
+ catch: (cause) => new MessageParseError({ cause }),
211
+ });
212
+
213
+ const encodeServerMessage = (message: ServerMessage): string =>
214
+ JSON.stringify(encodeServerMessageForWire(message));
215
+
216
+ // =============================================================================
217
+ // WebSocket Handler
218
+ // =============================================================================
219
+
220
+ /**
221
+ * Handle a WebSocket connection for a document.
222
+ *
223
+ * @param socket - The Effect Platform Socket
224
+ * @param path - The URL path (e.g., "/doc/my-document-id")
225
+ * @returns An Effect that handles the connection lifecycle
226
+ */
227
+ export const handleConnection = (
228
+ socket: Socket.Socket,
229
+ path: string
230
+ ): Effect.Effect<
231
+ void,
232
+ Socket.SocketError | MissingDocumentIdError | MessageParseError,
233
+ MimicServerConfigTag | MimicAuthServiceTag | DocumentManagerTag | PresenceManagerTag | Scope.Scope
234
+ > =>
235
+ Effect.gen(function* () {
236
+ const config = yield* MimicServerConfigTag;
237
+ const authService = yield* MimicAuthServiceTag;
238
+ const documentManager = yield* DocumentManagerTag;
239
+ const presenceManager = yield* PresenceManagerTag;
240
+
241
+ // Extract document ID from path
242
+ const documentId = yield* extractDocumentId(path);
243
+ const connectionId = crypto.randomUUID();
244
+
245
+ // Track connection state
246
+ let state: ConnectionState = {
247
+ documentId,
248
+ connectionId,
249
+ authenticated: false, // Start unauthenticated, auth service will validate
250
+ };
251
+
252
+ // Track if this connection has set presence (for cleanup)
253
+ let hasPresence = false;
254
+
255
+ // Get the socket writer
256
+ const write = yield* socket.writer;
257
+
258
+ // Helper to send a message to the client
259
+ const sendMessage = (message: ServerMessage) =>
260
+ write(encodeServerMessage(message));
261
+
262
+ // Send presence snapshot after auth
263
+ const sendPresenceSnapshot = Effect.gen(function* () {
264
+ if (!config.presence) return;
265
+
266
+ const snapshot = yield* presenceManager.getSnapshot(documentId);
267
+ yield* sendMessage({
268
+ type: "presence_snapshot",
269
+ selfId: connectionId,
270
+ presences: snapshot.presences,
271
+ });
272
+ });
273
+
274
+ // Handle authentication using the auth service
275
+ const handleAuth = (token: string) =>
276
+ Effect.gen(function* () {
277
+ const result = yield* authService.authenticate(token);
278
+
279
+ if (result.success) {
280
+ state = {
281
+ ...state,
282
+ authenticated: true,
283
+ userId: result.userId,
284
+ };
285
+ yield* sendMessage({ type: "auth_result", success: true });
286
+
287
+ // Send presence snapshot after successful auth
288
+ yield* sendPresenceSnapshot;
289
+ } else {
290
+ yield* sendMessage({
291
+ type: "auth_result",
292
+ success: false,
293
+ error: result.error,
294
+ });
295
+ }
296
+ });
297
+
298
+ // Handle presence set
299
+ const handlePresenceSet = (data: unknown) =>
300
+ Effect.gen(function* () {
301
+ if (!state.authenticated) return;
302
+ if (!config.presence) return;
303
+
304
+ // Validate presence data against schema
305
+ const validated = Presence.validateSafe(config.presence, data);
306
+ if (validated === undefined) {
307
+ yield* Effect.logWarning("Invalid presence data received", { connectionId, data });
308
+ return;
309
+ }
310
+
311
+ // Store in presence manager
312
+ yield* presenceManager.set(documentId, connectionId, {
313
+ data: validated,
314
+ userId: state.userId,
315
+ });
316
+
317
+ hasPresence = true;
318
+ });
319
+
320
+ // Handle presence clear
321
+ const handlePresenceClear = Effect.gen(function* () {
322
+ if (!state.authenticated) return;
323
+ if (!config.presence) return;
324
+
325
+ yield* presenceManager.remove(documentId, connectionId);
326
+ hasPresence = false;
327
+ });
328
+
329
+ // Handle a client message
330
+ const handleMessage = (message: ClientMessage) =>
331
+ Effect.gen(function* () {
332
+ switch (message.type) {
333
+ case "auth":
334
+ yield* handleAuth(message.token);
335
+ break;
336
+
337
+ case "ping":
338
+ yield* sendMessage({ type: "pong" });
339
+ break;
340
+
341
+ case "submit":
342
+ if (!state.authenticated) {
343
+ yield* sendMessage({
344
+ type: "error",
345
+ transactionId: message.transaction.id,
346
+ reason: "Not authenticated",
347
+ });
348
+ return;
349
+ }
350
+ // Submit to the document manager
351
+ const submitResult = yield* documentManager.submit(
352
+ documentId,
353
+ message.transaction as any
354
+ );
355
+ // If rejected, send error (success is broadcast to all)
356
+ if (!submitResult.success) {
357
+ yield* sendMessage({
358
+ type: "error",
359
+ transactionId: message.transaction.id,
360
+ reason: submitResult.reason,
361
+ });
362
+ }
363
+ break;
364
+
365
+ case "request_snapshot":
366
+ if (!state.authenticated) {
367
+ return;
368
+ }
369
+ const snapshot = yield* Effect.catchAll(
370
+ documentManager.getSnapshot(documentId),
371
+ () =>
372
+ Effect.succeed({
373
+ type: "snapshot" as const,
374
+ state: null,
375
+ version: 0,
376
+ })
377
+ );
378
+ yield* sendMessage(snapshot);
379
+ break;
380
+
381
+ case "presence_set":
382
+ yield* handlePresenceSet(message.data);
383
+ break;
384
+
385
+ case "presence_clear":
386
+ yield* handlePresenceClear;
387
+ break;
388
+ }
389
+ });
390
+
391
+ // Subscribe to document broadcasts
392
+ const subscribeFiber = yield* Effect.fork(
393
+ Effect.gen(function* () {
394
+ // Wait until authenticated before subscribing
395
+ while (!state.authenticated) {
396
+ yield* Effect.sleep(Duration.millis(100));
397
+ }
398
+
399
+ // Subscribe to the document
400
+ const broadcastStream = yield* Effect.catchAll(
401
+ documentManager.subscribe(documentId),
402
+ () => Effect.succeed(Stream.empty)
403
+ );
404
+
405
+ // Forward broadcasts to the WebSocket
406
+ yield* Stream.runForEach(broadcastStream, (broadcast) =>
407
+ sendMessage(broadcast as ServerMessage)
408
+ );
409
+ }).pipe(Effect.scoped)
410
+ );
411
+
412
+ // Subscribe to presence events (if presence is enabled)
413
+ const presenceFiber = yield* Effect.fork(
414
+ Effect.gen(function* () {
415
+ if (!config.presence) return;
416
+
417
+ // Wait until authenticated before subscribing
418
+ while (!state.authenticated) {
419
+ yield* Effect.sleep(Duration.millis(100));
420
+ }
421
+
422
+ // Subscribe to presence events
423
+ const presenceStream = yield* presenceManager.subscribe(documentId);
424
+
425
+ // Forward presence events to the WebSocket, filtering out our own events (no-echo)
426
+ yield* Stream.runForEach(presenceStream, (event) =>
427
+ Effect.gen(function* () {
428
+ // Don't echo our own presence events
429
+ if (event.id === connectionId) return;
430
+
431
+ if (event.type === "presence_update") {
432
+ yield* sendMessage({
433
+ type: "presence_update",
434
+ id: event.id,
435
+ data: event.data,
436
+ userId: event.userId,
437
+ });
438
+ } else if (event.type === "presence_remove") {
439
+ yield* sendMessage({
440
+ type: "presence_remove",
441
+ id: event.id,
442
+ });
443
+ }
444
+ })
445
+ );
446
+ }).pipe(Effect.scoped)
447
+ );
448
+
449
+ // Ensure cleanup on disconnect
450
+ yield* Effect.addFinalizer(() =>
451
+ Effect.gen(function* () {
452
+ // Interrupt the subscribe fibers
453
+ yield* Fiber.interrupt(subscribeFiber);
454
+ yield* Fiber.interrupt(presenceFiber);
455
+
456
+ // Remove presence if we had any
457
+ if (hasPresence && config.presence) {
458
+ yield* presenceManager.remove(documentId, connectionId);
459
+ }
460
+ })
461
+ );
462
+
463
+ // Process incoming messages
464
+ yield* socket.runRaw((data) =>
465
+ Effect.gen(function* () {
466
+ const message = yield* parseClientMessage(data);
467
+ yield* handleMessage(message);
468
+ }).pipe(
469
+ Effect.catchAll((error) =>
470
+ Effect.logError("Message handling error", error)
471
+ )
472
+ )
473
+ );
474
+ });
475
+
476
+ // =============================================================================
477
+ // WebSocket Server Handler Factory
478
+ // =============================================================================
479
+
480
+ /**
481
+ * Create a handler function for the WebSocket server.
482
+ * Returns a function that takes a socket and document ID.
483
+ */
484
+ export const makeHandler = Effect.gen(function* () {
485
+ const config = yield* MimicServerConfigTag;
486
+ const authService = yield* MimicAuthServiceTag;
487
+ const documentManager = yield* DocumentManagerTag;
488
+ const presenceManager = yield* PresenceManagerTag;
489
+
490
+ return (socket: Socket.Socket, documentId: string) =>
491
+ handleConnectionWithDocumentId(socket, documentId).pipe(
492
+ Effect.provideService(MimicServerConfigTag, config),
493
+ Effect.provideService(MimicAuthServiceTag, authService),
494
+ Effect.provideService(DocumentManagerTag, documentManager),
495
+ Effect.provideService(PresenceManagerTag, presenceManager),
496
+ Effect.scoped
497
+ );
498
+ });
499
+
500
+ /**
501
+ * Handle a WebSocket connection for a document (using document ID directly).
502
+ */
503
+ const handleConnectionWithDocumentId = (
504
+ socket: Socket.Socket,
505
+ documentId: string
506
+ ): Effect.Effect<
507
+ void,
508
+ Socket.SocketError | MessageParseError,
509
+ MimicServerConfigTag | MimicAuthServiceTag | DocumentManagerTag | PresenceManagerTag | Scope.Scope
510
+ > =>
511
+ Effect.gen(function* () {
512
+ const config = yield* MimicServerConfigTag;
513
+ const authService = yield* MimicAuthServiceTag;
514
+ const documentManager = yield* DocumentManagerTag;
515
+ const presenceManager = yield* PresenceManagerTag;
516
+
517
+ const connectionId = crypto.randomUUID();
518
+
519
+ // Track connection state
520
+ let state: ConnectionState = {
521
+ documentId,
522
+ connectionId,
523
+ authenticated: false,
524
+ };
525
+
526
+ // Track if this connection has set presence (for cleanup)
527
+ let hasPresence = false;
528
+
529
+ // Get the socket writer
530
+ const write = yield* socket.writer;
531
+
532
+ // Helper to send a message to the client
533
+ const sendMessage = (message: ServerMessage) =>
534
+ write(encodeServerMessage(message));
535
+
536
+ // Send presence snapshot after auth
537
+ const sendPresenceSnapshot = Effect.gen(function* () {
538
+ if (!config.presence) return;
539
+
540
+ const snapshot = yield* presenceManager.getSnapshot(documentId);
541
+ yield* sendMessage({
542
+ type: "presence_snapshot",
543
+ selfId: connectionId,
544
+ presences: snapshot.presences,
545
+ });
546
+ });
547
+
548
+ // Handle authentication using the auth service
549
+ const handleAuth = (token: string) =>
550
+ Effect.gen(function* () {
551
+ const result = yield* authService.authenticate(token);
552
+
553
+ if (result.success) {
554
+ state = {
555
+ ...state,
556
+ authenticated: true,
557
+ userId: result.userId,
558
+ };
559
+ yield* sendMessage({ type: "auth_result", success: true });
560
+
561
+ // Send presence snapshot after successful auth
562
+ yield* sendPresenceSnapshot;
563
+ } else {
564
+ yield* sendMessage({
565
+ type: "auth_result",
566
+ success: false,
567
+ error: result.error,
568
+ });
569
+ }
570
+ });
571
+
572
+ // Handle presence set
573
+ const handlePresenceSet = (data: unknown) =>
574
+ Effect.gen(function* () {
575
+ if (!state.authenticated) return;
576
+ if (!config.presence) return;
577
+
578
+ // Validate presence data against schema
579
+ const validated = Presence.validateSafe(config.presence, data);
580
+ if (validated === undefined) {
581
+ yield* Effect.logWarning("Invalid presence data received", { connectionId, data });
582
+ return;
583
+ }
584
+
585
+ // Store in presence manager
586
+ yield* presenceManager.set(documentId, connectionId, {
587
+ data: validated,
588
+ userId: state.userId,
589
+ });
590
+
591
+ hasPresence = true;
592
+ });
593
+
594
+ // Handle presence clear
595
+ const handlePresenceClear = Effect.gen(function* () {
596
+ if (!state.authenticated) return;
597
+ if (!config.presence) return;
598
+
599
+ yield* presenceManager.remove(documentId, connectionId);
600
+ hasPresence = false;
601
+ });
602
+
603
+ // Handle a client message
604
+ const handleMessage = (message: ClientMessage) =>
605
+ Effect.gen(function* () {
606
+ switch (message.type) {
607
+ case "auth":
608
+ yield* handleAuth(message.token);
609
+ break;
610
+
611
+ case "ping":
612
+ yield* sendMessage({ type: "pong" });
613
+ break;
614
+
615
+ case "submit":
616
+ if (!state.authenticated) {
617
+ yield* sendMessage({
618
+ type: "error",
619
+ transactionId: message.transaction.id,
620
+ reason: "Not authenticated",
621
+ });
622
+ return;
623
+ }
624
+ const submitResult = yield* documentManager.submit(
625
+ documentId,
626
+ message.transaction as any
627
+ );
628
+ if (!submitResult.success) {
629
+ yield* sendMessage({
630
+ type: "error",
631
+ transactionId: message.transaction.id,
632
+ reason: submitResult.reason,
633
+ });
634
+ }
635
+ break;
636
+
637
+ case "request_snapshot":
638
+ if (!state.authenticated) {
639
+ return;
640
+ }
641
+ const snapshot = yield* documentManager.getSnapshot(documentId);
642
+ yield* sendMessage(snapshot);
643
+ break;
644
+
645
+ case "presence_set":
646
+ yield* handlePresenceSet(message.data);
647
+ break;
648
+
649
+ case "presence_clear":
650
+ yield* handlePresenceClear;
651
+ break;
652
+ }
653
+ });
654
+
655
+ // Subscribe to document broadcasts
656
+ const subscribeFiber = yield* Effect.fork(
657
+ Effect.gen(function* () {
658
+ // Wait until authenticated before subscribing
659
+ while (!state.authenticated) {
660
+ yield* Effect.sleep(Duration.millis(100));
661
+ }
662
+
663
+ // Subscribe to the document
664
+ const broadcastStream = yield* documentManager.subscribe(documentId);
665
+
666
+ // Forward broadcasts to the WebSocket
667
+ yield* Stream.runForEach(broadcastStream, (broadcast) =>
668
+ sendMessage(broadcast as ServerMessage)
669
+ );
670
+ }).pipe(Effect.scoped)
671
+ );
672
+
673
+ // Subscribe to presence events (if presence is enabled)
674
+ const presenceFiber = yield* Effect.fork(
675
+ Effect.gen(function* () {
676
+ if (!config.presence) return;
677
+
678
+ // Wait until authenticated before subscribing
679
+ while (!state.authenticated) {
680
+ yield* Effect.sleep(Duration.millis(100));
681
+ }
682
+
683
+ // Subscribe to presence events
684
+ const presenceStream = yield* presenceManager.subscribe(documentId);
685
+
686
+ // Forward presence events to the WebSocket, filtering out our own events (no-echo)
687
+ yield* Stream.runForEach(presenceStream, (event) =>
688
+ Effect.gen(function* () {
689
+ // Don't echo our own presence events
690
+ if (event.id === connectionId) return;
691
+
692
+ if (event.type === "presence_update") {
693
+ yield* sendMessage({
694
+ type: "presence_update",
695
+ id: event.id,
696
+ data: event.data,
697
+ userId: event.userId,
698
+ });
699
+ } else if (event.type === "presence_remove") {
700
+ yield* sendMessage({
701
+ type: "presence_remove",
702
+ id: event.id,
703
+ });
704
+ }
705
+ })
706
+ );
707
+ }).pipe(Effect.scoped)
708
+ );
709
+
710
+ // Ensure cleanup on disconnect
711
+ yield* Effect.addFinalizer(() =>
712
+ Effect.gen(function* () {
713
+ // Interrupt the subscribe fibers
714
+ yield* Fiber.interrupt(subscribeFiber);
715
+ yield* Fiber.interrupt(presenceFiber);
716
+
717
+ // Remove presence if we had any
718
+ if (hasPresence && config.presence) {
719
+ yield* presenceManager.remove(documentId, connectionId);
720
+ }
721
+ })
722
+ );
723
+
724
+ // Process incoming messages
725
+ yield* socket.runRaw((data) =>
726
+ Effect.gen(function* () {
727
+ const message = yield* parseClientMessage(data);
728
+ yield* handleMessage(message);
729
+ }).pipe(
730
+ Effect.catchAll((error) =>
731
+ Effect.logError("Message handling error", error)
732
+ )
733
+ )
734
+ );
735
+ });