@luxexchange/websocket 1.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.
@@ -0,0 +1,113 @@
1
+ import { MockWebSocket } from '@luxexchange/websocket/src/client/__tests__/MockWebSocket'
2
+ import { createWebSocketClient } from '@luxexchange/websocket/src/client/createWebSocketClient'
3
+ import { createZustandConnectionStore } from '@luxexchange/websocket/src/store/createZustandConnectionStore'
4
+ import type { ConnectionStore } from '@luxexchange/websocket/src/store/types'
5
+ import type { CreateWebSocketClientOptions, WebSocketClient } from '@luxexchange/websocket/src/types'
6
+ import { vi } from 'vitest'
7
+
8
+ export interface TestParams {
9
+ channel: string
10
+ id: string
11
+ }
12
+
13
+ export interface TestMessage {
14
+ data: string
15
+ price?: number
16
+ }
17
+
18
+ interface TestHandler {
19
+ subscribe: ReturnType<typeof vi.fn>
20
+ unsubscribe: ReturnType<typeof vi.fn>
21
+ subscribeBatch: ReturnType<typeof vi.fn>
22
+ unsubscribeBatch: ReturnType<typeof vi.fn>
23
+ refreshSession: ReturnType<typeof vi.fn>
24
+ }
25
+
26
+ interface TestClientResult {
27
+ client: WebSocketClient<TestParams, TestMessage>
28
+ mockSocket: MockWebSocket
29
+ handler: TestHandler
30
+ connectionStore: ConnectionStore
31
+ }
32
+
33
+ /**
34
+ * Creates a test WebSocket client with mocked dependencies.
35
+ * Returns the client, mock socket, mock handler, and connection store for assertions.
36
+ */
37
+ export function createTestClient(
38
+ overrides?: Partial<CreateWebSocketClientOptions<TestParams, TestMessage>>,
39
+ ): TestClientResult {
40
+ const mockSocket = new MockWebSocket()
41
+ const connectionStore = createZustandConnectionStore()
42
+
43
+ const handler: TestHandler = {
44
+ subscribe: vi.fn().mockResolvedValue(undefined),
45
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
46
+ subscribeBatch: vi.fn().mockResolvedValue(undefined),
47
+ unsubscribeBatch: vi.fn().mockResolvedValue(undefined),
48
+ refreshSession: vi.fn().mockResolvedValue(undefined),
49
+ }
50
+
51
+ const client = createWebSocketClient<TestParams, TestMessage>({
52
+ config: { url: 'wss://test.example.com' },
53
+ connectionStore,
54
+ subscriptionHandler: overrides?.subscriptionHandler ?? handler,
55
+ parseMessage: (raw) => {
56
+ const msg = raw as { channel?: string; key?: string; data?: TestMessage }
57
+ if (msg.channel && msg.key && msg.data) {
58
+ return { channel: msg.channel, key: msg.key, data: msg.data }
59
+ }
60
+ return null
61
+ },
62
+ parseConnectionMessage: (raw) => {
63
+ const msg = raw as { type?: string; connectionId?: string }
64
+ if (msg.type === 'connected' && msg.connectionId) {
65
+ return { connectionId: msg.connectionId }
66
+ }
67
+ return null
68
+ },
69
+ createSubscriptionKey: (channel, params) => `${channel}:${params.id}`,
70
+ socketFactory: () => mockSocket,
71
+ ...overrides,
72
+ })
73
+
74
+ return { client, mockSocket, handler, connectionStore }
75
+ }
76
+
77
+ interface ConnectViaSubscribeOptions {
78
+ client: WebSocketClient<TestParams, TestMessage>
79
+ mockSocket: MockWebSocket
80
+ connectionId?: string
81
+ }
82
+
83
+ /**
84
+ * Triggers a connection by subscribing, then simulates socket open and connection message.
85
+ * Returns the unsubscribe function from the subscription that triggered the connection.
86
+ */
87
+ export function connectViaSubscribe(options: ConnectViaSubscribeOptions): () => void {
88
+ const { client, mockSocket, connectionId = 'conn-123' } = options
89
+ const unsub = client.subscribe({
90
+ channel: '__connect',
91
+ params: { channel: '__connect', id: '__connect' },
92
+ onMessage: vi.fn(),
93
+ })
94
+ mockSocket.simulateOpen()
95
+ mockSocket.simulateMessage({ type: 'connected', connectionId })
96
+ return unsub
97
+ }
98
+
99
+ /**
100
+ * Flush all pending microtasks and promises.
101
+ */
102
+ export async function flushMicrotasks(): Promise<void> {
103
+ // queueMicrotask runs before setTimeout, but we need to also flush
104
+ // any promises that are scheduled from within those microtasks
105
+ await new Promise((resolve) => setTimeout(resolve, 0))
106
+ }
107
+
108
+ /**
109
+ * Flush all pending promises in the microtask queue.
110
+ */
111
+ export function flushPromises(): Promise<void> {
112
+ return new Promise((resolve) => setTimeout(resolve, 0))
113
+ }
@@ -0,0 +1,262 @@
1
+ import { SubscriptionManager } from '@luxexchange/websocket/src/subscriptions/SubscriptionManager'
2
+ import type {
3
+ ConnectionStatus,
4
+ CreateWebSocketClientOptions,
5
+ SocketFactoryOptions,
6
+ SubscriptionOptions,
7
+ WebSocketClient,
8
+ WebSocketLike,
9
+ } from '@luxexchange/websocket/src/types'
10
+ import { getDefaultJitteredDelay } from '@luxexchange/websocket/src/utils/backoff'
11
+ import { WebSocket as PartySocket } from 'partysocket'
12
+
13
+ /** Default socket factory using PartySocket */
14
+ function defaultSocketFactory(url: string, options: SocketFactoryOptions): WebSocketLike {
15
+ return new PartySocket(url, [], options)
16
+ }
17
+
18
+ /** Default configuration for WebSocket connection behavior */
19
+ const DEFAULT_CONFIG = {
20
+ // Maximum delay (ms) between reconnection attempts
21
+ maxReconnectionDelay: 10000,
22
+ // Minimum delay (ms) between reconnection attempts (before jitter is applied)
23
+ minReconnectionDelay: 1000,
24
+ // Time (ms) to wait for a connection to establish before timing out
25
+ connectionTimeout: 4000,
26
+ // Maximum number of reconnection attempts before giving up
27
+ maxRetries: 5,
28
+ // Enable debug logging for connection events
29
+ debug: false,
30
+ }
31
+
32
+ /**
33
+ * Creates a generic WebSocket client with subscription management.
34
+ *
35
+ * The client handles:
36
+ * - Lazy connection lifecycle (connects on first subscribe, disconnects on last unsubscribe)
37
+ * - Subscription management via SubscriptionManager (reference counting, microtask batching)
38
+ * - Message parsing and routing to appropriate subscribers
39
+ *
40
+ * Consumers provide:
41
+ * - connectionStore: Manages connection state (status, connectionId, errors)
42
+ * - subscriptionHandler: REST API for subscribe/unsubscribe calls
43
+ * - parseMessage: Convert raw WS messages to typed messages
44
+ * - parseConnectionMessage: Extract connectionId from initial message
45
+ * - createSubscriptionKey: Create unique keys for subscriptions
46
+ */
47
+ export function createWebSocketClient<TParams, TMessage>(
48
+ options: CreateWebSocketClientOptions<TParams, TMessage>,
49
+ ): WebSocketClient<TParams, TMessage> {
50
+ const {
51
+ config,
52
+ connectionStore,
53
+ subscriptionHandler,
54
+ parseMessage,
55
+ parseConnectionMessage,
56
+ createSubscriptionKey,
57
+ onError,
58
+ onRawMessage,
59
+ sessionRefreshIntervalMs,
60
+ socketFactory = defaultSocketFactory,
61
+ } = options
62
+
63
+ const {
64
+ url,
65
+ maxReconnectionDelay = DEFAULT_CONFIG.maxReconnectionDelay,
66
+ connectionTimeout = DEFAULT_CONFIG.connectionTimeout,
67
+ maxRetries = DEFAULT_CONFIG.maxRetries,
68
+ debug = DEFAULT_CONFIG.debug,
69
+ } = config
70
+
71
+ // Internal state
72
+ let socket: WebSocketLike | null = null
73
+ const connectionCallbacks = new Set<(connectionId: string) => void>()
74
+ let wasConnected = false
75
+ let sessionRefreshTimer: ReturnType<typeof setInterval> | null = null
76
+
77
+ function startSessionRefreshTimer(): void {
78
+ stopSessionRefreshTimer()
79
+ if (sessionRefreshIntervalMs && subscriptionHandler.refreshSession) {
80
+ sessionRefreshTimer = setInterval(() => {
81
+ subscriptionManager.refreshSession().catch((error) => onError?.(error))
82
+ }, sessionRefreshIntervalMs)
83
+ }
84
+ }
85
+
86
+ function stopSessionRefreshTimer(): void {
87
+ if (sessionRefreshTimer !== null) {
88
+ clearInterval(sessionRefreshTimer)
89
+ sessionRefreshTimer = null
90
+ }
91
+ }
92
+
93
+ // Subscription manager handles all subscription logic
94
+ const subscriptionManager = new SubscriptionManager<TParams, TMessage>({
95
+ handler: subscriptionHandler,
96
+ createKey: createSubscriptionKey,
97
+ onError: (error) => onError?.(error),
98
+ onSubscriptionCountChange: (count): void => {
99
+ if (count > 0 && !socket) {
100
+ connect()
101
+ } else if (count === 0 && socket) {
102
+ disconnect()
103
+ }
104
+ },
105
+ })
106
+
107
+ function notifyStatusChange(status: ConnectionStatus): void {
108
+ connectionStore.setStatus(status)
109
+ }
110
+
111
+ function notifyConnectionEstablished(connectionId: string): void {
112
+ connectionStore.setConnectionId(connectionId)
113
+ for (const callback of connectionCallbacks) {
114
+ callback(connectionId)
115
+ }
116
+ }
117
+
118
+ function connect(): void {
119
+ if (socket) {
120
+ return
121
+ }
122
+
123
+ notifyStatusChange('connecting')
124
+
125
+ // Add jitter to prevent thundering herd on reconnect
126
+ const jitteredMinDelay = getDefaultJitteredDelay()
127
+
128
+ socket = socketFactory(url, {
129
+ maxReconnectionDelay,
130
+ minReconnectionDelay: jitteredMinDelay,
131
+ reconnectionDelayGrowFactor: 1.3,
132
+ connectionTimeout,
133
+ maxRetries,
134
+ debug,
135
+ })
136
+
137
+ // Capture a reference to this socket so event handlers can detect
138
+ // stale events from a previous socket (e.g. during React Strict Mode
139
+ // cleanup/remount cycles where disconnect + reconnect race).
140
+ const thisSocket = socket
141
+
142
+ socket.addEventListener('open', () => {
143
+ if (socket !== thisSocket) {
144
+ return
145
+ }
146
+ wasConnected = true
147
+ startSessionRefreshTimer()
148
+ notifyStatusChange('connected')
149
+ })
150
+
151
+ socket.addEventListener('close', () => {
152
+ if (socket !== thisSocket) {
153
+ return
154
+ }
155
+ stopSessionRefreshTimer()
156
+ // Ignore close events after intentional disconnect (socket already nulled)
157
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
158
+ if (!socket) {
159
+ return
160
+ }
161
+ if (wasConnected) {
162
+ notifyStatusChange('reconnecting')
163
+ } else {
164
+ notifyStatusChange('disconnected')
165
+ }
166
+ })
167
+
168
+ socket.addEventListener('error', () => {
169
+ if (socket !== thisSocket) {
170
+ return
171
+ }
172
+ onError?.(new Error('WebSocket error - check Network tab for details'))
173
+ connectionStore.setError(new Error('WebSocket error'))
174
+ })
175
+
176
+ socket.addEventListener('message', (event) => {
177
+ if (socket !== thisSocket) {
178
+ return
179
+ }
180
+ try {
181
+ const message: unknown = JSON.parse((event as { data: string }).data)
182
+ onRawMessage?.(message)
183
+
184
+ // Check for connection established message
185
+ const connectionInfo = parseConnectionMessage(message)
186
+ if (connectionInfo) {
187
+ const { connectionId } = connectionInfo
188
+ subscriptionManager.setConnectionId(connectionId)
189
+ notifyConnectionEstablished(connectionId)
190
+
191
+ // Always resubscribe if there are active subscriptions
192
+ // Covers both reconnect and initial connect with queued subs
193
+ if (subscriptionManager.hasActiveSubscriptions()) {
194
+ subscriptionManager.resubscribeAll(connectionId).catch((error) => onError?.(error))
195
+ }
196
+ return
197
+ }
198
+
199
+ // Try to parse as a subscription message
200
+ const parsed = parseMessage(message)
201
+ if (parsed) {
202
+ subscriptionManager.dispatch(parsed.key, parsed.data)
203
+ }
204
+ } catch (error) {
205
+ onError?.(error)
206
+ }
207
+ })
208
+ }
209
+
210
+ function disconnect(): void {
211
+ if (socket) {
212
+ stopSessionRefreshTimer()
213
+ const s = socket
214
+ wasConnected = false
215
+ socket = null
216
+ s.close()
217
+ subscriptionManager.clear()
218
+ connectionStore.reset()
219
+ notifyStatusChange('disconnected')
220
+ }
221
+ }
222
+
223
+ function isConnected(): boolean {
224
+ return socket?.readyState === WebSocket.OPEN
225
+ }
226
+
227
+ function getConnectionStatus(): ConnectionStatus {
228
+ return connectionStore.getStatus()
229
+ }
230
+
231
+ function getConnectionId(): string | null {
232
+ return connectionStore.getConnectionId()
233
+ }
234
+
235
+ function subscribe(opts: SubscriptionOptions<TParams, TMessage>): () => void {
236
+ return subscriptionManager.subscribe({
237
+ channel: opts.channel,
238
+ params: opts.params,
239
+ callback: opts.onMessage,
240
+ })
241
+ }
242
+
243
+ function onStatusChange(callback: (status: ConnectionStatus) => void): () => void {
244
+ return connectionStore.onStatusChange(callback)
245
+ }
246
+
247
+ function onConnectionEstablished(callback: (connectionId: string) => void): () => void {
248
+ connectionCallbacks.add(callback)
249
+ return () => {
250
+ connectionCallbacks.delete(callback)
251
+ }
252
+ }
253
+
254
+ return {
255
+ isConnected,
256
+ getConnectionStatus,
257
+ getConnectionId,
258
+ subscribe,
259
+ onStatusChange,
260
+ onConnectionEstablished,
261
+ }
262
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Main factory function
2
+ export { createWebSocketClient } from './client/createWebSocketClient'
3
+ export { createZustandConnectionStore } from './store/createZustandConnectionStore'
4
+ // Store types (for consumers who need to access connection state)
5
+ export type { ConnectionStore, CreateZustandConnectionStoreOptions } from './store/types'
6
+ // Additional exports for advanced use cases
7
+ export { SubscriptionManager } from './subscriptions/SubscriptionManager'
8
+ // Subscription types (for consumers building custom subscription logic)
9
+ export type { SubscribeInput, SubscriptionEntry, SubscriptionManagerOptions } from './subscriptions/types'
10
+ export type {
11
+ ConnectionConfig,
12
+ ConnectionStatus,
13
+ CreateWebSocketClientOptions,
14
+ SocketFactoryOptions,
15
+ SubscriptionHandler,
16
+ SubscriptionOptions,
17
+ WebSocketClient,
18
+ // For testing/mocking
19
+ WebSocketLike,
20
+ } from './types'
21
+ // Utility functions
22
+ export { addJitter, getDefaultJitteredDelay } from './utils/backoff'
@@ -0,0 +1,162 @@
1
+ import { createZustandConnectionStore } from '@luxexchange/websocket/src/store/createZustandConnectionStore'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ describe('createZustandConnectionStore', () => {
5
+ describe('initial state', () => {
6
+ it('starts with disconnected status', () => {
7
+ const store = createZustandConnectionStore()
8
+ expect(store.getStatus()).toBe('disconnected')
9
+ })
10
+
11
+ it('starts with null connectionId', () => {
12
+ const store = createZustandConnectionStore()
13
+ expect(store.getConnectionId()).toBe(null)
14
+ })
15
+
16
+ it('starts with null error', () => {
17
+ const store = createZustandConnectionStore()
18
+ expect(store.getError()).toBe(null)
19
+ })
20
+ })
21
+
22
+ describe('setStatus', () => {
23
+ it('updates status to connecting', () => {
24
+ const store = createZustandConnectionStore()
25
+ store.setStatus('connecting')
26
+ expect(store.getStatus()).toBe('connecting')
27
+ })
28
+
29
+ it('updates status to connected', () => {
30
+ const store = createZustandConnectionStore()
31
+ store.setStatus('connected')
32
+ expect(store.getStatus()).toBe('connected')
33
+ })
34
+
35
+ it('updates status to reconnecting', () => {
36
+ const store = createZustandConnectionStore()
37
+ store.setStatus('reconnecting')
38
+ expect(store.getStatus()).toBe('reconnecting')
39
+ })
40
+
41
+ it('updates status to disconnected', () => {
42
+ const store = createZustandConnectionStore()
43
+ store.setStatus('connected')
44
+ store.setStatus('disconnected')
45
+ expect(store.getStatus()).toBe('disconnected')
46
+ })
47
+ })
48
+
49
+ describe('setConnectionId', () => {
50
+ it('sets connectionId', () => {
51
+ const store = createZustandConnectionStore()
52
+ store.setConnectionId('test-connection-123')
53
+ expect(store.getConnectionId()).toBe('test-connection-123')
54
+ })
55
+
56
+ it('clears connectionId with null', () => {
57
+ const store = createZustandConnectionStore()
58
+ store.setConnectionId('test-connection-123')
59
+ store.setConnectionId(null)
60
+ expect(store.getConnectionId()).toBe(null)
61
+ })
62
+ })
63
+
64
+ describe('setError', () => {
65
+ it('sets error', () => {
66
+ const store = createZustandConnectionStore()
67
+ const error = new Error('Connection failed')
68
+ store.setError(error)
69
+ expect(store.getError()).toBe(error)
70
+ })
71
+
72
+ it('clears error with null', () => {
73
+ const store = createZustandConnectionStore()
74
+ store.setError(new Error('Connection failed'))
75
+ store.setError(null)
76
+ expect(store.getError()).toBe(null)
77
+ })
78
+ })
79
+
80
+ describe('reset', () => {
81
+ it('resets all state to initial values', () => {
82
+ const store = createZustandConnectionStore()
83
+
84
+ // Modify all state
85
+ store.setStatus('connected')
86
+ store.setConnectionId('conn-123')
87
+ store.setError(new Error('test'))
88
+
89
+ // Reset
90
+ store.reset()
91
+
92
+ // Verify all reset
93
+ expect(store.getStatus()).toBe('disconnected')
94
+ expect(store.getConnectionId()).toBe(null)
95
+ expect(store.getError()).toBe(null)
96
+ })
97
+ })
98
+
99
+ describe('onStatusChange', () => {
100
+ it('calls callback when status changes', () => {
101
+ const store = createZustandConnectionStore()
102
+ const callback = vi.fn()
103
+
104
+ store.onStatusChange(callback)
105
+ store.setStatus('connecting')
106
+
107
+ expect(callback).toHaveBeenCalledWith('connecting')
108
+ })
109
+
110
+ it('does not call callback when other state changes', () => {
111
+ const store = createZustandConnectionStore()
112
+ const callback = vi.fn()
113
+
114
+ store.onStatusChange(callback)
115
+ store.setConnectionId('conn-123')
116
+ store.setError(new Error('test'))
117
+
118
+ expect(callback).not.toHaveBeenCalled()
119
+ })
120
+
121
+ it('returns unsubscribe function', () => {
122
+ const store = createZustandConnectionStore()
123
+ const callback = vi.fn()
124
+
125
+ const unsubscribe = store.onStatusChange(callback)
126
+ store.setStatus('connecting')
127
+ expect(callback).toHaveBeenCalledTimes(1)
128
+
129
+ unsubscribe()
130
+ store.setStatus('connected')
131
+ expect(callback).toHaveBeenCalledTimes(1)
132
+ })
133
+
134
+ it('tracks multiple status transitions', () => {
135
+ const store = createZustandConnectionStore()
136
+ const statuses: string[] = []
137
+
138
+ store.onStatusChange((status) => statuses.push(status))
139
+ store.setStatus('connecting')
140
+ store.setStatus('connected')
141
+ store.setStatus('reconnecting')
142
+ store.setStatus('disconnected')
143
+
144
+ expect(statuses).toEqual(['connecting', 'connected', 'reconnecting', 'disconnected'])
145
+ })
146
+ })
147
+
148
+ describe('options', () => {
149
+ it('creates store without devtools by default', () => {
150
+ const store = createZustandConnectionStore()
151
+ expect(store).toBeDefined()
152
+ })
153
+
154
+ it('creates store with custom devtools name', () => {
155
+ const store = createZustandConnectionStore({
156
+ enableDevtools: true,
157
+ devtoolsName: 'customStoreName',
158
+ })
159
+ expect(store).toBeDefined()
160
+ })
161
+ })
162
+ })
@@ -0,0 +1,74 @@
1
+ import type { ConnectionStore, CreateZustandConnectionStoreOptions } from '@luxexchange/websocket/src/store/types'
2
+ import type { ConnectionStatus } from '@luxexchange/websocket/src/types'
3
+ import { devtools } from 'zustand/middleware'
4
+ import { createStore } from 'zustand/vanilla'
5
+
6
+ interface ConnectionStoreState {
7
+ status: ConnectionStatus
8
+ connectionId: string | null
9
+ error: Error | null
10
+ }
11
+
12
+ /**
13
+ * Creates a Zustand-backed ConnectionStore.
14
+ * This is a convenience implementation — consumers can provide any object
15
+ * that satisfies the ConnectionStore interface.
16
+ */
17
+ export function createZustandConnectionStore(options?: CreateZustandConnectionStoreOptions): ConnectionStore {
18
+ const { enableDevtools = false, devtoolsName = 'websocketConnectionStore' } = options ?? {}
19
+
20
+ const store = createStore<ConnectionStoreState>()(
21
+ devtools(
22
+ () => ({
23
+ status: 'disconnected' as ConnectionStatus,
24
+ connectionId: null,
25
+ error: null,
26
+ }),
27
+ {
28
+ name: devtoolsName,
29
+ enabled: enableDevtools,
30
+ },
31
+ ),
32
+ )
33
+
34
+ return {
35
+ getStatus(): ConnectionStatus {
36
+ return store.getState().status
37
+ },
38
+
39
+ setStatus(status: ConnectionStatus): void {
40
+ store.setState({ status }, false, 'setStatus')
41
+ },
42
+
43
+ getConnectionId(): string | null {
44
+ return store.getState().connectionId
45
+ },
46
+
47
+ setConnectionId(id: string | null): void {
48
+ store.setState({ connectionId: id }, false, 'setConnectionId')
49
+ },
50
+
51
+ getError(): Error | null {
52
+ return store.getState().error
53
+ },
54
+
55
+ setError(error: Error | null): void {
56
+ store.setState({ error }, false, 'setError')
57
+ },
58
+
59
+ reset(): void {
60
+ store.setState({ status: 'disconnected', connectionId: null, error: null }, false, 'reset')
61
+ },
62
+
63
+ onStatusChange(callback: (status: ConnectionStatus) => void): () => void {
64
+ let previousStatus = store.getState().status
65
+ return store.subscribe(() => {
66
+ const currentStatus = store.getState().status
67
+ if (currentStatus !== previousStatus) {
68
+ previousStatus = currentStatus
69
+ callback(currentStatus)
70
+ }
71
+ })
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,17 @@
1
+ import type { ConnectionStatus } from '@luxexchange/websocket/src/types'
2
+
3
+ export interface ConnectionStore {
4
+ getStatus(): ConnectionStatus
5
+ setStatus(status: ConnectionStatus): void
6
+ getConnectionId(): string | null
7
+ setConnectionId(id: string | null): void
8
+ getError(): Error | null
9
+ setError(error: Error | null): void
10
+ reset(): void
11
+ onStatusChange(callback: (status: ConnectionStatus) => void): () => void
12
+ }
13
+
14
+ export interface CreateZustandConnectionStoreOptions {
15
+ enableDevtools?: boolean
16
+ devtoolsName?: string
17
+ }