@talelk/newui-data 0.0.0-stage → 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.
- package/LICENSE +202 -0
- package/README.md +54 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +116 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +105 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +85 -0
- package/src/form/index.ts +1 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +4 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +151 -0
- package/src/query/collection.ts +192 -0
- package/src/query/index.ts +3 -0
- package/src/query/repository.ts +30 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/document.test.ts +151 -0
- package/src/resource/document.ts +198 -0
- package/src/resource/idb.test.ts +169 -0
- package/src/resource/idb.ts +167 -0
- package/src/resource/index.ts +70 -0
- package/src/resource/list.test.ts +150 -0
- package/src/resource/list.ts +230 -0
- package/src/resource/local.test.ts +111 -0
- package/src/resource/local.ts +155 -0
- package/src/resource/realtime.test.ts +331 -0
- package/src/resource/realtime.ts +287 -0
- package/src/resource/resource.test.ts +186 -0
- package/src/resource/resource.ts +213 -0
- package/src/resource/useCall.test.ts +60 -0
- package/src/resource/useCall.ts +74 -0
- package/src/resource/useDoc.test.ts +31 -0
- package/src/resource/useDoc.ts +23 -0
- package/src/resource/useDoctype.test.ts +104 -0
- package/src/resource/useDoctype.ts +128 -0
- package/src/resource/useList.test.ts +32 -0
- package/src/resource/useList.ts +22 -0
- package/src/resource/useNewDoc.test.ts +67 -0
- package/src/resource/useNewDoc.ts +78 -0
- package/src/select.test.ts +13 -0
- package/src/select.ts +19 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createRealtime,
|
|
4
|
+
type RealtimeListener,
|
|
5
|
+
type RealtimeSocket,
|
|
6
|
+
type RealtimeSocketFactory,
|
|
7
|
+
} from './realtime'
|
|
8
|
+
|
|
9
|
+
/** A minimal socket: records listeners, exposes a manual `fire`. */
|
|
10
|
+
function makeSocket() {
|
|
11
|
+
const handlers = new Map<string, Set<RealtimeListener>>()
|
|
12
|
+
const sent: unknown[] = []
|
|
13
|
+
let closed = false
|
|
14
|
+
|
|
15
|
+
const socket: RealtimeSocket = {
|
|
16
|
+
on(event, listener) {
|
|
17
|
+
const set = handlers.get(event) ?? new Set<RealtimeListener>()
|
|
18
|
+
set.add(listener)
|
|
19
|
+
handlers.set(event, set)
|
|
20
|
+
},
|
|
21
|
+
off(event, listener) {
|
|
22
|
+
handlers.get(event)?.delete(listener)
|
|
23
|
+
},
|
|
24
|
+
close() {
|
|
25
|
+
closed = true
|
|
26
|
+
},
|
|
27
|
+
send(data) {
|
|
28
|
+
sent.push(data)
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
socket,
|
|
34
|
+
fire(event: string, ...args: unknown[]) {
|
|
35
|
+
for (const listener of handlers.get(event) ?? []) listener(...args)
|
|
36
|
+
},
|
|
37
|
+
count(event: string) {
|
|
38
|
+
return handlers.get(event)?.size ?? 0
|
|
39
|
+
},
|
|
40
|
+
sent,
|
|
41
|
+
get closed() {
|
|
42
|
+
return closed
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Fake = ReturnType<typeof makeSocket>
|
|
48
|
+
|
|
49
|
+
function makeHarness() {
|
|
50
|
+
const sockets: Fake[] = []
|
|
51
|
+
const factory: RealtimeSocketFactory = () => {
|
|
52
|
+
const next = makeSocket()
|
|
53
|
+
sockets.push(next)
|
|
54
|
+
return next.socket
|
|
55
|
+
}
|
|
56
|
+
return { sockets, factory }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function socketAt(sockets: Fake[], index = 0): Fake {
|
|
60
|
+
const socket = sockets[index]
|
|
61
|
+
if (!socket) throw new Error(`No socket at index ${index}`)
|
|
62
|
+
return socket
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.useRealTimers()
|
|
67
|
+
vi.unstubAllGlobals()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('createRealtime', () => {
|
|
71
|
+
it('connects on creation and tracks the connected signal', () => {
|
|
72
|
+
const { sockets, factory } = makeHarness()
|
|
73
|
+
const realtime = createRealtime({
|
|
74
|
+
url: 'ws://x',
|
|
75
|
+
factory,
|
|
76
|
+
reconnect: false,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(sockets).toHaveLength(1)
|
|
80
|
+
expect(realtime.connected()).toBe(false)
|
|
81
|
+
|
|
82
|
+
socketAt(sockets).fire('open')
|
|
83
|
+
expect(realtime.connected()).toBe(true)
|
|
84
|
+
|
|
85
|
+
socketAt(sockets).fire('close')
|
|
86
|
+
expect(realtime.connected()).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('accepts socket.io connect/disconnect lifecycle events', () => {
|
|
90
|
+
const { sockets, factory } = makeHarness()
|
|
91
|
+
const realtime = createRealtime({
|
|
92
|
+
url: 'ws://x',
|
|
93
|
+
factory,
|
|
94
|
+
reconnect: false,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
socketAt(sockets).fire('connect')
|
|
98
|
+
expect(realtime.connected()).toBe(true)
|
|
99
|
+
|
|
100
|
+
socketAt(sockets).fire('disconnect', 'io server disconnect')
|
|
101
|
+
expect(realtime.connected()).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('reflects a socket that is already connected', () => {
|
|
105
|
+
const socket: RealtimeSocket = {
|
|
106
|
+
connected: true,
|
|
107
|
+
on() {},
|
|
108
|
+
off() {},
|
|
109
|
+
close() {},
|
|
110
|
+
send() {},
|
|
111
|
+
}
|
|
112
|
+
const realtime = createRealtime({
|
|
113
|
+
url: 'ws://x',
|
|
114
|
+
factory: () => socket,
|
|
115
|
+
reconnect: false,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(realtime.connected()).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('delivers named events and unsubscribes', () => {
|
|
122
|
+
const { sockets, factory } = makeHarness()
|
|
123
|
+
const realtime = createRealtime<{ list_update: { name: string } }>({
|
|
124
|
+
url: 'ws://x',
|
|
125
|
+
factory,
|
|
126
|
+
reconnect: false,
|
|
127
|
+
})
|
|
128
|
+
const received: string[] = []
|
|
129
|
+
const unsubscribe = realtime.on('list_update', (data) =>
|
|
130
|
+
received.push(data.name),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
socketAt(sockets).fire('list_update', { name: 'T-1' })
|
|
134
|
+
expect(received).toEqual(['T-1'])
|
|
135
|
+
expect(socketAt(sockets).count('list_update')).toBe(1)
|
|
136
|
+
|
|
137
|
+
unsubscribe()
|
|
138
|
+
expect(socketAt(sockets).count('list_update')).toBe(0)
|
|
139
|
+
socketAt(sockets).fire('list_update', { name: 'T-2' })
|
|
140
|
+
expect(received).toEqual(['T-1'])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('off() removes a listener registered with on()', () => {
|
|
144
|
+
const { sockets, factory } = makeHarness()
|
|
145
|
+
const realtime = createRealtime({
|
|
146
|
+
url: 'ws://x',
|
|
147
|
+
factory,
|
|
148
|
+
reconnect: false,
|
|
149
|
+
})
|
|
150
|
+
const listener = vi.fn()
|
|
151
|
+
|
|
152
|
+
realtime.on('evt', listener)
|
|
153
|
+
realtime.off('evt', listener)
|
|
154
|
+
socketAt(sockets).fire('evt', 1)
|
|
155
|
+
|
|
156
|
+
expect(listener).not.toHaveBeenCalled()
|
|
157
|
+
expect(socketAt(sockets).count('evt')).toBe(0)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('replays subscriptions registered before connecting', () => {
|
|
161
|
+
const { sockets, factory } = makeHarness()
|
|
162
|
+
const realtime = createRealtime({
|
|
163
|
+
url: 'ws://x',
|
|
164
|
+
factory,
|
|
165
|
+
auto: false,
|
|
166
|
+
reconnect: false,
|
|
167
|
+
})
|
|
168
|
+
const listener = vi.fn()
|
|
169
|
+
realtime.on('evt', listener)
|
|
170
|
+
|
|
171
|
+
expect(sockets).toHaveLength(0)
|
|
172
|
+
realtime.connect()
|
|
173
|
+
expect(sockets).toHaveLength(1)
|
|
174
|
+
expect(socketAt(sockets).count('evt')).toBe(1)
|
|
175
|
+
|
|
176
|
+
socketAt(sockets).fire('evt', 42)
|
|
177
|
+
expect(listener).toHaveBeenCalledWith(42)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('forwards send to the socket', () => {
|
|
181
|
+
const { sockets, factory } = makeHarness()
|
|
182
|
+
const realtime = createRealtime({
|
|
183
|
+
url: 'ws://x',
|
|
184
|
+
factory,
|
|
185
|
+
reconnect: false,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
realtime.send({ ping: 1 })
|
|
189
|
+
expect(socketAt(sockets).sent).toEqual([{ ping: 1 }])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('reports lifecycle callbacks', () => {
|
|
193
|
+
const { sockets, factory } = makeHarness()
|
|
194
|
+
const onOpen = vi.fn()
|
|
195
|
+
const onClose = vi.fn()
|
|
196
|
+
const onError = vi.fn()
|
|
197
|
+
createRealtime({
|
|
198
|
+
url: 'ws://x',
|
|
199
|
+
factory,
|
|
200
|
+
reconnect: false,
|
|
201
|
+
onOpen,
|
|
202
|
+
onClose,
|
|
203
|
+
onError,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
socketAt(sockets).fire('open')
|
|
207
|
+
socketAt(sockets).fire('error', 'boom')
|
|
208
|
+
socketAt(sockets).fire('close', 'bye')
|
|
209
|
+
|
|
210
|
+
expect(onOpen).toHaveBeenCalledOnce()
|
|
211
|
+
expect(onError).toHaveBeenCalledWith('boom')
|
|
212
|
+
expect(onClose).toHaveBeenCalledWith('bye')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('reconnects after an unexpected close, with backoff', () => {
|
|
216
|
+
vi.useFakeTimers()
|
|
217
|
+
const { sockets, factory } = makeHarness()
|
|
218
|
+
const realtime = createRealtime({
|
|
219
|
+
url: 'ws://x',
|
|
220
|
+
factory,
|
|
221
|
+
reconnectDelay: 100,
|
|
222
|
+
maxReconnectDelay: 100,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
expect(sockets).toHaveLength(1)
|
|
226
|
+
socketAt(sockets).fire('open')
|
|
227
|
+
socketAt(sockets).fire('close')
|
|
228
|
+
expect(realtime.connected()).toBe(false)
|
|
229
|
+
|
|
230
|
+
vi.advanceTimersByTime(99)
|
|
231
|
+
expect(sockets).toHaveLength(1)
|
|
232
|
+
vi.advanceTimersByTime(1)
|
|
233
|
+
expect(sockets).toHaveLength(2)
|
|
234
|
+
|
|
235
|
+
// A new connection is replayed the current subscriptions.
|
|
236
|
+
const listener = vi.fn()
|
|
237
|
+
realtime.on('evt', listener)
|
|
238
|
+
socketAt(sockets, 1).fire('evt', 'again')
|
|
239
|
+
expect(listener).toHaveBeenCalledWith('again')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('close() stops reconnecting and closes the socket', () => {
|
|
243
|
+
vi.useFakeTimers()
|
|
244
|
+
const { sockets, factory } = makeHarness()
|
|
245
|
+
const realtime = createRealtime({
|
|
246
|
+
url: 'ws://x',
|
|
247
|
+
factory,
|
|
248
|
+
reconnectDelay: 100,
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
socketAt(sockets).fire('open')
|
|
252
|
+
realtime.close()
|
|
253
|
+
expect(socketAt(sockets).closed).toBe(true)
|
|
254
|
+
expect(realtime.connected()).toBe(false)
|
|
255
|
+
|
|
256
|
+
// A late close event must not reopen a socket the caller closed.
|
|
257
|
+
socketAt(sockets).fire('close')
|
|
258
|
+
vi.advanceTimersByTime(1000)
|
|
259
|
+
expect(sockets).toHaveLength(1)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('is a no-op without a WebSocket or factory (SSR-safe)', () => {
|
|
263
|
+
vi.stubGlobal('WebSocket', undefined)
|
|
264
|
+
const realtime = createRealtime({ url: 'ws://x' })
|
|
265
|
+
|
|
266
|
+
expect(realtime.connected()).toBe(false)
|
|
267
|
+
expect(() => realtime.connect()).not.toThrow()
|
|
268
|
+
expect(() => realtime.send('x')).not.toThrow()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('adapts the native WebSocket by default', () => {
|
|
272
|
+
class FakeWebSocket {
|
|
273
|
+
static OPEN = 1
|
|
274
|
+
static instances: FakeWebSocket[] = []
|
|
275
|
+
readyState = 0
|
|
276
|
+
listeners = new Map<string, Set<EventListener>>()
|
|
277
|
+
sent: unknown[] = []
|
|
278
|
+
|
|
279
|
+
constructor(
|
|
280
|
+
readonly url: string,
|
|
281
|
+
readonly protocols?: string | string[],
|
|
282
|
+
) {
|
|
283
|
+
FakeWebSocket.instances.push(this)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
addEventListener(type: string, listener: EventListener) {
|
|
287
|
+
const set = this.listeners.get(type) ?? new Set<EventListener>()
|
|
288
|
+
set.add(listener)
|
|
289
|
+
this.listeners.set(type, set)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
removeEventListener(type: string, listener: EventListener) {
|
|
293
|
+
this.listeners.get(type)?.delete(listener)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
close() {
|
|
297
|
+
this.readyState = 3
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
send(data: unknown) {
|
|
301
|
+
this.sent.push(data)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
emit(type: string, event: unknown) {
|
|
305
|
+
for (const listener of this.listeners.get(type) ?? []) {
|
|
306
|
+
listener(event as Event)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
vi.stubGlobal('WebSocket', FakeWebSocket)
|
|
312
|
+
const realtime = createRealtime({ url: 'ws://x', reconnect: false })
|
|
313
|
+
const ws = FakeWebSocket.instances[0]
|
|
314
|
+
if (!ws) throw new Error('WebSocket was not constructed')
|
|
315
|
+
|
|
316
|
+
expect(ws.url).toBe('ws://x')
|
|
317
|
+
ws.emit('open', new Event('open'))
|
|
318
|
+
expect(realtime.connected()).toBe(true)
|
|
319
|
+
|
|
320
|
+
realtime.send({ a: 1 })
|
|
321
|
+
expect(ws.sent).toEqual(['{"a":1}'])
|
|
322
|
+
|
|
323
|
+
const received: unknown[] = []
|
|
324
|
+
realtime.on('message', (data) => received.push(data))
|
|
325
|
+
ws.emit('message', { data: 'hello' } as MessageEvent)
|
|
326
|
+
expect(received).toEqual(['hello'])
|
|
327
|
+
|
|
328
|
+
realtime.close()
|
|
329
|
+
expect(ws.readyState).toBe(3)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A realtime transport over a `WebSocket`.
|
|
5
|
+
*
|
|
6
|
+
* This is a **plain-`WebSocket`** helper, not a socket.io client: it opens one
|
|
7
|
+
* socket to a configurable URL and multiplexes subscriptions over it through
|
|
8
|
+
* `on`/`off`/`close`. The socket is created by an injectable
|
|
9
|
+
* {@link RealtimeSocketFactory}, so a consumer who already uses socket.io can
|
|
10
|
+
* pass `io(url)` as the factory — a socket.io `Socket` satisfies
|
|
11
|
+
* {@link RealtimeSocket} — and get named events for free. The wire protocol is
|
|
12
|
+
* deliberately *not* implemented here.
|
|
13
|
+
*
|
|
14
|
+
* The default factory adapts the native `WebSocket` to the same surface: it
|
|
15
|
+
* forwards the `open`/`close`/`error` lifecycle events and delivers each
|
|
16
|
+
* message as a single `message` event carrying the raw `event.data`. With the
|
|
17
|
+
* native transport there is no event multiplexing on the wire, so named
|
|
18
|
+
* subscriptions only fire under a transport that provides them (socket.io).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A listener registered with {@link Realtime.on}. */
|
|
22
|
+
export type RealtimeListener = (...args: unknown[]) => void
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The minimal socket surface the realtime helper drives. A native `WebSocket`
|
|
26
|
+
* is adapted to it by the default factory; a `socket.io-client` `Socket`
|
|
27
|
+
* already satisfies it, so it can be passed through
|
|
28
|
+
* {@link RealtimeOptions.factory} directly.
|
|
29
|
+
*/
|
|
30
|
+
export interface RealtimeSocket {
|
|
31
|
+
on(event: string, listener: RealtimeListener): unknown
|
|
32
|
+
off(event: string, listener: RealtimeListener): unknown
|
|
33
|
+
close(): void
|
|
34
|
+
send?(data: unknown): void
|
|
35
|
+
/** Whether the socket is currently connected (socket.io exposes this). */
|
|
36
|
+
readonly connected?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build the transport for a URL. See {@link RealtimeOptions.factory}. */
|
|
40
|
+
export type RealtimeSocketFactory = (
|
|
41
|
+
url: string,
|
|
42
|
+
protocols?: string | string[],
|
|
43
|
+
) => RealtimeSocket
|
|
44
|
+
|
|
45
|
+
/** The map of event name to payload, for a typed {@link Realtime}. */
|
|
46
|
+
export interface RealtimeEventMap {
|
|
47
|
+
[event: string]: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options for {@link createRealtime}. */
|
|
51
|
+
export interface RealtimeOptions {
|
|
52
|
+
/** WebSocket URL, or an accessor for a reactive URL. */
|
|
53
|
+
url: string | (() => string)
|
|
54
|
+
/** Subprotocols passed to the native `WebSocket` constructor. */
|
|
55
|
+
protocols?: string | string[]
|
|
56
|
+
/**
|
|
57
|
+
* Socket factory. Defaults to a native `WebSocket` adapter. Pass a
|
|
58
|
+
* socket.io client factory (`(url) => io(url)`) to reuse that transport.
|
|
59
|
+
*/
|
|
60
|
+
factory?: RealtimeSocketFactory
|
|
61
|
+
/** Connect on creation. Defaults to `true`. */
|
|
62
|
+
auto?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* Reconnect after an unexpected close, with exponential backoff. Defaults to
|
|
65
|
+
* `true`. Set `false` when the injected transport (socket.io) reconnects on
|
|
66
|
+
* its own.
|
|
67
|
+
*/
|
|
68
|
+
reconnect?: boolean
|
|
69
|
+
/** First reconnect delay in ms. Defaults to `1000`. */
|
|
70
|
+
reconnectDelay?: number
|
|
71
|
+
/** Cap on the reconnect delay in ms. Defaults to `30000`. */
|
|
72
|
+
maxReconnectDelay?: number
|
|
73
|
+
onOpen?: () => void
|
|
74
|
+
onClose?: (event?: unknown) => void
|
|
75
|
+
onError?: (event?: unknown) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The signal-based realtime handle {@link createRealtime} returns. */
|
|
79
|
+
export interface Realtime<TEvents extends RealtimeEventMap = RealtimeEventMap> {
|
|
80
|
+
/** Whether the socket is currently connected. */
|
|
81
|
+
connected: Accessor<boolean>
|
|
82
|
+
/** Open the connection. Idempotent while already open. */
|
|
83
|
+
connect(): void
|
|
84
|
+
/** Subscribe to an event. Returns an unsubscribe function. */
|
|
85
|
+
on<K extends keyof TEvents & string>(
|
|
86
|
+
event: K,
|
|
87
|
+
listener: (data: TEvents[K]) => void,
|
|
88
|
+
): () => void
|
|
89
|
+
/** Unsubscribe a listener previously registered with `on`. */
|
|
90
|
+
off(event: string, listener: RealtimeListener): void
|
|
91
|
+
/** Send a payload; the native transport JSON-encodes non-strings. */
|
|
92
|
+
send(data: unknown): void
|
|
93
|
+
/** Close the connection and stop reconnecting. */
|
|
94
|
+
close(): void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_RECONNECT_DELAY = 1000
|
|
98
|
+
const DEFAULT_MAX_RECONNECT_DELAY = 30_000
|
|
99
|
+
|
|
100
|
+
/** Adapt a native `WebSocket` to {@link RealtimeSocket}. */
|
|
101
|
+
function nativeFactory(
|
|
102
|
+
url: string,
|
|
103
|
+
protocols?: string | string[],
|
|
104
|
+
): RealtimeSocket {
|
|
105
|
+
const socket = protocols ? new WebSocket(url, protocols) : new WebSocket(url)
|
|
106
|
+
const messageHandlers = new Map<
|
|
107
|
+
RealtimeListener,
|
|
108
|
+
(event: MessageEvent) => void
|
|
109
|
+
>()
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
on(event, listener) {
|
|
113
|
+
if (event === 'message') {
|
|
114
|
+
const handler = (message: MessageEvent) => listener(message.data)
|
|
115
|
+
messageHandlers.set(listener, handler)
|
|
116
|
+
socket.addEventListener('message', handler)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
socket.addEventListener(
|
|
120
|
+
event as keyof WebSocketEventMap,
|
|
121
|
+
listener as EventListener,
|
|
122
|
+
)
|
|
123
|
+
},
|
|
124
|
+
off(event, listener) {
|
|
125
|
+
if (event === 'message') {
|
|
126
|
+
const handler = messageHandlers.get(listener)
|
|
127
|
+
if (handler) {
|
|
128
|
+
socket.removeEventListener('message', handler)
|
|
129
|
+
messageHandlers.delete(listener)
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
socket.removeEventListener(
|
|
134
|
+
event as keyof WebSocketEventMap,
|
|
135
|
+
listener as EventListener,
|
|
136
|
+
)
|
|
137
|
+
},
|
|
138
|
+
close() {
|
|
139
|
+
socket.close()
|
|
140
|
+
},
|
|
141
|
+
send(data) {
|
|
142
|
+
socket.send(typeof data === 'string' ? data : JSON.stringify(data))
|
|
143
|
+
},
|
|
144
|
+
get connected() {
|
|
145
|
+
return socket.readyState === WebSocket.OPEN
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The default factory, or `undefined` when there is no browser `WebSocket`. */
|
|
151
|
+
function defaultFactory(): RealtimeSocketFactory | undefined {
|
|
152
|
+
if (typeof window === 'undefined' || typeof WebSocket === 'undefined') {
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
return nativeFactory
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Open a WebSocket and expose typed `on`/`off`/`close` over it.
|
|
160
|
+
*
|
|
161
|
+
* `connected` is a signal. Subscriptions registered before the socket exists
|
|
162
|
+
* are replayed onto every (re)connection, so a consumer can call `on` in any
|
|
163
|
+
* order. Lifecycle events are accepted in both dialects — `open`/`close` for a
|
|
164
|
+
* native `WebSocket`, `connect`/`disconnect` for socket.io.
|
|
165
|
+
*/
|
|
166
|
+
export function createRealtime<
|
|
167
|
+
TEvents extends RealtimeEventMap = RealtimeEventMap,
|
|
168
|
+
>(options: RealtimeOptions): Realtime<TEvents> {
|
|
169
|
+
const [connected, setConnected] = createSignal(false)
|
|
170
|
+
const listeners = new Map<string, Set<RealtimeListener>>()
|
|
171
|
+
const reconnectEnabled = options.reconnect !== false
|
|
172
|
+
const baseDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY
|
|
173
|
+
const maxDelay = options.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY
|
|
174
|
+
|
|
175
|
+
let socket: RealtimeSocket | null = null
|
|
176
|
+
let stopped = false
|
|
177
|
+
let attempt = 0
|
|
178
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
179
|
+
|
|
180
|
+
const currentUrl = (): string =>
|
|
181
|
+
typeof options.url === 'function' ? options.url() : options.url
|
|
182
|
+
|
|
183
|
+
function handleOpen(): void {
|
|
184
|
+
attempt = 0
|
|
185
|
+
setConnected(true)
|
|
186
|
+
options.onOpen?.()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleClose(event?: unknown): void {
|
|
190
|
+
setConnected(false)
|
|
191
|
+
options.onClose?.(event)
|
|
192
|
+
socket = null
|
|
193
|
+
if (!stopped && reconnectEnabled) scheduleReconnect()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function handleError(event?: unknown): void {
|
|
197
|
+
options.onError?.(event)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function scheduleReconnect(): void {
|
|
201
|
+
if (timer) return
|
|
202
|
+
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
|
|
203
|
+
attempt += 1
|
|
204
|
+
timer = setTimeout(() => {
|
|
205
|
+
timer = null
|
|
206
|
+
open()
|
|
207
|
+
}, delay)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function attach(target: RealtimeSocket): void {
|
|
211
|
+
const isCurrent = (): boolean => socket === target
|
|
212
|
+
target.on('open', () => {
|
|
213
|
+
if (isCurrent()) handleOpen()
|
|
214
|
+
})
|
|
215
|
+
target.on('connect', () => {
|
|
216
|
+
if (isCurrent()) handleOpen()
|
|
217
|
+
})
|
|
218
|
+
target.on('close', (event) => {
|
|
219
|
+
if (isCurrent()) handleClose(event)
|
|
220
|
+
})
|
|
221
|
+
target.on('disconnect', (event) => {
|
|
222
|
+
if (isCurrent()) handleClose(event)
|
|
223
|
+
})
|
|
224
|
+
target.on('error', (event) => {
|
|
225
|
+
if (isCurrent()) handleError(event)
|
|
226
|
+
})
|
|
227
|
+
for (const [event, set] of listeners) {
|
|
228
|
+
for (const listener of set) target.on(event, listener)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function open(): void {
|
|
233
|
+
if (socket || stopped) return
|
|
234
|
+
const create = options.factory ?? defaultFactory()
|
|
235
|
+
if (!create) return
|
|
236
|
+
const target = create(currentUrl(), options.protocols)
|
|
237
|
+
socket = target
|
|
238
|
+
attach(target)
|
|
239
|
+
// A socket that is already connected (e.g. socket.io with `autoConnect`)
|
|
240
|
+
// does not re-fire `connect`, so reflect its state now.
|
|
241
|
+
if (target.connected) handleOpen()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function connect(): void {
|
|
245
|
+
stopped = false
|
|
246
|
+
open()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function on<K extends keyof TEvents & string>(
|
|
250
|
+
event: K,
|
|
251
|
+
listener: (data: TEvents[K]) => void,
|
|
252
|
+
): () => void {
|
|
253
|
+
const wrapped = listener as RealtimeListener
|
|
254
|
+
const set = listeners.get(event) ?? new Set<RealtimeListener>()
|
|
255
|
+
set.add(wrapped)
|
|
256
|
+
listeners.set(event, set)
|
|
257
|
+
socket?.on(event, wrapped)
|
|
258
|
+
return () => off(event, wrapped)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function off(event: string, listener: RealtimeListener): void {
|
|
262
|
+
listeners.get(event)?.delete(listener)
|
|
263
|
+
socket?.off(event, listener)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function send(data: unknown): void {
|
|
267
|
+
socket?.send?.(data)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function close(): void {
|
|
271
|
+
stopped = true
|
|
272
|
+
if (timer) {
|
|
273
|
+
clearTimeout(timer)
|
|
274
|
+
timer = null
|
|
275
|
+
}
|
|
276
|
+
const target = socket
|
|
277
|
+
// `handleClose` only runs for the current socket, so call it before the
|
|
278
|
+
// socket is detached; the transport's own close event is then ignored.
|
|
279
|
+
handleClose()
|
|
280
|
+
target?.close()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Auto-connect on creation. `connect` is a plain action, not a signal read.
|
|
284
|
+
if (options.auto !== false) connect()
|
|
285
|
+
|
|
286
|
+
return { connected, connect, on, off, send, close }
|
|
287
|
+
}
|