@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.
- package/README.md +0 -0
- package/package.json +40 -0
- package/src/DocumentManager.ts +252 -0
- package/src/DocumentProtocol.ts +112 -0
- package/src/MimicAuthService.ts +103 -0
- package/src/MimicConfig.ts +131 -0
- package/src/MimicDataStorage.ts +157 -0
- package/src/MimicServer.ts +363 -0
- package/src/PresenceManager.ts +297 -0
- package/src/WebSocketHandler.ts +735 -0
- package/src/auth/NoAuth.ts +46 -0
- package/src/errors.ts +113 -0
- package/src/index.ts +48 -0
- package/src/storage/InMemoryDataStorage.ts +66 -0
- package/tests/DocumentManager.test.ts +340 -0
- package/tests/DocumentProtocol.test.ts +113 -0
- package/tests/InMemoryDataStorage.test.ts +190 -0
- package/tests/MimicAuthService.test.ts +185 -0
- package/tests/MimicConfig.test.ts +175 -0
- package/tests/MimicDataStorage.test.ts +190 -0
- package/tests/MimicServer.test.ts +385 -0
- package/tests/NoAuth.test.ts +94 -0
- package/tests/PresenceManager.test.ts +421 -0
- package/tests/WebSocketHandler.test.ts +321 -0
- package/tests/errors.test.ts +77 -0
- package/tsconfig.build.json +24 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +18 -0
- package/vitest.mts +11 -0
|
@@ -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
|
+
});
|