@voidhash/mimic-effect 0.0.9 → 1.0.0-beta.10

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.
Files changed (236) hide show
  1. package/.turbo/turbo-build.log +136 -90
  2. package/README.md +385 -0
  3. package/dist/ColdStorage.cjs +64 -0
  4. package/dist/ColdStorage.d.cts +53 -0
  5. package/dist/ColdStorage.d.cts.map +1 -0
  6. package/dist/ColdStorage.d.mts +53 -0
  7. package/dist/ColdStorage.d.mts.map +1 -0
  8. package/dist/ColdStorage.mjs +64 -0
  9. package/dist/ColdStorage.mjs.map +1 -0
  10. package/dist/DocumentInstance.cjs +263 -0
  11. package/dist/DocumentInstance.d.cts +78 -0
  12. package/dist/DocumentInstance.d.cts.map +1 -0
  13. package/dist/DocumentInstance.d.mts +78 -0
  14. package/dist/DocumentInstance.d.mts.map +1 -0
  15. package/dist/DocumentInstance.mjs +264 -0
  16. package/dist/DocumentInstance.mjs.map +1 -0
  17. package/dist/Errors.cjs +54 -0
  18. package/dist/Errors.d.cts +96 -0
  19. package/dist/Errors.d.cts.map +1 -0
  20. package/dist/Errors.d.mts +96 -0
  21. package/dist/Errors.d.mts.map +1 -0
  22. package/dist/Errors.mjs +48 -0
  23. package/dist/Errors.mjs.map +1 -0
  24. package/dist/HotStorage.cjs +104 -0
  25. package/dist/HotStorage.d.cts +70 -0
  26. package/dist/HotStorage.d.cts.map +1 -0
  27. package/dist/HotStorage.d.mts +70 -0
  28. package/dist/HotStorage.d.mts.map +1 -0
  29. package/dist/HotStorage.mjs +104 -0
  30. package/dist/HotStorage.mjs.map +1 -0
  31. package/dist/Metrics.cjs +149 -0
  32. package/dist/Metrics.d.cts +32 -0
  33. package/dist/Metrics.d.cts.map +1 -0
  34. package/dist/Metrics.d.mts +32 -0
  35. package/dist/Metrics.d.mts.map +1 -0
  36. package/dist/Metrics.mjs +131 -0
  37. package/dist/Metrics.mjs.map +1 -0
  38. package/dist/MimicAuthService.cjs +61 -45
  39. package/dist/MimicAuthService.d.cts +61 -48
  40. package/dist/MimicAuthService.d.cts.map +1 -1
  41. package/dist/MimicAuthService.d.mts +61 -48
  42. package/dist/MimicAuthService.d.mts.map +1 -1
  43. package/dist/MimicAuthService.mjs +60 -36
  44. package/dist/MimicAuthService.mjs.map +1 -1
  45. package/dist/MimicClusterServerEngine.cjs +348 -0
  46. package/dist/MimicClusterServerEngine.d.cts +17 -0
  47. package/dist/MimicClusterServerEngine.d.cts.map +1 -0
  48. package/dist/MimicClusterServerEngine.d.mts +17 -0
  49. package/dist/MimicClusterServerEngine.d.mts.map +1 -0
  50. package/dist/MimicClusterServerEngine.mjs +350 -0
  51. package/dist/MimicClusterServerEngine.mjs.map +1 -0
  52. package/dist/MimicServer.cjs +205 -96
  53. package/dist/MimicServer.d.cts +9 -110
  54. package/dist/MimicServer.d.cts.map +1 -1
  55. package/dist/MimicServer.d.mts +9 -110
  56. package/dist/MimicServer.d.mts.map +1 -1
  57. package/dist/MimicServer.mjs +206 -90
  58. package/dist/MimicServer.mjs.map +1 -1
  59. package/dist/MimicServerEngine.cjs +178 -0
  60. package/dist/MimicServerEngine.d.cts +83 -0
  61. package/dist/MimicServerEngine.d.cts.map +1 -0
  62. package/dist/MimicServerEngine.d.mts +83 -0
  63. package/dist/MimicServerEngine.d.mts.map +1 -0
  64. package/dist/MimicServerEngine.mjs +178 -0
  65. package/dist/MimicServerEngine.mjs.map +1 -0
  66. package/dist/PresenceManager.cjs +75 -91
  67. package/dist/PresenceManager.d.cts +17 -66
  68. package/dist/PresenceManager.d.cts.map +1 -1
  69. package/dist/PresenceManager.d.mts +17 -66
  70. package/dist/PresenceManager.d.mts.map +1 -1
  71. package/dist/PresenceManager.mjs +74 -78
  72. package/dist/PresenceManager.mjs.map +1 -1
  73. package/dist/Protocol.cjs +146 -0
  74. package/dist/Protocol.d.cts +203 -0
  75. package/dist/Protocol.d.cts.map +1 -0
  76. package/dist/Protocol.d.mts +203 -0
  77. package/dist/Protocol.d.mts.map +1 -0
  78. package/dist/Protocol.mjs +132 -0
  79. package/dist/Protocol.mjs.map +1 -0
  80. package/dist/Types.d.cts +179 -0
  81. package/dist/Types.d.cts.map +1 -0
  82. package/dist/Types.d.mts +179 -0
  83. package/dist/Types.d.mts.map +1 -0
  84. package/dist/_virtual/rolldown_runtime.cjs +1 -25
  85. package/dist/_virtual/rolldown_runtime.mjs +4 -1
  86. package/dist/index.cjs +37 -76
  87. package/dist/index.d.cts +13 -12
  88. package/dist/index.d.mts +13 -12
  89. package/dist/index.mjs +12 -12
  90. package/dist/testing/ColdStorageTestSuite.cjs +508 -0
  91. package/dist/testing/ColdStorageTestSuite.d.cts +36 -0
  92. package/dist/testing/ColdStorageTestSuite.d.cts.map +1 -0
  93. package/dist/testing/ColdStorageTestSuite.d.mts +36 -0
  94. package/dist/testing/ColdStorageTestSuite.d.mts.map +1 -0
  95. package/dist/testing/ColdStorageTestSuite.mjs +508 -0
  96. package/dist/testing/ColdStorageTestSuite.mjs.map +1 -0
  97. package/dist/testing/FailingStorage.cjs +162 -0
  98. package/dist/testing/FailingStorage.d.cts +43 -0
  99. package/dist/testing/FailingStorage.d.cts.map +1 -0
  100. package/dist/testing/FailingStorage.d.mts +43 -0
  101. package/dist/testing/FailingStorage.d.mts.map +1 -0
  102. package/dist/testing/FailingStorage.mjs +163 -0
  103. package/dist/testing/FailingStorage.mjs.map +1 -0
  104. package/dist/testing/HotStorageTestSuite.cjs +820 -0
  105. package/dist/testing/HotStorageTestSuite.d.cts +42 -0
  106. package/dist/testing/HotStorageTestSuite.d.cts.map +1 -0
  107. package/dist/testing/HotStorageTestSuite.d.mts +42 -0
  108. package/dist/testing/HotStorageTestSuite.d.mts.map +1 -0
  109. package/dist/testing/HotStorageTestSuite.mjs +820 -0
  110. package/dist/testing/HotStorageTestSuite.mjs.map +1 -0
  111. package/dist/testing/StorageIntegrationTestSuite.cjs +487 -0
  112. package/dist/testing/StorageIntegrationTestSuite.d.cts +37 -0
  113. package/dist/testing/StorageIntegrationTestSuite.d.cts.map +1 -0
  114. package/dist/testing/StorageIntegrationTestSuite.d.mts +37 -0
  115. package/dist/testing/StorageIntegrationTestSuite.d.mts.map +1 -0
  116. package/dist/testing/StorageIntegrationTestSuite.mjs +487 -0
  117. package/dist/testing/StorageIntegrationTestSuite.mjs.map +1 -0
  118. package/dist/testing/assertions.cjs +117 -0
  119. package/dist/testing/assertions.mjs +112 -0
  120. package/dist/testing/assertions.mjs.map +1 -0
  121. package/dist/testing/index.cjs +14 -0
  122. package/dist/testing/index.d.cts +6 -0
  123. package/dist/testing/index.d.mts +6 -0
  124. package/dist/testing/index.mjs +7 -0
  125. package/dist/testing/types.cjs +15 -0
  126. package/dist/testing/types.d.cts +90 -0
  127. package/dist/testing/types.d.cts.map +1 -0
  128. package/dist/testing/types.d.mts +90 -0
  129. package/dist/testing/types.d.mts.map +1 -0
  130. package/dist/testing/types.mjs +16 -0
  131. package/dist/testing/types.mjs.map +1 -0
  132. package/package.json +18 -3
  133. package/src/ColdStorage.ts +145 -0
  134. package/src/DocumentInstance.ts +527 -0
  135. package/src/Errors.ts +114 -0
  136. package/src/HotStorage.ts +256 -0
  137. package/src/Metrics.ts +193 -0
  138. package/src/MimicAuthService.ts +126 -64
  139. package/src/MimicClusterServerEngine.ts +669 -0
  140. package/src/MimicServer.ts +459 -198
  141. package/src/MimicServerEngine.ts +472 -0
  142. package/src/PresenceManager.ts +173 -234
  143. package/src/Protocol.ts +350 -0
  144. package/src/Types.ts +238 -0
  145. package/src/index.ts +27 -23
  146. package/src/testing/ColdStorageTestSuite.ts +589 -0
  147. package/src/testing/FailingStorage.ts +338 -0
  148. package/src/testing/HotStorageTestSuite.ts +1105 -0
  149. package/src/testing/StorageIntegrationTestSuite.ts +736 -0
  150. package/src/testing/assertions.ts +188 -0
  151. package/src/testing/index.ts +83 -0
  152. package/src/testing/types.ts +100 -0
  153. package/tests/ColdStorage.test.ts +24 -0
  154. package/tests/DocumentInstance.test.ts +669 -0
  155. package/tests/HotStorage.test.ts +24 -0
  156. package/tests/MimicAuthService.test.ts +102 -134
  157. package/tests/MimicClusterServerEngine.test.ts +587 -0
  158. package/tests/MimicServer.test.ts +90 -226
  159. package/tests/MimicServerEngine.test.ts +521 -0
  160. package/tests/PresenceManager.test.ts +22 -63
  161. package/tests/Protocol.test.ts +190 -0
  162. package/tests/StorageIntegration.test.ts +259 -0
  163. package/tsconfig.json +1 -1
  164. package/tsdown.config.ts +1 -1
  165. package/dist/DocumentManager.cjs +0 -118
  166. package/dist/DocumentManager.d.cts +0 -45
  167. package/dist/DocumentManager.d.cts.map +0 -1
  168. package/dist/DocumentManager.d.mts +0 -45
  169. package/dist/DocumentManager.d.mts.map +0 -1
  170. package/dist/DocumentManager.mjs +0 -105
  171. package/dist/DocumentManager.mjs.map +0 -1
  172. package/dist/DocumentProtocol.cjs +0 -94
  173. package/dist/DocumentProtocol.d.cts +0 -113
  174. package/dist/DocumentProtocol.d.cts.map +0 -1
  175. package/dist/DocumentProtocol.d.mts +0 -113
  176. package/dist/DocumentProtocol.d.mts.map +0 -1
  177. package/dist/DocumentProtocol.mjs +0 -89
  178. package/dist/DocumentProtocol.mjs.map +0 -1
  179. package/dist/MimicConfig.cjs +0 -60
  180. package/dist/MimicConfig.d.cts +0 -141
  181. package/dist/MimicConfig.d.cts.map +0 -1
  182. package/dist/MimicConfig.d.mts +0 -141
  183. package/dist/MimicConfig.d.mts.map +0 -1
  184. package/dist/MimicConfig.mjs +0 -50
  185. package/dist/MimicConfig.mjs.map +0 -1
  186. package/dist/MimicDataStorage.cjs +0 -83
  187. package/dist/MimicDataStorage.d.cts +0 -113
  188. package/dist/MimicDataStorage.d.cts.map +0 -1
  189. package/dist/MimicDataStorage.d.mts +0 -113
  190. package/dist/MimicDataStorage.d.mts.map +0 -1
  191. package/dist/MimicDataStorage.mjs +0 -74
  192. package/dist/MimicDataStorage.mjs.map +0 -1
  193. package/dist/WebSocketHandler.cjs +0 -365
  194. package/dist/WebSocketHandler.d.cts +0 -34
  195. package/dist/WebSocketHandler.d.cts.map +0 -1
  196. package/dist/WebSocketHandler.d.mts +0 -34
  197. package/dist/WebSocketHandler.d.mts.map +0 -1
  198. package/dist/WebSocketHandler.mjs +0 -355
  199. package/dist/WebSocketHandler.mjs.map +0 -1
  200. package/dist/auth/NoAuth.cjs +0 -43
  201. package/dist/auth/NoAuth.d.cts +0 -22
  202. package/dist/auth/NoAuth.d.cts.map +0 -1
  203. package/dist/auth/NoAuth.d.mts +0 -22
  204. package/dist/auth/NoAuth.d.mts.map +0 -1
  205. package/dist/auth/NoAuth.mjs +0 -36
  206. package/dist/auth/NoAuth.mjs.map +0 -1
  207. package/dist/errors.cjs +0 -74
  208. package/dist/errors.d.cts +0 -89
  209. package/dist/errors.d.cts.map +0 -1
  210. package/dist/errors.d.mts +0 -89
  211. package/dist/errors.d.mts.map +0 -1
  212. package/dist/errors.mjs +0 -67
  213. package/dist/errors.mjs.map +0 -1
  214. package/dist/storage/InMemoryDataStorage.cjs +0 -57
  215. package/dist/storage/InMemoryDataStorage.d.cts +0 -19
  216. package/dist/storage/InMemoryDataStorage.d.cts.map +0 -1
  217. package/dist/storage/InMemoryDataStorage.d.mts +0 -19
  218. package/dist/storage/InMemoryDataStorage.d.mts.map +0 -1
  219. package/dist/storage/InMemoryDataStorage.mjs +0 -48
  220. package/dist/storage/InMemoryDataStorage.mjs.map +0 -1
  221. package/src/DocumentManager.ts +0 -254
  222. package/src/DocumentProtocol.ts +0 -112
  223. package/src/MimicConfig.ts +0 -211
  224. package/src/MimicDataStorage.ts +0 -157
  225. package/src/WebSocketHandler.ts +0 -735
  226. package/src/auth/NoAuth.ts +0 -46
  227. package/src/errors.ts +0 -113
  228. package/src/storage/InMemoryDataStorage.ts +0 -66
  229. package/tests/DocumentManager.test.ts +0 -464
  230. package/tests/DocumentProtocol.test.ts +0 -113
  231. package/tests/InMemoryDataStorage.test.ts +0 -190
  232. package/tests/MimicConfig.test.ts +0 -290
  233. package/tests/MimicDataStorage.test.ts +0 -190
  234. package/tests/NoAuth.test.ts +0 -94
  235. package/tests/WebSocketHandler.test.ts +0 -321
  236. package/tests/errors.test.ts +0 -77
