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

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