@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.
- package/.turbo/turbo-build.log +136 -90
- package/README.md +385 -0
- package/dist/ColdStorage.cjs +64 -0
- package/dist/ColdStorage.d.cts +53 -0
- package/dist/ColdStorage.d.cts.map +1 -0
- package/dist/ColdStorage.d.mts +53 -0
- package/dist/ColdStorage.d.mts.map +1 -0
- package/dist/ColdStorage.mjs +64 -0
- package/dist/ColdStorage.mjs.map +1 -0
- package/dist/DocumentInstance.cjs +263 -0
- package/dist/DocumentInstance.d.cts +78 -0
- package/dist/DocumentInstance.d.cts.map +1 -0
- package/dist/DocumentInstance.d.mts +78 -0
- package/dist/DocumentInstance.d.mts.map +1 -0
- package/dist/DocumentInstance.mjs +264 -0
- package/dist/DocumentInstance.mjs.map +1 -0
- package/dist/Errors.cjs +54 -0
- package/dist/Errors.d.cts +96 -0
- package/dist/Errors.d.cts.map +1 -0
- package/dist/Errors.d.mts +96 -0
- package/dist/Errors.d.mts.map +1 -0
- package/dist/Errors.mjs +48 -0
- package/dist/Errors.mjs.map +1 -0
- package/dist/HotStorage.cjs +104 -0
- package/dist/HotStorage.d.cts +70 -0
- package/dist/HotStorage.d.cts.map +1 -0
- package/dist/HotStorage.d.mts +70 -0
- package/dist/HotStorage.d.mts.map +1 -0
- package/dist/HotStorage.mjs +104 -0
- package/dist/HotStorage.mjs.map +1 -0
- package/dist/Metrics.cjs +149 -0
- package/dist/Metrics.d.cts +32 -0
- package/dist/Metrics.d.cts.map +1 -0
- package/dist/Metrics.d.mts +32 -0
- package/dist/Metrics.d.mts.map +1 -0
- package/dist/Metrics.mjs +131 -0
- package/dist/Metrics.mjs.map +1 -0
- package/dist/MimicAuthService.cjs +61 -45
- package/dist/MimicAuthService.d.cts +61 -48
- package/dist/MimicAuthService.d.cts.map +1 -1
- package/dist/MimicAuthService.d.mts +61 -48
- package/dist/MimicAuthService.d.mts.map +1 -1
- package/dist/MimicAuthService.mjs +60 -36
- package/dist/MimicAuthService.mjs.map +1 -1
- package/dist/MimicClusterServerEngine.cjs +348 -0
- package/dist/MimicClusterServerEngine.d.cts +17 -0
- package/dist/MimicClusterServerEngine.d.cts.map +1 -0
- package/dist/MimicClusterServerEngine.d.mts +17 -0
- package/dist/MimicClusterServerEngine.d.mts.map +1 -0
- package/dist/MimicClusterServerEngine.mjs +350 -0
- package/dist/MimicClusterServerEngine.mjs.map +1 -0
- package/dist/MimicServer.cjs +205 -96
- package/dist/MimicServer.d.cts +9 -110
- package/dist/MimicServer.d.cts.map +1 -1
- package/dist/MimicServer.d.mts +9 -110
- package/dist/MimicServer.d.mts.map +1 -1
- package/dist/MimicServer.mjs +206 -90
- package/dist/MimicServer.mjs.map +1 -1
- package/dist/MimicServerEngine.cjs +178 -0
- package/dist/MimicServerEngine.d.cts +83 -0
- package/dist/MimicServerEngine.d.cts.map +1 -0
- package/dist/MimicServerEngine.d.mts +83 -0
- package/dist/MimicServerEngine.d.mts.map +1 -0
- package/dist/MimicServerEngine.mjs +178 -0
- package/dist/MimicServerEngine.mjs.map +1 -0
- package/dist/PresenceManager.cjs +75 -91
- package/dist/PresenceManager.d.cts +17 -66
- package/dist/PresenceManager.d.cts.map +1 -1
- package/dist/PresenceManager.d.mts +17 -66
- package/dist/PresenceManager.d.mts.map +1 -1
- package/dist/PresenceManager.mjs +74 -78
- package/dist/PresenceManager.mjs.map +1 -1
- package/dist/Protocol.cjs +146 -0
- package/dist/Protocol.d.cts +203 -0
- package/dist/Protocol.d.cts.map +1 -0
- package/dist/Protocol.d.mts +203 -0
- package/dist/Protocol.d.mts.map +1 -0
- package/dist/Protocol.mjs +132 -0
- package/dist/Protocol.mjs.map +1 -0
- package/dist/Types.d.cts +179 -0
- package/dist/Types.d.cts.map +1 -0
- package/dist/Types.d.mts +179 -0
- package/dist/Types.d.mts.map +1 -0
- package/dist/_virtual/rolldown_runtime.cjs +1 -25
- package/dist/_virtual/rolldown_runtime.mjs +4 -1
- package/dist/index.cjs +37 -76
- package/dist/index.d.cts +13 -12
- package/dist/index.d.mts +13 -12
- package/dist/index.mjs +12 -12
- package/dist/testing/ColdStorageTestSuite.cjs +508 -0
- package/dist/testing/ColdStorageTestSuite.d.cts +36 -0
- package/dist/testing/ColdStorageTestSuite.d.cts.map +1 -0
- package/dist/testing/ColdStorageTestSuite.d.mts +36 -0
- package/dist/testing/ColdStorageTestSuite.d.mts.map +1 -0
- package/dist/testing/ColdStorageTestSuite.mjs +508 -0
- package/dist/testing/ColdStorageTestSuite.mjs.map +1 -0
- package/dist/testing/FailingStorage.cjs +162 -0
- package/dist/testing/FailingStorage.d.cts +43 -0
- package/dist/testing/FailingStorage.d.cts.map +1 -0
- package/dist/testing/FailingStorage.d.mts +43 -0
- package/dist/testing/FailingStorage.d.mts.map +1 -0
- package/dist/testing/FailingStorage.mjs +163 -0
- package/dist/testing/FailingStorage.mjs.map +1 -0
- package/dist/testing/HotStorageTestSuite.cjs +820 -0
- package/dist/testing/HotStorageTestSuite.d.cts +42 -0
- package/dist/testing/HotStorageTestSuite.d.cts.map +1 -0
- package/dist/testing/HotStorageTestSuite.d.mts +42 -0
- package/dist/testing/HotStorageTestSuite.d.mts.map +1 -0
- package/dist/testing/HotStorageTestSuite.mjs +820 -0
- package/dist/testing/HotStorageTestSuite.mjs.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.cjs +487 -0
- package/dist/testing/StorageIntegrationTestSuite.d.cts +37 -0
- package/dist/testing/StorageIntegrationTestSuite.d.cts.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.d.mts +37 -0
- package/dist/testing/StorageIntegrationTestSuite.d.mts.map +1 -0
- package/dist/testing/StorageIntegrationTestSuite.mjs +487 -0
- package/dist/testing/StorageIntegrationTestSuite.mjs.map +1 -0
- package/dist/testing/assertions.cjs +117 -0
- package/dist/testing/assertions.mjs +112 -0
- package/dist/testing/assertions.mjs.map +1 -0
- package/dist/testing/index.cjs +14 -0
- package/dist/testing/index.d.cts +6 -0
- package/dist/testing/index.d.mts +6 -0
- package/dist/testing/index.mjs +7 -0
- package/dist/testing/types.cjs +15 -0
- package/dist/testing/types.d.cts +90 -0
- package/dist/testing/types.d.cts.map +1 -0
- package/dist/testing/types.d.mts +90 -0
- package/dist/testing/types.d.mts.map +1 -0
- package/dist/testing/types.mjs +16 -0
- package/dist/testing/types.mjs.map +1 -0
- package/package.json +18 -3
- package/src/ColdStorage.ts +145 -0
- package/src/DocumentInstance.ts +527 -0
- package/src/Errors.ts +114 -0
- package/src/HotStorage.ts +256 -0
- package/src/Metrics.ts +193 -0
- package/src/MimicAuthService.ts +126 -64
- package/src/MimicClusterServerEngine.ts +669 -0
- package/src/MimicServer.ts +459 -198
- package/src/MimicServerEngine.ts +472 -0
- package/src/PresenceManager.ts +173 -234
- package/src/Protocol.ts +350 -0
- package/src/Types.ts +238 -0
- package/src/index.ts +27 -23
- package/src/testing/ColdStorageTestSuite.ts +589 -0
- package/src/testing/FailingStorage.ts +338 -0
- package/src/testing/HotStorageTestSuite.ts +1105 -0
- package/src/testing/StorageIntegrationTestSuite.ts +736 -0
- package/src/testing/assertions.ts +188 -0
- package/src/testing/index.ts +83 -0
- package/src/testing/types.ts +100 -0
- package/tests/ColdStorage.test.ts +24 -0
- package/tests/DocumentInstance.test.ts +669 -0
- package/tests/HotStorage.test.ts +24 -0
- package/tests/MimicAuthService.test.ts +102 -134
- package/tests/MimicClusterServerEngine.test.ts +587 -0
- package/tests/MimicServer.test.ts +90 -226
- package/tests/MimicServerEngine.test.ts +521 -0
- package/tests/PresenceManager.test.ts +22 -63
- package/tests/Protocol.test.ts +190 -0
- package/tests/StorageIntegration.test.ts +259 -0
- package/tsconfig.json +1 -1
- package/tsdown.config.ts +1 -1
- package/dist/DocumentManager.cjs +0 -118
- package/dist/DocumentManager.d.cts +0 -45
- package/dist/DocumentManager.d.cts.map +0 -1
- package/dist/DocumentManager.d.mts +0 -45
- package/dist/DocumentManager.d.mts.map +0 -1
- package/dist/DocumentManager.mjs +0 -105
- package/dist/DocumentManager.mjs.map +0 -1
- package/dist/DocumentProtocol.cjs +0 -94
- package/dist/DocumentProtocol.d.cts +0 -113
- package/dist/DocumentProtocol.d.cts.map +0 -1
- package/dist/DocumentProtocol.d.mts +0 -113
- package/dist/DocumentProtocol.d.mts.map +0 -1
- package/dist/DocumentProtocol.mjs +0 -89
- package/dist/DocumentProtocol.mjs.map +0 -1
- package/dist/MimicConfig.cjs +0 -60
- package/dist/MimicConfig.d.cts +0 -141
- package/dist/MimicConfig.d.cts.map +0 -1
- package/dist/MimicConfig.d.mts +0 -141
- package/dist/MimicConfig.d.mts.map +0 -1
- package/dist/MimicConfig.mjs +0 -50
- package/dist/MimicConfig.mjs.map +0 -1
- package/dist/MimicDataStorage.cjs +0 -83
- package/dist/MimicDataStorage.d.cts +0 -113
- package/dist/MimicDataStorage.d.cts.map +0 -1
- package/dist/MimicDataStorage.d.mts +0 -113
- package/dist/MimicDataStorage.d.mts.map +0 -1
- package/dist/MimicDataStorage.mjs +0 -74
- package/dist/MimicDataStorage.mjs.map +0 -1
- package/dist/WebSocketHandler.cjs +0 -365
- package/dist/WebSocketHandler.d.cts +0 -34
- package/dist/WebSocketHandler.d.cts.map +0 -1
- package/dist/WebSocketHandler.d.mts +0 -34
- package/dist/WebSocketHandler.d.mts.map +0 -1
- package/dist/WebSocketHandler.mjs +0 -355
- package/dist/WebSocketHandler.mjs.map +0 -1
- package/dist/auth/NoAuth.cjs +0 -43
- package/dist/auth/NoAuth.d.cts +0 -22
- package/dist/auth/NoAuth.d.cts.map +0 -1
- package/dist/auth/NoAuth.d.mts +0 -22
- package/dist/auth/NoAuth.d.mts.map +0 -1
- package/dist/auth/NoAuth.mjs +0 -36
- package/dist/auth/NoAuth.mjs.map +0 -1
- package/dist/errors.cjs +0 -74
- package/dist/errors.d.cts +0 -89
- package/dist/errors.d.cts.map +0 -1
- package/dist/errors.d.mts +0 -89
- package/dist/errors.d.mts.map +0 -1
- package/dist/errors.mjs +0 -67
- package/dist/errors.mjs.map +0 -1
- package/dist/storage/InMemoryDataStorage.cjs +0 -57
- package/dist/storage/InMemoryDataStorage.d.cts +0 -19
- package/dist/storage/InMemoryDataStorage.d.cts.map +0 -1
- package/dist/storage/InMemoryDataStorage.d.mts +0 -19
- package/dist/storage/InMemoryDataStorage.d.mts.map +0 -1
- package/dist/storage/InMemoryDataStorage.mjs +0 -48
- package/dist/storage/InMemoryDataStorage.mjs.map +0 -1
- package/src/DocumentManager.ts +0 -254
- package/src/DocumentProtocol.ts +0 -112
- package/src/MimicConfig.ts +0 -211
- package/src/MimicDataStorage.ts +0 -157
- package/src/WebSocketHandler.ts +0 -735
- package/src/auth/NoAuth.ts +0 -46
- package/src/errors.ts +0 -113
- package/src/storage/InMemoryDataStorage.ts +0 -66
- package/tests/DocumentManager.test.ts +0 -464
- package/tests/DocumentProtocol.test.ts +0 -113
- package/tests/InMemoryDataStorage.test.ts +0 -190
- package/tests/MimicConfig.test.ts +0 -290
- package/tests/MimicDataStorage.test.ts +0 -190
- package/tests/NoAuth.test.ts +0 -94
- package/tests/WebSocketHandler.test.ts +0 -321
- package/tests/errors.test.ts +0 -77
package/src/MimicServer.ts
CHANGED
|
@@ -1,239 +1,500 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
3
|
-
*
|
|
2
|
+
* @voidhash/mimic-effect - MimicServer
|
|
3
|
+
*
|
|
4
|
+
* WebSocket route layer for MimicServerEngine.
|
|
5
|
+
* Creates routes compatible with HttpLayerRouter.
|
|
4
6
|
*/
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
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 {
|
|
10
|
-
import type {
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
*
|
|
39
|
+
* Resolve route configuration with defaults
|
|
29
40
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
58
|
+
* Extract document ID from URL path.
|
|
59
|
+
* Expected format: /basePath/doc/{documentId}
|
|
72
60
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
):
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
*
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
-
*
|
|
154
|
-
*
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
399
|
+
* // 1. Create the engine
|
|
400
|
+
* const Engine = MimicServerEngine.make({
|
|
401
|
+
* schema: DocSchema,
|
|
402
|
+
* initial: { title: "Untitled" },
|
|
403
|
+
* })
|
|
161
404
|
*
|
|
162
|
-
*
|
|
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
|
-
*
|
|
170
|
-
*
|
|
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
|
-
* //
|
|
174
|
-
* const
|
|
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
|
-
* //
|
|
183
|
-
* const AllRoutes = Layer.mergeAll(MimicRoute, OtherRoutes);
|
|
421
|
+
* // 5. Serve
|
|
184
422
|
* HttpLayerRouter.serve(AllRoutes).pipe(
|
|
185
|
-
* Layer.provide(
|
|
423
|
+
* Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
|
|
186
424
|
* Layer.launch,
|
|
187
|
-
*
|
|
188
|
-
* )
|
|
425
|
+
* NodeRuntime.runMain
|
|
426
|
+
* )
|
|
189
427
|
* ```
|
|
190
428
|
*/
|
|
191
|
-
export const layerHttpLayerRouter =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 };
|