@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.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +54 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +116 -0
  5. package/src/download.test.ts +14 -0
  6. package/src/download.ts +39 -0
  7. package/src/exports.test.ts +105 -0
  8. package/src/form/form.test.tsx +102 -0
  9. package/src/form/form.tsx +85 -0
  10. package/src/form/index.ts +1 -0
  11. package/src/format.test.ts +22 -0
  12. package/src/format.ts +36 -0
  13. package/src/index.ts +4 -0
  14. package/src/query/client.tsx +53 -0
  15. package/src/query/collection.test.tsx +151 -0
  16. package/src/query/collection.ts +192 -0
  17. package/src/query/index.ts +3 -0
  18. package/src/query/repository.ts +30 -0
  19. package/src/resource/client.test.ts +173 -0
  20. package/src/resource/client.ts +307 -0
  21. package/src/resource/document.test.ts +151 -0
  22. package/src/resource/document.ts +198 -0
  23. package/src/resource/idb.test.ts +169 -0
  24. package/src/resource/idb.ts +167 -0
  25. package/src/resource/index.ts +70 -0
  26. package/src/resource/list.test.ts +150 -0
  27. package/src/resource/list.ts +230 -0
  28. package/src/resource/local.test.ts +111 -0
  29. package/src/resource/local.ts +155 -0
  30. package/src/resource/realtime.test.ts +331 -0
  31. package/src/resource/realtime.ts +287 -0
  32. package/src/resource/resource.test.ts +186 -0
  33. package/src/resource/resource.ts +213 -0
  34. package/src/resource/useCall.test.ts +60 -0
  35. package/src/resource/useCall.ts +74 -0
  36. package/src/resource/useDoc.test.ts +31 -0
  37. package/src/resource/useDoc.ts +23 -0
  38. package/src/resource/useDoctype.test.ts +104 -0
  39. package/src/resource/useDoctype.ts +128 -0
  40. package/src/resource/useList.test.ts +32 -0
  41. package/src/resource/useList.ts +22 -0
  42. package/src/resource/useNewDoc.test.ts +67 -0
  43. package/src/resource/useNewDoc.ts +78 -0
  44. package/src/select.test.ts +13 -0
  45. package/src/select.ts +19 -0
  46. package/src/undo.test.ts +24 -0
  47. 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
+ }