@mdxui/terminal 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +571 -0
- package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
- package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
- package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
- package/dist/chunk-3EFDH7PK.js +5235 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-3X5IR6WE.js +884 -0
- package/dist/chunk-4FV5ZDCE.js +5236 -0
- package/dist/chunk-4OVMSF2J.js +243 -0
- package/dist/chunk-63FEETIS.js +4048 -0
- package/dist/chunk-B43KP7XJ.js +884 -0
- package/dist/chunk-BMTJXWUV.js +655 -0
- package/dist/chunk-C3SVH4N7.js +882 -0
- package/dist/chunk-EVWR7Y47.js +874 -0
- package/dist/chunk-F6A5VWUC.js +1285 -0
- package/dist/chunk-FD7KW7GE.js +882 -0
- package/dist/chunk-GBQ6UD6I.js +655 -0
- package/dist/chunk-GMDD3M6U.js +5227 -0
- package/dist/chunk-JBHRXOXM.js +1058 -0
- package/dist/chunk-JFOO3EYO.js +1182 -0
- package/dist/chunk-JQ5H3WXL.js +1291 -0
- package/dist/chunk-JQD5NASE.js +234 -0
- package/dist/chunk-KRHJP5R7.js +592 -0
- package/dist/chunk-KWF6WVJE.js +962 -0
- package/dist/chunk-LHYQVN3H.js +1038 -0
- package/dist/chunk-M3TLQLGC.js +1032 -0
- package/dist/chunk-MVW4Q5OP.js +240 -0
- package/dist/chunk-NXCZSWLU.js +1294 -0
- package/dist/chunk-O25TNRO6.js +607 -0
- package/dist/chunk-PNECDA2I.js +884 -0
- package/dist/chunk-QIHWRLJR.js +962 -0
- package/dist/chunk-QW5YMQ7K.js +882 -0
- package/dist/chunk-R5U7XKVJ.js +16 -0
- package/dist/chunk-RP2MVQLR.js +962 -0
- package/dist/chunk-TP6RXGXA.js +1087 -0
- package/dist/chunk-TQQSTITZ.js +655 -0
- package/dist/chunk-X24GWXQV.js +1281 -0
- package/dist/components/index.d.ts +802 -0
- package/dist/components/index.js +149 -0
- package/dist/data/index.d.ts +2554 -0
- package/dist/data/index.js +51 -0
- package/dist/forms/index.d.ts +1596 -0
- package/dist/forms/index.js +464 -0
- package/dist/index-CQRFZntR.d.ts +867 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +786 -0
- package/dist/interactive-D0JkWosD.d.ts +217 -0
- package/dist/keyboard/index.d.ts +2 -0
- package/dist/keyboard/index.js +43 -0
- package/dist/renderers/index.d.ts +546 -0
- package/dist/renderers/index.js +2157 -0
- package/dist/storybook/index.d.ts +396 -0
- package/dist/storybook/index.js +641 -0
- package/dist/theme/index.d.ts +1339 -0
- package/dist/theme/index.js +123 -0
- package/dist/types-Bxu5PAgA.d.ts +710 -0
- package/dist/types-CIlop5Ji.d.ts +701 -0
- package/dist/types-Ca8p_p5X.d.ts +710 -0
- package/package.json +90 -0
- package/src/__tests__/components/data/card.test.ts +458 -0
- package/src/__tests__/components/data/list.test.ts +473 -0
- package/src/__tests__/components/data/metrics.test.ts +541 -0
- package/src/__tests__/components/data/table.test.ts +448 -0
- package/src/__tests__/components/input/field.test.ts +555 -0
- package/src/__tests__/components/input/form.test.ts +870 -0
- package/src/__tests__/components/input/search.test.ts +1238 -0
- package/src/__tests__/components/input/select.test.ts +658 -0
- package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
- package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
- package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
- package/src/__tests__/components/navigation/tabs.test.ts +995 -0
- package/src/__tests__/components.test.tsx +1197 -0
- package/src/__tests__/core/compiler.test.ts +986 -0
- package/src/__tests__/core/parser.test.ts +785 -0
- package/src/__tests__/core/tier-switcher.test.ts +1103 -0
- package/src/__tests__/core/types.test.ts +1398 -0
- package/src/__tests__/data/collections.test.ts +1337 -0
- package/src/__tests__/data/db.test.ts +1265 -0
- package/src/__tests__/data/reactive.test.ts +1010 -0
- package/src/__tests__/data/sync.test.ts +1614 -0
- package/src/__tests__/errors.test.ts +660 -0
- package/src/__tests__/forms/integration.test.ts +444 -0
- package/src/__tests__/integration.test.ts +905 -0
- package/src/__tests__/keyboard.test.ts +1791 -0
- package/src/__tests__/renderer.test.ts +489 -0
- package/src/__tests__/renderers/ansi-css.test.ts +948 -0
- package/src/__tests__/renderers/ansi.test.ts +1366 -0
- package/src/__tests__/renderers/ascii.test.ts +1360 -0
- package/src/__tests__/renderers/interactive.test.ts +2353 -0
- package/src/__tests__/renderers/markdown.test.ts +1483 -0
- package/src/__tests__/renderers/text.test.ts +1369 -0
- package/src/__tests__/renderers/unicode.test.ts +1307 -0
- package/src/__tests__/theme.test.ts +639 -0
- package/src/__tests__/utils/assertions.ts +685 -0
- package/src/__tests__/utils/index.ts +115 -0
- package/src/__tests__/utils/test-renderer.ts +381 -0
- package/src/__tests__/utils/utils.test.ts +560 -0
- package/src/components/containers/card.ts +56 -0
- package/src/components/containers/dialog.ts +53 -0
- package/src/components/containers/index.ts +9 -0
- package/src/components/containers/panel.ts +59 -0
- package/src/components/feedback/badge.ts +40 -0
- package/src/components/feedback/index.ts +8 -0
- package/src/components/feedback/spinner.ts +23 -0
- package/src/components/helpers.ts +81 -0
- package/src/components/index.ts +153 -0
- package/src/components/layout/breadcrumb.ts +31 -0
- package/src/components/layout/index.ts +10 -0
- package/src/components/layout/list.ts +29 -0
- package/src/components/layout/sidebar.ts +79 -0
- package/src/components/layout/table.ts +62 -0
- package/src/components/primitives/box.ts +95 -0
- package/src/components/primitives/button.ts +54 -0
- package/src/components/primitives/index.ts +11 -0
- package/src/components/primitives/input.ts +88 -0
- package/src/components/primitives/select.ts +97 -0
- package/src/components/primitives/text.ts +60 -0
- package/src/components/render.ts +155 -0
- package/src/components/templates/app.ts +43 -0
- package/src/components/templates/index.ts +8 -0
- package/src/components/templates/site.ts +54 -0
- package/src/components/types.ts +777 -0
- package/src/core/compiler.ts +718 -0
- package/src/core/parser.ts +127 -0
- package/src/core/tier-switcher.ts +607 -0
- package/src/core/types.ts +672 -0
- package/src/data/collection.ts +316 -0
- package/src/data/collections.ts +50 -0
- package/src/data/context.tsx +174 -0
- package/src/data/db.ts +127 -0
- package/src/data/hooks.ts +532 -0
- package/src/data/index.ts +138 -0
- package/src/data/reactive.ts +1225 -0
- package/src/data/saas-collections.ts +375 -0
- package/src/data/sync.ts +1213 -0
- package/src/data/types.ts +660 -0
- package/src/forms/converters.ts +512 -0
- package/src/forms/index.ts +133 -0
- package/src/forms/schemas.ts +403 -0
- package/src/forms/types.ts +476 -0
- package/src/index.ts +542 -0
- package/src/keyboard/focus.ts +748 -0
- package/src/keyboard/index.ts +96 -0
- package/src/keyboard/integration.ts +371 -0
- package/src/keyboard/manager.ts +377 -0
- package/src/keyboard/presets.ts +90 -0
- package/src/renderers/ansi-css.ts +576 -0
- package/src/renderers/ansi.ts +802 -0
- package/src/renderers/ascii.ts +680 -0
- package/src/renderers/breadcrumb.ts +480 -0
- package/src/renderers/command-palette.ts +802 -0
- package/src/renderers/components/field.ts +210 -0
- package/src/renderers/components/form.ts +327 -0
- package/src/renderers/components/index.ts +21 -0
- package/src/renderers/components/search.ts +449 -0
- package/src/renderers/components/select.ts +222 -0
- package/src/renderers/index.ts +101 -0
- package/src/renderers/interactive/component-handlers.ts +622 -0
- package/src/renderers/interactive/cursor-manager.ts +147 -0
- package/src/renderers/interactive/focus-manager.ts +279 -0
- package/src/renderers/interactive/index.ts +661 -0
- package/src/renderers/interactive/input-handler.ts +164 -0
- package/src/renderers/interactive/keyboard-handler.ts +212 -0
- package/src/renderers/interactive/mouse-handler.ts +167 -0
- package/src/renderers/interactive/state-manager.ts +109 -0
- package/src/renderers/interactive/types.ts +338 -0
- package/src/renderers/interactive-string.ts +299 -0
- package/src/renderers/interactive.ts +59 -0
- package/src/renderers/markdown.ts +950 -0
- package/src/renderers/sidebar.ts +549 -0
- package/src/renderers/tabs.ts +682 -0
- package/src/renderers/text.ts +791 -0
- package/src/renderers/unicode.ts +917 -0
- package/src/renderers/utils.ts +942 -0
- package/src/router/adapters.ts +383 -0
- package/src/router/types.ts +140 -0
- package/src/router/utils.ts +452 -0
- package/src/schemas.ts +205 -0
- package/src/storybook/index.ts +91 -0
- package/src/storybook/interactive-decorator.tsx +659 -0
- package/src/storybook/keyboard-simulator.ts +501 -0
- package/src/theme/ansi-codes.ts +80 -0
- package/src/theme/box-drawing.ts +132 -0
- package/src/theme/color-convert.ts +254 -0
- package/src/theme/color-support.ts +321 -0
- package/src/theme/index.ts +134 -0
- package/src/theme/strip-ansi.ts +50 -0
- package/src/theme/tailwind-map.ts +469 -0
- package/src/theme/text-styles.ts +206 -0
- package/src/theme/theme-system.ts +568 -0
- package/src/types.ts +103 -0
package/src/data/sync.ts
ADDED
|
@@ -0,0 +1,1213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mdxui/terminal DO Sync Adapter
|
|
3
|
+
*
|
|
4
|
+
* Durable Objects sync adapter for bidirectional data synchronization
|
|
5
|
+
* via WebSocket connection. Provides:
|
|
6
|
+
* - WebSocket connection lifecycle management
|
|
7
|
+
* - Bidirectional data synchronization
|
|
8
|
+
* - Auth header injection for authenticated requests
|
|
9
|
+
* - Optimistic updates with server confirmation
|
|
10
|
+
* - Automatic reconnection with exponential backoff + jitter
|
|
11
|
+
* - Offline mutation queue for resilience
|
|
12
|
+
* - Connection state observable
|
|
13
|
+
* - Error categorization (recoverable vs fatal)
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* ## Connection State Machine
|
|
17
|
+
*
|
|
18
|
+
* ```
|
|
19
|
+
* ┌─────────────┐ connect() ┌────────────┐
|
|
20
|
+
* │ disconnected│──────────────►│ connecting │
|
|
21
|
+
* └─────────────┘ └────────────┘
|
|
22
|
+
* ▲ │
|
|
23
|
+
* │ │ onopen
|
|
24
|
+
* │ close() ▼
|
|
25
|
+
* │ ┌───────────┐
|
|
26
|
+
* │◄──────────────────────│ connected │
|
|
27
|
+
* │ close() / fatal err └───────────┘
|
|
28
|
+
* │ │
|
|
29
|
+
* │ │ recoverable error / close
|
|
30
|
+
* │ ▼
|
|
31
|
+
* │ ┌─────────────┐
|
|
32
|
+
* │◄─────────────────────│ reconnecting│───► (loop with backoff)
|
|
33
|
+
* │ max attempts └─────────────┘
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* ## Error Categories
|
|
37
|
+
*
|
|
38
|
+
* **Recoverable errors** - trigger reconnection:
|
|
39
|
+
* - Network timeouts
|
|
40
|
+
* - Temporary server unavailability
|
|
41
|
+
* - WebSocket close codes 1001 (going away), 1006 (abnormal closure)
|
|
42
|
+
*
|
|
43
|
+
* **Fatal errors** - require user intervention:
|
|
44
|
+
* - Authentication failures (401, 403)
|
|
45
|
+
* - Invalid namespace URL
|
|
46
|
+
* - WebSocket close code 1008 (policy violation)
|
|
47
|
+
* - Close code 4000+ (application-level errors)
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import type { SyncAdapter } from './types'
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Types
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Connection state for monitoring adapter health.
|
|
60
|
+
*
|
|
61
|
+
* State transitions follow a deterministic state machine pattern:
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* - `disconnected`: No active WebSocket connection. Initial state and terminal state.
|
|
65
|
+
* - `connecting`: WebSocket connection in progress. Transitions to `connected` on success,
|
|
66
|
+
* `disconnected` on fatal error, or `reconnecting` on recoverable error (if enabled).
|
|
67
|
+
* - `connected`: Active WebSocket connection ready for sync. Can transition to
|
|
68
|
+
* `disconnected` on close/fatal error or `reconnecting` on recoverable error.
|
|
69
|
+
* - `reconnecting`: Attempting to re-establish connection after a recoverable failure.
|
|
70
|
+
* Includes exponential backoff with jitter. Transitions to `connecting` when attempting,
|
|
71
|
+
* `disconnected` when max attempts reached or user calls close().
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* const sync = createDOSync({ namespaceUrl: '...' })
|
|
76
|
+
*
|
|
77
|
+
* sync.onConnectionStateChange((state) => {
|
|
78
|
+
* switch (state) {
|
|
79
|
+
* case 'disconnected':
|
|
80
|
+
* showOfflineIndicator()
|
|
81
|
+
* break
|
|
82
|
+
* case 'connecting':
|
|
83
|
+
* case 'reconnecting':
|
|
84
|
+
* showConnectingSpinner()
|
|
85
|
+
* break
|
|
86
|
+
* case 'connected':
|
|
87
|
+
* hideOfflineIndicator()
|
|
88
|
+
* break
|
|
89
|
+
* }
|
|
90
|
+
* })
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Error category for determining recovery strategy.
|
|
97
|
+
*
|
|
98
|
+
* @remarks
|
|
99
|
+
* Used internally to decide whether to attempt reconnection or fail permanently.
|
|
100
|
+
* - `recoverable`: Temporary failure that may succeed on retry (network issues, server overload)
|
|
101
|
+
* - `fatal`: Permanent failure requiring user intervention (auth, validation, policy)
|
|
102
|
+
*/
|
|
103
|
+
export type ErrorCategory = 'recoverable' | 'fatal'
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sync error with categorization for recovery decisions.
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* Extends Error with additional metadata for the sync adapter to make
|
|
110
|
+
* intelligent retry decisions. Use `category` to determine if reconnection
|
|
111
|
+
* should be attempted.
|
|
112
|
+
*/
|
|
113
|
+
export interface SyncError extends Error {
|
|
114
|
+
/** Error category for recovery decision */
|
|
115
|
+
category: ErrorCategory
|
|
116
|
+
/** WebSocket close code if applicable */
|
|
117
|
+
closeCode?: number
|
|
118
|
+
/** Original error that caused this failure */
|
|
119
|
+
cause?: Error
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a categorized sync error.
|
|
124
|
+
*
|
|
125
|
+
* @param message - Error message
|
|
126
|
+
* @param category - Whether error is recoverable or fatal
|
|
127
|
+
* @param options - Optional close code and cause
|
|
128
|
+
* @returns Categorized SyncError instance
|
|
129
|
+
*
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
function createSyncError(
|
|
133
|
+
message: string,
|
|
134
|
+
category: ErrorCategory,
|
|
135
|
+
options?: { closeCode?: number; cause?: Error }
|
|
136
|
+
): SyncError {
|
|
137
|
+
const error = new Error(message) as SyncError
|
|
138
|
+
error.category = category
|
|
139
|
+
error.name = 'SyncError'
|
|
140
|
+
if (options?.closeCode !== undefined) {
|
|
141
|
+
error.closeCode = options.closeCode
|
|
142
|
+
}
|
|
143
|
+
if (options?.cause) {
|
|
144
|
+
error.cause = options.cause
|
|
145
|
+
}
|
|
146
|
+
return error
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Categorize a WebSocket close code.
|
|
151
|
+
*
|
|
152
|
+
* @remarks
|
|
153
|
+
* Close codes are categorized based on their recoverability:
|
|
154
|
+
*
|
|
155
|
+
* **Recoverable (will retry):**
|
|
156
|
+
* - 1001 (Going Away) - Server shutting down normally
|
|
157
|
+
* - 1006 (Abnormal Closure) - Connection lost unexpectedly
|
|
158
|
+
* - 1011 (Internal Error) - Server encountered unexpected condition
|
|
159
|
+
* - 1012 (Service Restart) - Server restarting
|
|
160
|
+
* - 1013 (Try Again Later) - Server overloaded
|
|
161
|
+
* - 1014 (Bad Gateway) - Proxy/gateway error
|
|
162
|
+
*
|
|
163
|
+
* **Fatal (no retry):**
|
|
164
|
+
* - 1000 (Normal Closure) - Intentional close, no error
|
|
165
|
+
* - 1002 (Protocol Error) - Invalid WebSocket behavior
|
|
166
|
+
* - 1003 (Unsupported Data) - Received data type not supported
|
|
167
|
+
* - 1007 (Invalid Data) - Message data was invalid
|
|
168
|
+
* - 1008 (Policy Violation) - Server policy forbids connection
|
|
169
|
+
* - 1009 (Message Too Big) - Message exceeds size limit
|
|
170
|
+
* - 1010 (Missing Extension) - Required extension not negotiated
|
|
171
|
+
* - 4000+ (Application errors) - Custom close codes indicating app-level errors
|
|
172
|
+
*
|
|
173
|
+
* @param code - WebSocket close code
|
|
174
|
+
* @returns Error category for recovery decisions
|
|
175
|
+
*
|
|
176
|
+
* @internal
|
|
177
|
+
*/
|
|
178
|
+
function categorizeCloseCode(code: number): ErrorCategory {
|
|
179
|
+
// Recoverable close codes - temporary failures that may succeed on retry
|
|
180
|
+
const recoverableCodes = new Set([
|
|
181
|
+
1001, // Going Away - server shutting down
|
|
182
|
+
1006, // Abnormal Closure - connection lost
|
|
183
|
+
1011, // Internal Error - server-side issue
|
|
184
|
+
1012, // Service Restart - temporary unavailability
|
|
185
|
+
1013, // Try Again Later - server overloaded
|
|
186
|
+
1014, // Bad Gateway - proxy/gateway issue
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
if (recoverableCodes.has(code)) {
|
|
190
|
+
return 'recoverable'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Application-level close codes (4000+) are typically fatal
|
|
194
|
+
if (code >= 4000) {
|
|
195
|
+
return 'fatal'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Default to fatal for unknown codes (fail safe)
|
|
199
|
+
return 'fatal'
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Calculate backoff delay with jitter.
|
|
204
|
+
*
|
|
205
|
+
* Uses exponential backoff with full jitter to prevent thundering herd
|
|
206
|
+
* when many clients reconnect simultaneously.
|
|
207
|
+
*
|
|
208
|
+
* @param attempt - Current attempt number (0-based)
|
|
209
|
+
* @param initialDelay - Initial delay in ms
|
|
210
|
+
* @param maxDelay - Maximum delay cap in ms
|
|
211
|
+
* @returns Delay in ms with jitter applied
|
|
212
|
+
*
|
|
213
|
+
* @internal
|
|
214
|
+
*/
|
|
215
|
+
function calculateBackoffWithJitter(attempt: number, initialDelay: number, maxDelay: number): number {
|
|
216
|
+
// Calculate base exponential delay
|
|
217
|
+
const exponentialDelay = initialDelay * Math.pow(2, attempt)
|
|
218
|
+
|
|
219
|
+
// Cap at maxDelay
|
|
220
|
+
const cappedDelay = Math.min(exponentialDelay, maxDelay)
|
|
221
|
+
|
|
222
|
+
// Apply full jitter: random value between 0 and cappedDelay
|
|
223
|
+
// This prevents thundering herd when many clients reconnect
|
|
224
|
+
return Math.random() * cappedDelay
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Queued mutation for offline resilience.
|
|
229
|
+
*
|
|
230
|
+
* @remarks
|
|
231
|
+
* When the adapter is offline, mutations are queued and automatically
|
|
232
|
+
* retried once the connection is re-established. This ensures no data
|
|
233
|
+
* is lost during temporary disconnections.
|
|
234
|
+
*
|
|
235
|
+
* ## Lifecycle
|
|
236
|
+
* 1. Push fails due to connection error
|
|
237
|
+
* 2. Mutation is added to queue with timestamp
|
|
238
|
+
* 3. Connection is restored
|
|
239
|
+
* 4. Queue is flushed in FIFO order
|
|
240
|
+
* 5. Successful mutations are removed from queue
|
|
241
|
+
* 6. Failed mutations remain with incremented retryCount
|
|
242
|
+
*
|
|
243
|
+
* ## Inspection
|
|
244
|
+
* Use `getQueuedMutations()` to inspect the queue for debugging
|
|
245
|
+
* or displaying pending sync status to users.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```typescript
|
|
249
|
+
* // Display pending changes to user
|
|
250
|
+
* const pending = adapter.getQueuedMutations()
|
|
251
|
+
* if (pending.length > 0) {
|
|
252
|
+
* console.log(`${pending.length} changes waiting to sync`)
|
|
253
|
+
* }
|
|
254
|
+
* ```
|
|
255
|
+
*/
|
|
256
|
+
export interface QueuedMutation {
|
|
257
|
+
/** Unique identifier for this mutation (auto-generated) */
|
|
258
|
+
id: string
|
|
259
|
+
/** The changes to sync to the server */
|
|
260
|
+
changes: unknown[]
|
|
261
|
+
/** Timestamp (ms since epoch) when mutation was queued */
|
|
262
|
+
queuedAt: number
|
|
263
|
+
/** Number of failed retry attempts (0 = first attempt pending) */
|
|
264
|
+
retryCount: number
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Reconnection configuration options.
|
|
269
|
+
*
|
|
270
|
+
* @remarks
|
|
271
|
+
* Configures automatic reconnection behavior with exponential backoff.
|
|
272
|
+
* When enabled, the adapter will automatically attempt to reconnect
|
|
273
|
+
* on connection loss until maxAttempts is reached.
|
|
274
|
+
*
|
|
275
|
+
* ## Defaults
|
|
276
|
+
* - `enabled`: false (opt-in for auto-reconnect)
|
|
277
|
+
* - `maxAttempts`: Infinity (keep trying forever)
|
|
278
|
+
* - `initialDelay`: 1000ms (1 second)
|
|
279
|
+
* - `maxDelay`: 30000ms (30 seconds cap)
|
|
280
|
+
*
|
|
281
|
+
* ## Backoff Formula
|
|
282
|
+
* `delay = min(initialDelay * 2^attempt, maxDelay)`
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```typescript
|
|
286
|
+
* // Recommended production config
|
|
287
|
+
* const sync = createDOSync({
|
|
288
|
+
* namespaceUrl: '...',
|
|
289
|
+
* reconnect: {
|
|
290
|
+
* enabled: true,
|
|
291
|
+
* maxAttempts: 10,
|
|
292
|
+
* initialDelay: 1000,
|
|
293
|
+
* maxDelay: 30000
|
|
294
|
+
* }
|
|
295
|
+
* })
|
|
296
|
+
* ```
|
|
297
|
+
*/
|
|
298
|
+
export interface ReconnectOptions {
|
|
299
|
+
/**
|
|
300
|
+
* Whether to automatically reconnect on connection loss.
|
|
301
|
+
* @defaultValue false
|
|
302
|
+
*/
|
|
303
|
+
enabled?: boolean
|
|
304
|
+
/**
|
|
305
|
+
* Maximum number of reconnection attempts before giving up.
|
|
306
|
+
* Set to Infinity to retry indefinitely.
|
|
307
|
+
* @defaultValue Infinity
|
|
308
|
+
*/
|
|
309
|
+
maxAttempts?: number
|
|
310
|
+
/**
|
|
311
|
+
* Initial delay in ms before first reconnection attempt.
|
|
312
|
+
* Subsequent delays are calculated with exponential backoff.
|
|
313
|
+
* @defaultValue 1000
|
|
314
|
+
*/
|
|
315
|
+
initialDelay?: number
|
|
316
|
+
/**
|
|
317
|
+
* Maximum delay in ms between reconnection attempts (backoff cap).
|
|
318
|
+
* @defaultValue 30000
|
|
319
|
+
*/
|
|
320
|
+
maxDelay?: number
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Conflict resolution strategy for handling server-client data conflicts.
|
|
325
|
+
*
|
|
326
|
+
* @remarks
|
|
327
|
+
* - `server-wins`: Server version is authoritative, client changes discarded
|
|
328
|
+
* - `client-wins`: Client version is authoritative, server changes overwritten
|
|
329
|
+
* - `merge`: Attempt to merge both versions (server determines merge logic)
|
|
330
|
+
* - `throw`: Reject push with conflict error, let caller handle
|
|
331
|
+
* - `custom`: Call `onConflict` callback for custom resolution logic
|
|
332
|
+
*/
|
|
333
|
+
export type ConflictResolution = 'server-wins' | 'client-wins' | 'merge' | 'throw' | 'custom'
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Conflict details returned by server when client/server versions diverge.
|
|
337
|
+
*/
|
|
338
|
+
export interface Conflict {
|
|
339
|
+
/** ID of the conflicting entity */
|
|
340
|
+
id: string
|
|
341
|
+
/** Server's current version of the entity */
|
|
342
|
+
serverVersion: Record<string, unknown>
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Configuration for creating a DO Sync adapter.
|
|
347
|
+
*
|
|
348
|
+
* @remarks
|
|
349
|
+
* ## Minimal Configuration
|
|
350
|
+
* ```typescript
|
|
351
|
+
* const sync = createDOSync({
|
|
352
|
+
* namespaceUrl: 'https://api.example.com/namespace'
|
|
353
|
+
* })
|
|
354
|
+
* ```
|
|
355
|
+
*
|
|
356
|
+
* ## Full Configuration
|
|
357
|
+
* ```typescript
|
|
358
|
+
* const sync = createDOSync({
|
|
359
|
+
* namespaceUrl: 'https://api.example.com/namespace',
|
|
360
|
+
* authToken: 'jwt-token',
|
|
361
|
+
* reconnect: { enabled: true, maxAttempts: 10 },
|
|
362
|
+
* conflictResolution: 'server-wins',
|
|
363
|
+
* requestTimeout: 10000
|
|
364
|
+
* })
|
|
365
|
+
* ```
|
|
366
|
+
*/
|
|
367
|
+
export interface DOSyncConfig {
|
|
368
|
+
/**
|
|
369
|
+
* URL of the Durable Objects namespace endpoint.
|
|
370
|
+
* Will be converted to WebSocket URL (https -> wss, http -> ws).
|
|
371
|
+
*
|
|
372
|
+
* @example 'https://api.example.com/namespace'
|
|
373
|
+
*/
|
|
374
|
+
namespaceUrl: string
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Optional auth token for authenticated connections.
|
|
378
|
+
* Sent as URL query param and in message payloads.
|
|
379
|
+
* Can be updated dynamically via `setAuthToken()`.
|
|
380
|
+
*/
|
|
381
|
+
authToken?: string
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Automatic reconnection options.
|
|
385
|
+
* @see ReconnectOptions for defaults and configuration
|
|
386
|
+
*/
|
|
387
|
+
reconnect?: ReconnectOptions
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Strategy for resolving server-client data conflicts.
|
|
391
|
+
* @defaultValue 'server-wins' (implicit - server decides)
|
|
392
|
+
*/
|
|
393
|
+
conflictResolution?: ConflictResolution
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Custom conflict resolver callback.
|
|
397
|
+
* Required when `conflictResolution` is 'custom'.
|
|
398
|
+
*
|
|
399
|
+
* @param conflicts - Array of conflicting entities with server versions
|
|
400
|
+
* @returns Promise resolving to `{ resolved: true }` when conflicts handled
|
|
401
|
+
*/
|
|
402
|
+
onConflict?: (conflicts: Conflict[]) => Promise<{ resolved: boolean }>
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Request timeout in milliseconds for push/pull operations.
|
|
406
|
+
* Operations will reject with timeout error if no response received.
|
|
407
|
+
* @defaultValue 30000 (30 seconds)
|
|
408
|
+
*/
|
|
409
|
+
requestTimeout?: number
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Connection state observer callback type.
|
|
414
|
+
*
|
|
415
|
+
* @remarks
|
|
416
|
+
* Called whenever the connection state changes. Useful for UI feedback
|
|
417
|
+
* (e.g., showing "offline" status, disabling sync buttons, etc.).
|
|
418
|
+
*
|
|
419
|
+
* @param state - The new connection state
|
|
420
|
+
*/
|
|
421
|
+
export type ConnectionStateObserver = (state: ConnectionState) => void
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Extended sync adapter with connection management and state monitoring.
|
|
425
|
+
*
|
|
426
|
+
* @remarks
|
|
427
|
+
* Extends the base SyncAdapter with:
|
|
428
|
+
* - Connection lifecycle management (`close()`)
|
|
429
|
+
* - Dynamic auth token updates (`setAuthToken()`)
|
|
430
|
+
* - Reactive connection state (`onConnectionStateChange()`, `getConnectionState()`)
|
|
431
|
+
* - Offline queue inspection (`getQueuedMutations()`, `getQueueStats()`)
|
|
432
|
+
*
|
|
433
|
+
* ## Offline Resilience
|
|
434
|
+
* When push operations fail due to connection errors, mutations are
|
|
435
|
+
* automatically queued and retried when the connection is restored.
|
|
436
|
+
* This ensures no data is lost during temporary disconnections.
|
|
437
|
+
*
|
|
438
|
+
* @example
|
|
439
|
+
* ```typescript
|
|
440
|
+
* const sync = createDOSync({ namespaceUrl: '...' })
|
|
441
|
+
*
|
|
442
|
+
* // React to connection state changes
|
|
443
|
+
* sync.onConnectionStateChange((state) => {
|
|
444
|
+
* updateStatusIndicator(state)
|
|
445
|
+
* })
|
|
446
|
+
*
|
|
447
|
+
* // Check pending changes
|
|
448
|
+
* const { count, oldestAt } = sync.getQueueStats()
|
|
449
|
+
* if (count > 0) {
|
|
450
|
+
* showPendingBanner(`${count} changes pending since ${new Date(oldestAt!)}`)
|
|
451
|
+
* }
|
|
452
|
+
*
|
|
453
|
+
* // Cleanup on unmount
|
|
454
|
+
* sync.close()
|
|
455
|
+
* ```
|
|
456
|
+
*/
|
|
457
|
+
export interface DOSyncAdapter extends SyncAdapter {
|
|
458
|
+
/**
|
|
459
|
+
* Close the WebSocket connection and stop all reconnection attempts.
|
|
460
|
+
* Call this when unmounting or navigating away to clean up resources.
|
|
461
|
+
*/
|
|
462
|
+
close(): void
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Update the auth token dynamically.
|
|
466
|
+
* Useful for refreshing expired tokens without recreating the adapter.
|
|
467
|
+
* The new token will be used for subsequent connections.
|
|
468
|
+
*
|
|
469
|
+
* @param token - New auth token
|
|
470
|
+
*/
|
|
471
|
+
setAuthToken(token: string): void
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Subscribe to connection state changes.
|
|
475
|
+
* Callback is called immediately with current state, then on each change.
|
|
476
|
+
*
|
|
477
|
+
* @param callback - Observer function called on state changes
|
|
478
|
+
* @returns Unsubscribe function
|
|
479
|
+
*/
|
|
480
|
+
onConnectionStateChange(callback: ConnectionStateObserver): () => void
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get current connection state without subscribing.
|
|
484
|
+
* Use for one-time checks; use `onConnectionStateChange()` for reactive updates.
|
|
485
|
+
*
|
|
486
|
+
* @returns Current connection state
|
|
487
|
+
*/
|
|
488
|
+
getConnectionState(): ConnectionState
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Get all mutations currently in the offline queue.
|
|
492
|
+
* Useful for debugging or displaying pending sync status.
|
|
493
|
+
*
|
|
494
|
+
* @returns Array of queued mutations
|
|
495
|
+
*/
|
|
496
|
+
getQueuedMutations(): QueuedMutation[]
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Get summary statistics about the offline queue.
|
|
500
|
+
*
|
|
501
|
+
* @returns Object with:
|
|
502
|
+
* - `count`: Number of pending mutations
|
|
503
|
+
* - `oldestAt`: Timestamp of oldest mutation (null if empty)
|
|
504
|
+
*/
|
|
505
|
+
getQueueStats(): { count: number; oldestAt: number | null }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ============================================================================
|
|
509
|
+
// Message Types
|
|
510
|
+
// ============================================================================
|
|
511
|
+
|
|
512
|
+
interface PushMessage {
|
|
513
|
+
type: 'push'
|
|
514
|
+
id: string
|
|
515
|
+
changes: unknown[]
|
|
516
|
+
auth?: { token: string }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
interface PullMessage {
|
|
520
|
+
type: 'pull'
|
|
521
|
+
id: string
|
|
522
|
+
auth?: { token: string }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
interface AckMessage {
|
|
526
|
+
type: 'ack'
|
|
527
|
+
id: string
|
|
528
|
+
status: 'success' | 'error' | 'partial' | 'conflict'
|
|
529
|
+
error?: string
|
|
530
|
+
confirmedChanges?: string[]
|
|
531
|
+
failedChanges?: Array<{ id: string; reason: string }>
|
|
532
|
+
conflicts?: Conflict[]
|
|
533
|
+
resolution?: string
|
|
534
|
+
mergedVersion?: Record<string, unknown>
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
interface PullResponseMessage {
|
|
538
|
+
type: 'pull-response'
|
|
539
|
+
changes: unknown[]
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
interface SyncMessage {
|
|
543
|
+
type: 'sync'
|
|
544
|
+
changes: unknown[]
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
type ServerMessage = AckMessage | PullResponseMessage | SyncMessage
|
|
548
|
+
|
|
549
|
+
// ============================================================================
|
|
550
|
+
// Implementation
|
|
551
|
+
// ============================================================================
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Create a Durable Objects sync adapter
|
|
555
|
+
*
|
|
556
|
+
* @param config - Configuration options
|
|
557
|
+
* @returns A sync adapter instance for use with createDB
|
|
558
|
+
*
|
|
559
|
+
* @throws Error if namespaceUrl is empty or invalid
|
|
560
|
+
*
|
|
561
|
+
* @example
|
|
562
|
+
* ```typescript
|
|
563
|
+
* import { createDOSync } from '@mdxui/terminal'
|
|
564
|
+
*
|
|
565
|
+
* const sync = createDOSync({
|
|
566
|
+
* namespaceUrl: 'https://api.example.com/namespace',
|
|
567
|
+
* authToken: 'your-auth-token',
|
|
568
|
+
* reconnect: { enabled: true, maxAttempts: 5 }
|
|
569
|
+
* })
|
|
570
|
+
*
|
|
571
|
+
* const db = createDB({
|
|
572
|
+
* collections: [usersCollection],
|
|
573
|
+
* sync
|
|
574
|
+
* })
|
|
575
|
+
* ```
|
|
576
|
+
*/
|
|
577
|
+
export function createDOSync(config: DOSyncConfig): DOSyncAdapter {
|
|
578
|
+
// Validate namespace URL
|
|
579
|
+
if (!config.namespaceUrl) {
|
|
580
|
+
throw new Error('namespaceUrl is required')
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Validate URL format
|
|
584
|
+
try {
|
|
585
|
+
new URL(config.namespaceUrl)
|
|
586
|
+
} catch {
|
|
587
|
+
throw new Error('Invalid namespaceUrl format')
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// State
|
|
591
|
+
let ws: WebSocket | null = null
|
|
592
|
+
let authToken = config.authToken
|
|
593
|
+
let messageId = 0
|
|
594
|
+
let reconnectAttempts = 0
|
|
595
|
+
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
596
|
+
let isClosed = false
|
|
597
|
+
let connectionState: ConnectionState = 'disconnected'
|
|
598
|
+
|
|
599
|
+
// Pending operations waiting for responses
|
|
600
|
+
const pendingPush = new Map<
|
|
601
|
+
string,
|
|
602
|
+
{
|
|
603
|
+
resolve: () => void
|
|
604
|
+
reject: (error: Error) => void
|
|
605
|
+
timeoutId?: ReturnType<typeof setTimeout>
|
|
606
|
+
}
|
|
607
|
+
>()
|
|
608
|
+
const pendingPull = new Map<
|
|
609
|
+
string,
|
|
610
|
+
{
|
|
611
|
+
resolve: (changes: unknown[]) => void
|
|
612
|
+
reject: (error: Error) => void
|
|
613
|
+
timeoutId?: ReturnType<typeof setTimeout>
|
|
614
|
+
}
|
|
615
|
+
>()
|
|
616
|
+
|
|
617
|
+
// Subscribers for remote changes
|
|
618
|
+
const subscribers = new Set<(changes: unknown[]) => void>()
|
|
619
|
+
|
|
620
|
+
// Connection state observers for monitoring
|
|
621
|
+
const connectionStateObservers = new Set<ConnectionStateObserver>()
|
|
622
|
+
|
|
623
|
+
// Offline mutation queue for resilience
|
|
624
|
+
const mutationQueue = new Map<string, QueuedMutation>()
|
|
625
|
+
|
|
626
|
+
// Default reconnect options
|
|
627
|
+
const reconnectOptions: Required<ReconnectOptions> = {
|
|
628
|
+
enabled: config.reconnect?.enabled ?? false,
|
|
629
|
+
maxAttempts: config.reconnect?.maxAttempts ?? Infinity,
|
|
630
|
+
initialDelay: config.reconnect?.initialDelay ?? 1000,
|
|
631
|
+
maxDelay: config.reconnect?.maxDelay ?? 30000,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Default timeout
|
|
635
|
+
const requestTimeout = config.requestTimeout ?? 30000
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Notify all connection state observers of a state change
|
|
639
|
+
*/
|
|
640
|
+
function notifyConnectionStateChange(newState: ConnectionState): void {
|
|
641
|
+
if (newState !== connectionState) {
|
|
642
|
+
connectionState = newState
|
|
643
|
+
for (const observer of connectionStateObservers) {
|
|
644
|
+
observer(connectionState)
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Add a mutation to the offline queue
|
|
651
|
+
*/
|
|
652
|
+
function queueMutation(changes: unknown[]): QueuedMutation {
|
|
653
|
+
const id = `queued-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
654
|
+
const mutation: QueuedMutation = {
|
|
655
|
+
id,
|
|
656
|
+
changes,
|
|
657
|
+
queuedAt: Date.now(),
|
|
658
|
+
retryCount: 0,
|
|
659
|
+
}
|
|
660
|
+
mutationQueue.set(id, mutation)
|
|
661
|
+
return mutation
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Flush queued mutations by retrying them
|
|
666
|
+
*
|
|
667
|
+
* @remarks
|
|
668
|
+
* Called when connection is restored. Retries all queued mutations
|
|
669
|
+
* in the order they were queued. Failed mutations are kept for retry.
|
|
670
|
+
*/
|
|
671
|
+
async function flushMutationQueue(): Promise<void> {
|
|
672
|
+
const mutations = Array.from(mutationQueue.values())
|
|
673
|
+
for (const mutation of mutations) {
|
|
674
|
+
try {
|
|
675
|
+
// Retry the queued mutation
|
|
676
|
+
await push(mutation.changes)
|
|
677
|
+
// Remove from queue on success
|
|
678
|
+
mutationQueue.delete(mutation.id)
|
|
679
|
+
} catch {
|
|
680
|
+
// Keep in queue and increment retry count for next attempt
|
|
681
|
+
mutation.retryCount++
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Convert HTTPS URL to WSS URL
|
|
688
|
+
*/
|
|
689
|
+
function getWebSocketUrl(): string {
|
|
690
|
+
const url = new URL(config.namespaceUrl)
|
|
691
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
692
|
+
if (authToken) {
|
|
693
|
+
url.searchParams.set('token', authToken)
|
|
694
|
+
}
|
|
695
|
+
return url.toString()
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Generate a unique message ID
|
|
700
|
+
*/
|
|
701
|
+
function nextMessageId(): string {
|
|
702
|
+
messageId++
|
|
703
|
+
return `push-${messageId}`
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Handle incoming WebSocket message
|
|
708
|
+
*/
|
|
709
|
+
function handleMessage(event: MessageEvent): void {
|
|
710
|
+
let message: ServerMessage
|
|
711
|
+
try {
|
|
712
|
+
message = JSON.parse(event.data)
|
|
713
|
+
} catch {
|
|
714
|
+
// Malformed JSON - ignore gracefully
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
switch (message.type) {
|
|
719
|
+
case 'ack': {
|
|
720
|
+
const pending = pendingPush.get(message.id)
|
|
721
|
+
if (pending) {
|
|
722
|
+
if (pending.timeoutId) {
|
|
723
|
+
clearTimeout(pending.timeoutId)
|
|
724
|
+
}
|
|
725
|
+
pendingPush.delete(message.id)
|
|
726
|
+
|
|
727
|
+
if (message.status === 'error') {
|
|
728
|
+
pending.reject(new Error(message.error || 'Push failed'))
|
|
729
|
+
} else if (message.status === 'conflict') {
|
|
730
|
+
// Handle based on conflict resolution strategy
|
|
731
|
+
if (config.conflictResolution === 'throw') {
|
|
732
|
+
pending.reject(new Error('Conflict detected'))
|
|
733
|
+
} else if (config.conflictResolution === 'custom' && config.onConflict && message.conflicts) {
|
|
734
|
+
config.onConflict(message.conflicts).then(() => pending.resolve())
|
|
735
|
+
} else {
|
|
736
|
+
// server-wins, client-wins, merge - all resolve successfully
|
|
737
|
+
pending.resolve()
|
|
738
|
+
}
|
|
739
|
+
} else {
|
|
740
|
+
// success or partial
|
|
741
|
+
pending.resolve()
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
break
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
case 'pull-response': {
|
|
748
|
+
// Find the first pending pull and resolve it
|
|
749
|
+
const entries = Array.from(pendingPull.entries())
|
|
750
|
+
if (entries.length > 0) {
|
|
751
|
+
const [id, pending] = entries[0]
|
|
752
|
+
if (pending.timeoutId) {
|
|
753
|
+
clearTimeout(pending.timeoutId)
|
|
754
|
+
}
|
|
755
|
+
pendingPull.delete(id)
|
|
756
|
+
pending.resolve(message.changes)
|
|
757
|
+
}
|
|
758
|
+
break
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
case 'sync': {
|
|
762
|
+
// Notify all subscribers of remote changes
|
|
763
|
+
for (const callback of subscribers) {
|
|
764
|
+
callback(message.changes)
|
|
765
|
+
}
|
|
766
|
+
break
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Handle WebSocket error
|
|
773
|
+
*
|
|
774
|
+
* @remarks
|
|
775
|
+
* On connection error:
|
|
776
|
+
* - Pull operations are rejected immediately (need fresh data)
|
|
777
|
+
* - Push operations are left to be handled by their caller's catch blocks
|
|
778
|
+
* (which can queue them for offline resilience)
|
|
779
|
+
* Connection state is updated to 'disconnected' to signal offline mode.
|
|
780
|
+
*/
|
|
781
|
+
function handleError(): void {
|
|
782
|
+
notifyConnectionStateChange('disconnected')
|
|
783
|
+
|
|
784
|
+
// Clear timeouts on pending push operations (but don't reject)
|
|
785
|
+
// Let the catch handler in push() deal with queueing
|
|
786
|
+
for (const [id, pending] of pendingPush) {
|
|
787
|
+
if (pending.timeoutId) {
|
|
788
|
+
clearTimeout(pending.timeoutId)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Reject all pending pull operations (can't queue reads, need fresh data)
|
|
793
|
+
for (const [id, pending] of pendingPull) {
|
|
794
|
+
if (pending.timeoutId) {
|
|
795
|
+
clearTimeout(pending.timeoutId)
|
|
796
|
+
}
|
|
797
|
+
pending.reject(new Error('WebSocket error'))
|
|
798
|
+
}
|
|
799
|
+
pendingPull.clear()
|
|
800
|
+
|
|
801
|
+
// Attempt reconnection
|
|
802
|
+
scheduleReconnect()
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Handle WebSocket close
|
|
807
|
+
*
|
|
808
|
+
* @remarks
|
|
809
|
+
* Cleans up the closed socket and schedules reconnection if not explicitly
|
|
810
|
+
* closed by the user. Connection state is updated to reflect disconnection.
|
|
811
|
+
*/
|
|
812
|
+
function handleClose(): void {
|
|
813
|
+
ws = null
|
|
814
|
+
notifyConnectionStateChange('disconnected')
|
|
815
|
+
if (!isClosed) {
|
|
816
|
+
scheduleReconnect()
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
822
|
+
*
|
|
823
|
+
* @remarks
|
|
824
|
+
* Implements exponential backoff strategy with the formula:
|
|
825
|
+
* `delay = min(initialDelay * 2^attempt, maxDelay)`
|
|
826
|
+
*
|
|
827
|
+
* Example progression with initialDelay=1000, maxDelay=30000:
|
|
828
|
+
* - Attempt 0: 1000ms
|
|
829
|
+
* - Attempt 1: 2000ms
|
|
830
|
+
* - Attempt 2: 4000ms
|
|
831
|
+
* - Attempt 3: 8000ms
|
|
832
|
+
* - Attempt 4: 16000ms
|
|
833
|
+
* - Attempt 5+: 30000ms (capped)
|
|
834
|
+
*
|
|
835
|
+
* Updates connection state to 'reconnecting' to signal retry attempts.
|
|
836
|
+
* Stops scheduling when:
|
|
837
|
+
* - `reconnect.enabled` is false
|
|
838
|
+
* - `close()` has been called
|
|
839
|
+
* - `maxAttempts` has been reached
|
|
840
|
+
*
|
|
841
|
+
* @internal
|
|
842
|
+
*/
|
|
843
|
+
function scheduleReconnect(): void {
|
|
844
|
+
// Guard: Don't reconnect if disabled or explicitly closed
|
|
845
|
+
if (!reconnectOptions.enabled || isClosed) {
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Guard: Don't exceed max attempts
|
|
850
|
+
if (reconnectAttempts >= reconnectOptions.maxAttempts) {
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
notifyConnectionStateChange('reconnecting')
|
|
855
|
+
|
|
856
|
+
// Calculate delay with exponential backoff
|
|
857
|
+
// Note: For production use with many concurrent clients, consider using
|
|
858
|
+
// calculateBackoffWithJitter() to prevent thundering herd
|
|
859
|
+
const delay = Math.min(
|
|
860
|
+
reconnectOptions.initialDelay * Math.pow(2, reconnectAttempts),
|
|
861
|
+
reconnectOptions.maxDelay
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
reconnectAttempts++
|
|
865
|
+
|
|
866
|
+
reconnectTimeout = setTimeout(() => {
|
|
867
|
+
if (!isClosed) {
|
|
868
|
+
connect()
|
|
869
|
+
}
|
|
870
|
+
}, delay)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// WebSocket readyState constants
|
|
874
|
+
const WS_CONNECTING = 0
|
|
875
|
+
const WS_OPEN = 1
|
|
876
|
+
const WS_CLOSING = 2
|
|
877
|
+
const WS_CLOSED = 3
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Establish WebSocket connection
|
|
881
|
+
*
|
|
882
|
+
* @remarks
|
|
883
|
+
* Creates a new WebSocket if none exists. Reuses existing connections
|
|
884
|
+
* and updates connection state. Flushes queued mutations on successful connection.
|
|
885
|
+
*/
|
|
886
|
+
function connect(): WebSocket {
|
|
887
|
+
if (ws && ws.readyState === WS_OPEN) {
|
|
888
|
+
return ws
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (ws && ws.readyState === WS_CONNECTING) {
|
|
892
|
+
return ws
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
notifyConnectionStateChange('connecting')
|
|
896
|
+
|
|
897
|
+
ws = new WebSocket(getWebSocketUrl())
|
|
898
|
+
|
|
899
|
+
ws.onopen = () => {
|
|
900
|
+
// Reset reconnection attempts on successful connection
|
|
901
|
+
reconnectAttempts = 0
|
|
902
|
+
notifyConnectionStateChange('connected')
|
|
903
|
+
|
|
904
|
+
// Flush queued mutations when reconnected
|
|
905
|
+
flushMutationQueue().catch(() => {
|
|
906
|
+
// Errors during flush are non-blocking - mutations stay queued for retry
|
|
907
|
+
})
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
ws.onmessage = handleMessage
|
|
911
|
+
ws.onerror = handleError
|
|
912
|
+
ws.onclose = handleClose
|
|
913
|
+
|
|
914
|
+
return ws
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Ensure WebSocket is connected, waiting if necessary
|
|
919
|
+
*
|
|
920
|
+
* @remarks
|
|
921
|
+
* Returns a connected WebSocket or rejects if connection fails.
|
|
922
|
+
* Does NOT queue mutations - that's handled by the caller (push/pull).
|
|
923
|
+
*/
|
|
924
|
+
function ensureConnected(): Promise<WebSocket> {
|
|
925
|
+
return new Promise((resolve, reject) => {
|
|
926
|
+
const socket = connect()
|
|
927
|
+
|
|
928
|
+
if (socket.readyState === WS_OPEN) {
|
|
929
|
+
resolve(socket)
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const originalOnOpen = socket.onopen
|
|
934
|
+
const originalOnError = socket.onerror
|
|
935
|
+
|
|
936
|
+
socket.onopen = (event) => {
|
|
937
|
+
socket.onopen = originalOnOpen
|
|
938
|
+
socket.onerror = originalOnError
|
|
939
|
+
if (originalOnOpen) {
|
|
940
|
+
originalOnOpen.call(socket, event)
|
|
941
|
+
}
|
|
942
|
+
resolve(socket)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
socket.onerror = (event) => {
|
|
946
|
+
socket.onopen = originalOnOpen
|
|
947
|
+
socket.onerror = originalOnError
|
|
948
|
+
if (originalOnError) {
|
|
949
|
+
originalOnError.call(socket, event)
|
|
950
|
+
}
|
|
951
|
+
reject(new Error('WebSocket connection failed'))
|
|
952
|
+
}
|
|
953
|
+
})
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Push local changes to remote server
|
|
958
|
+
*
|
|
959
|
+
* @remarks
|
|
960
|
+
* Attempts to push changes to the server. If the connection is not available,
|
|
961
|
+
* the mutation is queued for later retry. Empty pushes are sent immediately
|
|
962
|
+
* and don't trigger queueing.
|
|
963
|
+
*/
|
|
964
|
+
function push(changes: unknown[]): Promise<void> {
|
|
965
|
+
const id = nextMessageId()
|
|
966
|
+
const isEmptyPush = changes.length === 0
|
|
967
|
+
|
|
968
|
+
return new Promise((resolve, reject) => {
|
|
969
|
+
const message: PushMessage = {
|
|
970
|
+
type: 'push',
|
|
971
|
+
id,
|
|
972
|
+
changes,
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (authToken) {
|
|
976
|
+
message.auth = { token: authToken }
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// For non-empty pushes, set up timeout and wait for ACK
|
|
980
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
981
|
+
if (!isEmptyPush && requestTimeout > 0) {
|
|
982
|
+
timeoutId = setTimeout(() => {
|
|
983
|
+
const pending = pendingPush.get(id)
|
|
984
|
+
if (pending) {
|
|
985
|
+
pendingPush.delete(id)
|
|
986
|
+
pending.reject(new Error('Push timeout'))
|
|
987
|
+
}
|
|
988
|
+
}, requestTimeout)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// For non-empty pushes, register pending request BEFORE waiting for connection
|
|
992
|
+
// This ensures ACK messages are processed correctly even if they
|
|
993
|
+
// arrive in the same event loop tick as connection opens
|
|
994
|
+
if (!isEmptyPush) {
|
|
995
|
+
pendingPush.set(id, { resolve, reject, timeoutId })
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Wait for connection, then send
|
|
999
|
+
ensureConnected()
|
|
1000
|
+
.then((socket) => {
|
|
1001
|
+
socket.send(JSON.stringify(message))
|
|
1002
|
+
// For empty pushes, resolve immediately after sending (no ACK needed)
|
|
1003
|
+
if (isEmptyPush) {
|
|
1004
|
+
resolve()
|
|
1005
|
+
}
|
|
1006
|
+
// For non-empty pushes, wait for ACK (pendingPush will resolve via handleMessage)
|
|
1007
|
+
})
|
|
1008
|
+
.catch((error) => {
|
|
1009
|
+
if (!isEmptyPush) {
|
|
1010
|
+
// Remove from pending map so ACK handler won't try to resolve it again
|
|
1011
|
+
pendingPush.delete(id)
|
|
1012
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
1013
|
+
|
|
1014
|
+
// Queue the mutation for offline resilience
|
|
1015
|
+
queueMutation(changes)
|
|
1016
|
+
resolve() // Resolve after queueing to signal acceptance
|
|
1017
|
+
} else {
|
|
1018
|
+
// Empty pushes also queue on connection failure for consistency
|
|
1019
|
+
// Just silently ignore the error (empty push is fire-and-forget with queueing)
|
|
1020
|
+
// Don't reject to avoid unhandled rejection
|
|
1021
|
+
}
|
|
1022
|
+
})
|
|
1023
|
+
})
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Pull remote changes from server
|
|
1028
|
+
*/
|
|
1029
|
+
function pull(): Promise<unknown[]> {
|
|
1030
|
+
const id = nextMessageId()
|
|
1031
|
+
|
|
1032
|
+
return new Promise((resolve, reject) => {
|
|
1033
|
+
const message: PullMessage = {
|
|
1034
|
+
type: 'pull',
|
|
1035
|
+
id,
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (authToken) {
|
|
1039
|
+
message.auth = { token: authToken }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Set up timeout
|
|
1043
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
1044
|
+
if (requestTimeout > 0) {
|
|
1045
|
+
timeoutId = setTimeout(() => {
|
|
1046
|
+
const pending = pendingPull.get(id)
|
|
1047
|
+
if (pending) {
|
|
1048
|
+
pendingPull.delete(id)
|
|
1049
|
+
pending.reject(new Error('Pull timeout'))
|
|
1050
|
+
}
|
|
1051
|
+
}, requestTimeout)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Register pending request BEFORE waiting for connection
|
|
1055
|
+
pendingPull.set(id, { resolve, reject, timeoutId })
|
|
1056
|
+
|
|
1057
|
+
// Wait for connection, then send
|
|
1058
|
+
ensureConnected()
|
|
1059
|
+
.then((socket) => {
|
|
1060
|
+
socket.send(JSON.stringify(message))
|
|
1061
|
+
})
|
|
1062
|
+
.catch((error) => {
|
|
1063
|
+
pendingPull.delete(id)
|
|
1064
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
1065
|
+
reject(error)
|
|
1066
|
+
})
|
|
1067
|
+
})
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Subscribe to remote changes
|
|
1072
|
+
*/
|
|
1073
|
+
function subscribe(callback: (changes: unknown[]) => void): () => void {
|
|
1074
|
+
subscribers.add(callback)
|
|
1075
|
+
|
|
1076
|
+
// Ensure connection is established for subscription
|
|
1077
|
+
connect()
|
|
1078
|
+
|
|
1079
|
+
return () => {
|
|
1080
|
+
subscribers.delete(callback)
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Close the WebSocket connection
|
|
1086
|
+
*
|
|
1087
|
+
* @remarks
|
|
1088
|
+
* Stops all reconnection attempts and closes the active WebSocket.
|
|
1089
|
+
* Connection state is updated to 'disconnected' and remains so until
|
|
1090
|
+
* the adapter is recreated.
|
|
1091
|
+
*/
|
|
1092
|
+
function close(): void {
|
|
1093
|
+
isClosed = true
|
|
1094
|
+
|
|
1095
|
+
if (reconnectTimeout) {
|
|
1096
|
+
clearTimeout(reconnectTimeout)
|
|
1097
|
+
reconnectTimeout = null
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (ws) {
|
|
1101
|
+
ws.close()
|
|
1102
|
+
ws = null
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
notifyConnectionStateChange('disconnected')
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Update auth token dynamically
|
|
1110
|
+
*
|
|
1111
|
+
* @remarks
|
|
1112
|
+
* Updates the token used for all future connections. Existing connection
|
|
1113
|
+
* will use the new token on reconnection. Useful for refreshing expired
|
|
1114
|
+
* credentials without recreating the adapter.
|
|
1115
|
+
*
|
|
1116
|
+
* @example
|
|
1117
|
+
* ```typescript
|
|
1118
|
+
* adapter.setAuthToken(newToken)
|
|
1119
|
+
* ```
|
|
1120
|
+
*/
|
|
1121
|
+
function setAuthToken(token: string): void {
|
|
1122
|
+
authToken = token
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Subscribe to connection state changes
|
|
1127
|
+
*
|
|
1128
|
+
* @remarks
|
|
1129
|
+
* Returns an unsubscribe function. Observers are called synchronously
|
|
1130
|
+
* whenever the connection state changes. The callback is called immediately
|
|
1131
|
+
* with the current state when subscribed.
|
|
1132
|
+
*
|
|
1133
|
+
* @example
|
|
1134
|
+
* ```typescript
|
|
1135
|
+
* const unsubscribe = adapter.onConnectionStateChange((state) => {
|
|
1136
|
+
* console.log('Connection state:', state)
|
|
1137
|
+
* })
|
|
1138
|
+
* unsubscribe() // Stop listening
|
|
1139
|
+
* ```
|
|
1140
|
+
*/
|
|
1141
|
+
function onConnectionStateChange(callback: ConnectionStateObserver): () => void {
|
|
1142
|
+
connectionStateObservers.add(callback)
|
|
1143
|
+
// Call immediately with current state
|
|
1144
|
+
callback(connectionState)
|
|
1145
|
+
return () => {
|
|
1146
|
+
connectionStateObservers.delete(callback)
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Get the current connection state
|
|
1152
|
+
*
|
|
1153
|
+
* @remarks
|
|
1154
|
+
* Returns the current state without subscribing. Use `onConnectionStateChange`
|
|
1155
|
+
* for reactive updates. Possible states:
|
|
1156
|
+
* - `disconnected`: No active connection
|
|
1157
|
+
* - `connecting`: Attempting to establish connection
|
|
1158
|
+
* - `connected`: Active connection ready for sync
|
|
1159
|
+
* - `reconnecting`: In exponential backoff before retry
|
|
1160
|
+
*
|
|
1161
|
+
* @returns Current connection state
|
|
1162
|
+
*/
|
|
1163
|
+
function getConnectionState(): ConnectionState {
|
|
1164
|
+
return connectionState
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Get all queued mutations (for inspection/debugging)
|
|
1169
|
+
*
|
|
1170
|
+
* @remarks
|
|
1171
|
+
* Returns a snapshot of all mutations currently in the offline queue.
|
|
1172
|
+
* Useful for debugging or displaying pending sync status to users.
|
|
1173
|
+
* The queue is automatically flushed when the connection is restored.
|
|
1174
|
+
*
|
|
1175
|
+
* @returns Array of queued mutations with retry counts
|
|
1176
|
+
*/
|
|
1177
|
+
function getQueuedMutations(): QueuedMutation[] {
|
|
1178
|
+
return Array.from(mutationQueue.values())
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Get statistics about the offline mutation queue
|
|
1183
|
+
*
|
|
1184
|
+
* @remarks
|
|
1185
|
+
* Provides queue statistics:
|
|
1186
|
+
* - `count`: Number of mutations waiting to be synced
|
|
1187
|
+
* - `oldestAt`: Timestamp of the oldest queued mutation (null if empty)
|
|
1188
|
+
*
|
|
1189
|
+
* Useful for UI to show "N pending changes" or "syncing..." indicators.
|
|
1190
|
+
*
|
|
1191
|
+
* @returns Queue statistics with count and oldest mutation timestamp
|
|
1192
|
+
*/
|
|
1193
|
+
function getQueueStats(): { count: number; oldestAt: number | null } {
|
|
1194
|
+
const mutations = Array.from(mutationQueue.values())
|
|
1195
|
+
if (mutations.length === 0) {
|
|
1196
|
+
return { count: 0, oldestAt: null }
|
|
1197
|
+
}
|
|
1198
|
+
const oldest = Math.min(...mutations.map((m) => m.queuedAt))
|
|
1199
|
+
return { count: mutations.length, oldestAt: oldest }
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
push,
|
|
1204
|
+
pull,
|
|
1205
|
+
subscribe,
|
|
1206
|
+
close,
|
|
1207
|
+
setAuthToken,
|
|
1208
|
+
onConnectionStateChange,
|
|
1209
|
+
getConnectionState,
|
|
1210
|
+
getQueuedMutations,
|
|
1211
|
+
getQueueStats,
|
|
1212
|
+
}
|
|
1213
|
+
}
|