@@ -1,239 +1,500 @@
1
1
  /**
2
- * @since 0.0.1
3
- * Mimic server layer composition.
2
+ * @voidhash/mimic-effect - MimicServer
3
+ *
4
+ * WebSocket route layer for MimicServerEngine.
5
+ * Creates routes compatible with HttpLayerRouter.
4
6
  */
5
- import * as Effect from "effect/Effect";
6
- import * as Layer from "effect/Layer";
7
- import * as Context from "effect/Context";
7
+ import {
8
+ Duration,
9
+ Effect,
10
+ Fiber,
11
+ Layer,
12
+ Metric,
13
+ Stream,
14
+ } from "effect";
15
+ import {
16
+ HttpLayerRouter,
17
+ HttpServerRequest,
18
+ HttpServerResponse,
19
+ } from "@effect/platform";
8
20
  import type * as Socket from "@effect/platform/Socket";
9
- import { SocketServer } from "@effect/platform/SocketServer";
10
- import type { Primitive, Presence } from "@voidhash/mimic";
11
-
12
- import * as DocumentManager from "./DocumentManager.js";
13
- import * as WebSocketHandler from "./WebSocketHandler.js";
14
- import * as MimicConfig from "./MimicConfig.js";
15
- import { MimicDataStorageTag } from "./MimicDataStorage.js";
16
- import { MimicAuthServiceTag } from "./MimicAuthService.js";
17
- import * as PresenceManager from "./PresenceManager.js";
18
- import * as InMemoryDataStorage from "./storage/InMemoryDataStorage.js";
19
- import * as NoAuth from "./auth/NoAuth.js";
20
- import { HttpLayerRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform";
21
- import { PathInput } from "@effect/platform/HttpRouter";
21
+ import { Presence } from "@voidhash/mimic";
22
+ import type { MimicServerRouteConfig, ResolvedRouteConfig } from "./Types";
23
+ import * as Protocol from "./Protocol";
24
+ import { MissingDocumentIdError } from "./Errors";
25
+ import { MimicServerEngineTag, type MimicServerEngine } from "./MimicServerEngine";
26
+ import { MimicAuthServiceTag, type MimicAuthService } from "./MimicAuthService";
27
+ import * as Metrics from "./Metrics";
28
+ import type { AuthContext } from "./Types";
22
29
 
23
30
  // =============================================================================
24
- // Layer Composition Options
31
+ // Default Configuration
25
32
  // =============================================================================
26
33
 
34
+ const DEFAULT_PATH = "/mimic";
35
+ const DEFAULT_HEARTBEAT_INTERVAL = Duration.seconds(30);
36
+ const DEFAULT_HEARTBEAT_TIMEOUT = Duration.seconds(10);
37
+
27
38
  /**
28
- * Options for creating a Mimic server layer.
39
+ * Resolve route configuration with defaults
29
40
  */
30
- export interface MimicLayerOptions<
31
- TSchema extends Primitive.AnyPrimitive,
32
- > {
33
- /**
34
- * Base path for document routes (used for path matching).
35
- * @example "/mimic/todo" - documents accessed at "/mimic/todo/:documentId"
36
- */
37
- readonly basePath?: PathInput;
38
- /**
39
- * The schema defining the document structure.
40
- */
41
- readonly schema: TSchema;
42
- /**
43
- * Maximum number of processed transaction IDs to track for deduplication.
44
- * @default 1000
45
- */
46
- readonly maxTransactionHistory?: number;
47
- /**
48
- * Optional presence schema for ephemeral per-user data.
49
- * When provided, enables presence features on WebSocket connections.
50
- */
51
- readonly presence?: Presence.AnyPresence;
52
- /**
53
- * Initial state for new documents.
54
- * Can be either:
55
- * - A plain object with the initial state values
56
- * - A function that receives context (with documentId) and returns an Effect producing the initial state
57
- *
58
- * When using a function that requires Effect services (has R requirements),
59
- * you must also provide `initialLayer` to supply those dependencies.
60
- *
61
- * Type-safe: required fields (without defaults) must be provided,
62
- * while optional fields and fields with defaults can be omitted.
63
- *
64
- * @default undefined (documents start empty or use schema defaults)
65
- */
66
- readonly initial?: Primitive.InferSetInput<TSchema> | MimicConfig.InitialFn<TSchema>;
67
- }
41
+ const resolveRouteConfig = (
42
+ config?: MimicServerRouteConfig
43
+ ): ResolvedRouteConfig => ({
44
+ path: config?.path ?? DEFAULT_PATH,
45
+ heartbeatInterval: config?.heartbeatInterval
46
+ ? Duration.decode(config.heartbeatInterval)
47
+ : DEFAULT_HEARTBEAT_INTERVAL,
48
+ heartbeatTimeout: config?.heartbeatTimeout
49
+ ? Duration.decode(config.heartbeatTimeout)
50
+ : DEFAULT_HEARTBEAT_TIMEOUT,
51
+ });
68
52
 
53
+ // =============================================================================
54
+ // URL Path Parsing
55
+ // =============================================================================
69
56
 
70
57
  /**
71
- * Create the document manager layer.
58
+ * Extract document ID from URL path.
59
+ * Expected format: /basePath/doc/{documentId}
72
60
  */
73
- export const documentManagerLayer = <TSchema extends Primitive.AnyPrimitive>(
74
- options: MimicConfig.MimicServerConfigOptions<TSchema>
75
- ): Layer.Layer<DocumentManager.DocumentManagerTag> =>
76
- DocumentManager.layer.pipe(
77
- Layer.provide(MimicConfig.layer(options)),
78
- // Provide defaults
79
- Layer.provide(InMemoryDataStorage.layerDefault),
80
- Layer.provide(NoAuth.layerDefault)
81
- );
61
+ const extractDocumentId = (
62
+ path: string
63
+ ): Effect.Effect<string, MissingDocumentIdError> => {
64
+ // Remove leading slash and split
65
+ const parts = path.replace(/^\/+/, "").split("/");
66
+
67
+ // Find the last occurrence of 'doc' in the path
68
+ const docIndex = parts.lastIndexOf("doc");
69
+ const part = parts[docIndex + 1];
70
+ if (docIndex !== -1 && part) {
71
+ return Effect.succeed(decodeURIComponent(part));
72
+ }
73
+ return Effect.fail(new MissingDocumentIdError({ path }));
74
+ };
75
+
76
+ // =============================================================================
77
+ // Connection State
78
+ // =============================================================================
79
+
80
+ interface ConnectionState {
81
+ readonly documentId: string;
82
+ readonly connectionId: string;
83
+ authenticated: boolean;
84
+ authContext?: AuthContext;
85
+ hasPresence: boolean;
86
+ }
87
+
88
+ // =============================================================================
89
+ // WebSocket Connection Handler
90
+ // =============================================================================
82
91
 
83
92
  /**
84
- * Create the HTTP handler effect for WebSocket upgrade.
85
- * This handler:
86
- * 1. Extracts the document ID from the URL path
87
- * 2. Upgrades the HTTP connection to WebSocket
88
- * 3. Delegates to the WebSocketHandler for document sync
93
+ * Handle a WebSocket connection for a document.
89
94
  */
90
- const makeMimicHandler = Effect.gen(function* () {
91
- const config = yield* MimicConfig.MimicServerConfigTag;
92
- const authService = yield* MimicAuthServiceTag;
93
- const documentManager = yield* DocumentManager.DocumentManagerTag;
94
- const presenceManager = yield* PresenceManager.PresenceManagerTag;
95
-
96
- return Effect.gen(function* () {
97
- const request = yield* HttpServerRequest.HttpServerRequest;
98
-
99
- // Extract document ID from the URL path
100
- // Expected format: /basePath/doc/{documentId}
101
- const documentId = yield* WebSocketHandler.extractDocumentId(request.url);
102
-
103
- // Upgrade to WebSocket
104
- const socket = yield* request.upgrade;
105
-
106
- // Handle the WebSocket connection
107
- yield* WebSocketHandler.handleConnection(socket, request.url).pipe(
108
- Effect.provideService(MimicConfig.MimicServerConfigTag, config),
109
- Effect.provideService(MimicAuthServiceTag, authService),
110
- Effect.provideService(DocumentManager.DocumentManagerTag, documentManager),
111
- Effect.provideService(PresenceManager.PresenceManagerTag, presenceManager),
112
- Effect.scoped,
113
- Effect.catchAll((error) =>
114
- Effect.logError("WebSocket connection error", error)
115
- )
95
+ const handleWebSocketConnection = Effect.fn("websocket.connection.handle")(
96
+ function* (
97
+ socket: Socket.Socket,
98
+ documentId: string,
99
+ engine: MimicServerEngine,
100
+ authService: MimicAuthService,
101
+ _routeConfig: ResolvedRouteConfig
102
+ ) {
103
+ const connectionId = crypto.randomUUID();
104
+ const connectionStartTime = Date.now();
105
+
106
+ // Track connection metrics
107
+ yield* Metric.increment(Metrics.connectionsTotal);
108
+ yield* Metric.incrementBy(Metrics.connectionsActive, 1);
109
+
110
+ // Track connection state (mutable for simplicity)
111
+ const state: ConnectionState = {
112
+ documentId,
113
+ connectionId,
114
+ authenticated: false,
115
+ hasPresence: false,
116
+ };
117
+
118
+ // Get the socket writer
119
+ const write = yield* socket.writer;
120
+
121
+ // Helper to send a message to the client
122
+ const sendMessage = (message: Protocol.ServerMessage) =>
123
+ write(Protocol.encodeServerMessage(message));
124
+
125
+ // Send presence snapshot after auth
126
+ const sendPresenceSnapshot = Effect.fn("presence.snapshot.send")(
127
+ function* () {
128
+ if (!engine.config.presence) return;
129
+
130
+ const snapshot = yield* engine.getPresenceSnapshot(documentId);
131
+ yield* sendMessage(
132
+ Protocol.presenceSnapshotMessage(connectionId, snapshot.presences)
133
+ );
134
+ }
116
135
  );
117
136
 
118
- // Return empty response - the WebSocket upgrade handles the connection
119
- return HttpServerResponse.empty();
120
- }).pipe(
121
- Effect.catchAll((error) =>
122
- Effect.gen(function* () {
123
- yield* Effect.logWarning("WebSocket upgrade failed", error);
124
- return HttpServerResponse.text("WebSocket upgrade failed", {
125
- status: 400,
137
+ // Send document snapshot after auth
138
+ const sendDocumentSnapshot = Effect.fn("document.snapshot.send")(
139
+ function* () {
140
+ const snapshot = yield* engine.getSnapshot(documentId);
141
+ yield* sendMessage(
142
+ Protocol.snapshotMessage(snapshot.state, snapshot.version)
143
+ );
144
+ }
145
+ );
146
+
147
+ // Handle authentication
148
+ const handleAuth = Effect.fn("auth.handle")(function* (token: string) {
149
+ const result = yield* Effect.either(
150
+ authService.authenticate(token, documentId)
151
+ );
152
+
153
+ if (result._tag === "Right") {
154
+ state.authenticated = true;
155
+ state.authContext = result.right;
156
+
157
+ yield* sendMessage(
158
+ Protocol.authResultSuccess(
159
+ result.right.userId,
160
+ result.right.permission
161
+ )
162
+ );
163
+
164
+ // Send document snapshot after successful auth
165
+ yield* sendDocumentSnapshot();
166
+
167
+ // Send presence snapshot after successful auth
168
+ yield* sendPresenceSnapshot();
169
+ } else {
170
+ yield* Metric.increment(Metrics.connectionsErrors);
171
+ yield* sendMessage(
172
+ Protocol.authResultFailure(
173
+ result.left.reason ?? "Authentication failed"
174
+ )
175
+ );
176
+ }
177
+ });
178
+
179
+ // Handle presence set
180
+ const handlePresenceSet = Effect.fn("presence.set.handle")(
181
+ function* (data: unknown) {
182
+ if (!state.authenticated) return;
183
+ if (!state.authContext) return;
184
+ if (!engine.config.presence) return;
185
+
186
+ // Check write permission
187
+ if (state.authContext.permission !== "write") {
188
+ yield* Effect.logWarning("Presence set rejected - read-only user", {
189
+ connectionId,
190
+ });
191
+ return;
192
+ }
193
+
194
+ // Validate presence data against schema
195
+ const validated = Presence.validateSafe(engine.config.presence, data);
196
+ if (validated === undefined) {
197
+ yield* Effect.logWarning("Invalid presence data received", {
198
+ connectionId,
199
+ data,
200
+ });
201
+ return;
202
+ }
203
+
204
+ // Store in engine
205
+ yield* engine.setPresence(documentId, connectionId, {
206
+ data: validated,
207
+ userId: state.authContext.userId,
126
208
  });
127
- })
128
- )
129
- );
130
- });
131
209
 
210
+ state.hasPresence = true;
211
+ }
212
+ );
132
213
 
214
+ // Handle presence clear
215
+ const handlePresenceClear = Effect.fn("presence.clear.handle")(
216
+ function* () {
217
+ if (!state.authenticated) return;
218
+ if (!engine.config.presence) return;
133
219
 
134
- /**
135
- * Options for layerHttpLayerRouter including optional custom layers.
136
- */
137
- export interface MimicLayerRouterOptions<TSchema extends Primitive.AnyPrimitive>
138
- extends MimicLayerOptions<TSchema> {
139
- /** Custom auth layer. Defaults to NoAuth (all connections allowed). */
140
- readonly authLayer?: Layer.Layer<MimicAuthServiceTag>;
141
- /** Custom storage layer. Defaults to InMemoryDataStorage. */
142
- readonly storageLayer?: Layer.Layer<MimicDataStorageTag>;
143
- }
220
+ yield* engine.removePresence(documentId, connectionId);
221
+ state.hasPresence = false;
222
+ }
223
+ );
224
+
225
+ // Handle a client message
226
+ const handleMessage = Effect.fn("message.handle")(
227
+ function* (message: Protocol.ClientMessage) {
228
+ // Touch document on any activity (prevents idle GC)
229
+ yield* engine.touch(documentId);
230
+
231
+ switch (message.type) {
232
+ case "auth":
233
+ yield* handleAuth(message.token);
234
+ break;
235
+
236
+ case "ping":
237
+ yield* sendMessage(Protocol.pong());
238
+ break;
239
+
240
+ case "submit":
241
+ if (!state.authenticated) {
242
+ yield* sendMessage(
243
+ Protocol.errorMessage(
244
+ message.transaction.id,
245
+ "Not authenticated"
246
+ )
247
+ );
248
+ return;
249
+ }
250
+
251
+ // Check write permission
252
+ if (state.authContext?.permission !== "write") {
253
+ yield* sendMessage(
254
+ Protocol.errorMessage(
255
+ message.transaction.id,
256
+ "Write permission required"
257
+ )
258
+ );
259
+ return;
260
+ }
261
+
262
+ // Submit to the engine
263
+ const submitResult = yield* engine.submit(
264
+ documentId,
265
+ message.transaction
266
+ );
267
+
268
+ // If rejected, send error (success is broadcast to all)
269
+ if (!submitResult.success) {
270
+ yield* sendMessage(
271
+ Protocol.errorMessage(message.transaction.id, submitResult.reason)
272
+ );
273
+ }
274
+ break;
275
+
276
+ case "request_snapshot":
277
+ if (!state.authenticated) {
278
+ return;
279
+ }
280
+ const snapshot = yield* engine.getSnapshot(documentId);
281
+ yield* sendMessage(
282
+ Protocol.snapshotMessage(snapshot.state, snapshot.version)
283
+ );
284
+ break;
285
+
286
+ case "presence_set":
287
+ yield* handlePresenceSet(message.data);
288
+ break;
289
+
290
+ case "presence_clear":
291
+ yield* handlePresenceClear();
292
+ break;
293
+ }
294
+ }
295
+ );
296
+
297
+ // Subscribe to document broadcasts
298
+ const subscribeFiber = yield* Effect.fork(
299
+ Effect.fn("subscriptions.document.start")(function* () {
300
+ // Wait until authenticated before subscribing
301
+ while (!state.authenticated) {
302
+ yield* Effect.sleep(Duration.millis(100));
303
+ }
304
+
305
+ // Subscribe to the document
306
+ const broadcastStream = yield* engine.subscribe(documentId);
307
+
308
+ // Forward broadcasts to the WebSocket
309
+ yield* Stream.runForEach(broadcastStream, (broadcast) =>
310
+ sendMessage(broadcast as Protocol.ServerMessage)
311
+ );
312
+ })().pipe(Effect.scoped)
313
+ );
314
+
315
+ // Subscribe to presence events (if presence is enabled)
316
+ const presenceFiber = yield* Effect.fork(
317
+ Effect.fn("subscriptions.presence.start")(function* () {
318
+ if (!engine.config.presence) return;
319
+
320
+ // Wait until authenticated before subscribing
321
+ while (!state.authenticated) {
322
+ yield* Effect.sleep(Duration.millis(100));
323
+ }
324
+
325
+ // Subscribe to presence events
326
+ const presenceStream = yield* engine.subscribePresence(documentId);
327
+
328
+ // Forward presence events to the WebSocket, filtering out our own events (no-echo)
329
+ yield* Stream.runForEach(presenceStream, (event) =>
330
+ Effect.gen(function* () {
331
+ // Don't echo our own presence events
332
+ if (event.id === connectionId) return;
333
+
334
+ if (event.type === "presence_update") {
335
+ yield* sendMessage(
336
+ Protocol.presenceUpdateMessage(event.id, event.data, event.userId)
337
+ );
338
+ } else if (event.type === "presence_remove") {
339
+ yield* sendMessage(Protocol.presenceRemoveMessage(event.id));
340
+ }
341
+ })
342
+ );
343
+ })().pipe(Effect.scoped)
344
+ );
345
+
346
+ // Ensure cleanup on disconnect
347
+ yield* Effect.addFinalizer(() =>
348
+ Effect.fn("connection.cleanup")(function* () {
349
+ // Calculate connection duration
350
+ const duration = Date.now() - connectionStartTime;
351
+
352
+ // Interrupt the subscribe fibers
353
+ yield* Fiber.interrupt(subscribeFiber);
354
+ yield* Fiber.interrupt(presenceFiber);
355
+
356
+ // Remove presence if we had any
357
+ if (state.hasPresence && engine.config.presence) {
358
+ yield* engine.removePresence(documentId, connectionId);
359
+ }
360
+
361
+ // Update connection metrics
362
+ yield* Metric.incrementBy(Metrics.connectionsActive, -1);
363
+ yield* Metric.update(Metrics.connectionsDuration, duration);
364
+
365
+ yield* Effect.logDebug("WebSocket connection closed", {
366
+ connectionId,
367
+ documentId,
368
+ durationMs: duration,
369
+ });
370
+ })()
371
+ );
372
+
373
+ // Process incoming messages
374
+ yield* socket.runRaw((data) =>
375
+ Effect.fn("message.process")(function* () {
376
+ const message = yield* Protocol.parseClientMessage(data);
377
+ yield* handleMessage(message);
378
+ })().pipe(
379
+ Effect.catchAll((error) =>
380
+ Effect.logError("Message handling error", error)
381
+ )
382
+ )
383
+ );
384
+ }
385
+ );
386
+
387
+ // =============================================================================
388
+ // Factory
389
+ // =============================================================================
144
390
 
145
391
  /**
146
- * Create a Mimic server layer that integrates with HttpLayerRouter.
147
- *
148
- * This function creates a layer that:
149
- * 1. Registers a WebSocket route at the specified base path
150
- * 2. Handles WebSocket upgrades for document sync
151
- * 3. Provides all required dependencies (config, auth, storage, document manager)
392
+ * Create a route layer for MimicServerEngine.
152
393
  *
153
- * By default, uses in-memory storage and no authentication.
154
- * To override these defaults, provide custom layers before the defaults:
394
+ * This creates a WebSocket route that connects to the engine.
395
+ * Use Layer.mergeAll to compose with other routes.
155
396
  *
156
397
  * @example
157
398
  * ```typescript
158
- * import { MimicServer, MimicAuthService } from "@voidhash/mimic-effect";
159
- * import { HttpLayerRouter } from "@effect/platform";
160
- * import { Primitive } from "@voidhash/mimic";
399
+ * // 1. Create the engine
400
+ * const Engine = MimicServerEngine.make({
401
+ * schema: DocSchema,
402
+ * initial: { title: "Untitled" },
403
+ * })
161
404
  *
162
- * const TodoSchema = Primitive.Struct({
163
- * title: Primitive.String(),
164
- * completed: Primitive.Boolean(),
165
- * });
166
- *
167
- * // Create the Mimic route layer with defaults
405
+ * // 2. Create the WebSocket route
168
406
  * const MimicRoute = MimicServer.layerHttpLayerRouter({
169
- * basePath: "/mimic/todo",
170
- * schema: TodoSchema
171
- * });
407
+ * path: "/mimic",
408
+ * })
409
+ *
410
+ * // 3. Wire together
411
+ * const MimicLive = MimicRoute.pipe(
412
+ * Layer.provide(Engine),
413
+ * Layer.provide(ColdStorage.InMemory.make()),
414
+ * Layer.provide(HotStorage.InMemory.make()),
415
+ * Layer.provide(MimicAuthService.NoAuth.make()),
416
+ * )
172
417
  *
173
- * // Or with custom auth - use Layer.provide to inject before defaults
174
- * const MimicRouteWithAuth = MimicServer.layerHttpLayerRouter({
175
- * basePath: "/mimic/todo",
176
- * schema: TodoSchema,
177
- * authLayer: MimicAuthService.layer({
178
- * authHandler: (token) => ({ success: true, userId: token })
179
- * })
180
- * });
418
+ * // 4. Compose with other routes
419
+ * const AllRoutes = Layer.mergeAll(MimicLive, DocsRoute, OtherRoutes)
181
420
  *
182
- * // Merge with other routes and serve
183
- * const AllRoutes = Layer.mergeAll(MimicRoute, OtherRoutes);
421
+ * // 5. Serve
184
422
  * HttpLayerRouter.serve(AllRoutes).pipe(
185
- * Layer.provide(BunHttpServer.layer({ port: 3000 })),
423
+ * Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
186
424
  * Layer.launch,
187
- * BunRuntime.runMain
188
- * );
425
+ * NodeRuntime.runMain
426
+ * )
189
427
  * ```
190
428
  */
191
- export const layerHttpLayerRouter = <
192
- TSchema extends Primitive.AnyPrimitive,
193
- TError,
194
- TRequirements
195
- >(
196
- optionsEf: Effect.Effect<MimicLayerRouterOptions<TSchema>, TError, TRequirements>
197
- ): Layer.Layer<never, TError, TRequirements | HttpLayerRouter.HttpRouter> => {
198
- return Layer.unwrapScoped(
429
+ export const layerHttpLayerRouter = (
430
+ options?: MimicServerRouteConfig
431
+ ) => {
432
+ const routeConfig = resolveRouteConfig(options);
433
+
434
+ // Build the route path pattern: {path}/doc/:documentId
435
+ const routePath =
436
+ `${routeConfig.path}/doc/:documentId` as HttpLayerRouter.PathInput;
437
+
438
+ return Layer.scopedDiscard(
199
439
  Effect.gen(function* () {
200
- const options = yield* optionsEf;
201
-
202
- // Build the base path pattern for WebSocket routes
203
- // Append /doc/* to match /basePath/doc/{documentId}
204
- const basePath = options.basePath ?? "/mimic";
205
- const wsPath: PathInput = `${basePath}/doc/*` as PathInput;
206
-
207
- // Create the config layer with properly typed initial function
208
- const configLayer = MimicConfig.layer<TSchema>({
209
- schema: options.schema,
210
- maxTransactionHistory: options.maxTransactionHistory,
211
- presence: options.presence,
212
- initial: options.initial,
213
- });
214
-
215
- // Use provided layers or defaults
216
- const authLayer = options.authLayer ?? NoAuth.layerDefault;
217
- const storageLayer = options.storageLayer ?? InMemoryDataStorage.layerDefault;
218
-
219
- // Combine all dependency layers
220
- const depsLayer = Layer.mergeAll(configLayer, authLayer, storageLayer);
221
-
222
- // Create the route registration layer
223
- const routeLayer = Layer.scopedDiscard(
224
- Effect.gen(function* () {
225
- const router = yield* HttpLayerRouter.HttpRouter;
226
- const handler = yield* makeMimicHandler;
227
- yield* router.add("GET", wsPath, handler);
228
- })
229
- );
440
+ const router = yield* HttpLayerRouter.HttpRouter;
441
+ // Capture engine and auth service at layer creation time
442
+ const engine = yield* MimicServerEngineTag;
443
+ const authService = yield* MimicAuthServiceTag;
444
+
445
+ // Create the handler that receives the request
446
+ // Engine and authService are captured in closure, not yielded per-request
447
+ const handler = Effect.fn("websocket.route.handler")(
448
+ function* (request: HttpServerRequest.HttpServerRequest) {
449
+ // Extract document ID from path
450
+ const documentIdResult = yield* Effect.either(
451
+ extractDocumentId(request.url)
452
+ );
453
+ if (documentIdResult._tag === "Left") {
454
+ return HttpServerResponse.text(
455
+ `Missing document ID in path: ${request.url}`,
456
+ { status: 400 }
457
+ );
458
+ }
459
+ const documentId = documentIdResult.right;
230
460
 
231
- // Build the complete layer with all dependencies provided
232
- return routeLayer.pipe(
233
- Layer.provide(DocumentManager.layer),
234
- Layer.provide(PresenceManager.layer),
235
- Layer.provide(depsLayer),
461
+ // Upgrade to WebSocket
462
+ const socket = yield* request.upgrade;
463
+
464
+ // Handle the WebSocket connection
465
+ yield* handleWebSocketConnection(
466
+ socket,
467
+ documentId,
468
+ engine,
469
+ authService,
470
+ routeConfig
471
+ ).pipe(
472
+ Effect.scoped,
473
+ Effect.catchAll((error) =>
474
+ Effect.logError("WebSocket connection error", error)
475
+ )
476
+ );
477
+
478
+ // Return empty response - the WebSocket upgrade handles the connection
479
+ return HttpServerResponse.empty();
480
+ }
236
481
  );
482
+
483
+ yield* router.add("GET", routePath, handler);
237
484
  })
238
485
  );
239
- };
486
+ };
487
+
488
+ // =============================================================================
489
+ // Re-export namespace
490
+ // =============================================================================
491
+
492
+ export const MimicServer = {
493
+ layerHttpLayerRouter,
494
+ };
495
+
496
+ // =============================================================================
497
+ // Re-export types
498
+ // =============================================================================
499
+
500
+ export type { MimicServerRouteConfig };