@talelk/newui-data 0.0.0-stage → 1.1.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 +65 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +170 -0
- package/src/aggregate.test.ts +231 -0
- package/src/aggregate.ts +320 -0
- package/src/config-store.test.ts +157 -0
- package/src/config-store.ts +295 -0
- package/src/csv-export.test.ts +156 -0
- package/src/csv-export.ts +129 -0
- package/src/dashboard.test.tsx +285 -0
- package/src/dashboard.tsx +381 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +135 -0
- package/src/form/autosave.test.ts +186 -0
- package/src/form/autosave.ts +194 -0
- package/src/form/config.test.tsx +288 -0
- package/src/form/config.ts +187 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +205 -0
- package/src/form/index.ts +3 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +10 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +278 -0
- package/src/query/collection.ts +207 -0
- package/src/query/index.ts +4 -0
- package/src/query/repository.ts +30 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -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 +77 -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/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- package/src/url-state.ts +168 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createLocalStore } from './local'
|
|
3
|
+
|
|
4
|
+
// jsdom provides a working `localStorage` (vitest sets an http origin), so the
|
|
5
|
+
// default backend is exercised for real; the memory fallback is tested by
|
|
6
|
+
// passing `storage: null` and by hiding the global.
|
|
7
|
+
|
|
8
|
+
let prefix = 0
|
|
9
|
+
function nextPrefix(): string {
|
|
10
|
+
prefix += 1
|
|
11
|
+
return `test-${prefix}:`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
localStorage.clear()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.unstubAllGlobals()
|
|
20
|
+
localStorage.clear()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('createLocalStore', () => {
|
|
24
|
+
it('round-trips JSON values through localStorage', () => {
|
|
25
|
+
const store = createLocalStore({ prefix: nextPrefix() })
|
|
26
|
+
|
|
27
|
+
store.set('doc', { name: 'T-1', tags: ['a', 'b'] })
|
|
28
|
+
store.set('count', 3)
|
|
29
|
+
store.set('flag', false)
|
|
30
|
+
|
|
31
|
+
expect(store.persistent).toBe(true)
|
|
32
|
+
expect(store.get('doc')).toEqual({ name: 'T-1', tags: ['a', 'b'] })
|
|
33
|
+
expect(store.get('count')).toBe(3)
|
|
34
|
+
expect(store.get('flag')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns null for a missing or unparseable entry', () => {
|
|
38
|
+
const store = createLocalStore<unknown>({ prefix: 'broken:' })
|
|
39
|
+
expect(store.get('nope')).toBe(null)
|
|
40
|
+
|
|
41
|
+
// A value written outside the JSON contract must not throw on read.
|
|
42
|
+
localStorage.setItem('broken:x', 'not json')
|
|
43
|
+
expect(store.get('x')).toBe(null)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('scopes keys, delete, has, keys and clear to its prefix', () => {
|
|
47
|
+
const store = createLocalStore({ prefix: 'mine:' })
|
|
48
|
+
const other = createLocalStore({ prefix: 'other:' })
|
|
49
|
+
|
|
50
|
+
store.set('a', 1)
|
|
51
|
+
store.set('b', 2)
|
|
52
|
+
other.set('a', 9)
|
|
53
|
+
|
|
54
|
+
expect(store.has('a')).toBe(true)
|
|
55
|
+
expect(store.keys().sort()).toEqual(['a', 'b'])
|
|
56
|
+
expect(other.keys()).toEqual(['a'])
|
|
57
|
+
|
|
58
|
+
store.delete('a')
|
|
59
|
+
expect(store.has('a')).toBe(false)
|
|
60
|
+
expect(other.get('a')).toBe(9)
|
|
61
|
+
|
|
62
|
+
store.clear()
|
|
63
|
+
expect(store.keys()).toEqual([])
|
|
64
|
+
expect(other.get('a')).toBe(9)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('persists across store instances that share a prefix', () => {
|
|
68
|
+
const first = createLocalStore<{ v: number }>({ prefix: 'shared:' })
|
|
69
|
+
first.set('k', { v: 1 })
|
|
70
|
+
|
|
71
|
+
const second = createLocalStore<{ v: number }>({ prefix: 'shared:' })
|
|
72
|
+
expect(second.get('k')).toEqual({ v: 1 })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('falls back to memory when storage is explicitly null', () => {
|
|
76
|
+
const store = createLocalStore({ prefix: nextPrefix(), storage: null })
|
|
77
|
+
|
|
78
|
+
expect(store.persistent).toBe(false)
|
|
79
|
+
store.set('k', 'v')
|
|
80
|
+
expect(store.get('k')).toBe('v')
|
|
81
|
+
expect(store.has('k')).toBe(true)
|
|
82
|
+
expect(store.keys()).toEqual(['k'])
|
|
83
|
+
store.clear()
|
|
84
|
+
expect(store.keys()).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('falls back to memory when localStorage is unavailable (SSR)', () => {
|
|
88
|
+
vi.stubGlobal('localStorage', undefined)
|
|
89
|
+
const store = createLocalStore({ prefix: nextPrefix() })
|
|
90
|
+
|
|
91
|
+
expect(store.persistent).toBe(false)
|
|
92
|
+
store.set('k', { v: 1 })
|
|
93
|
+
expect(store.get('k')).toEqual({ v: 1 })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('drops writes when the backing storage throws', () => {
|
|
97
|
+
const throwing = {
|
|
98
|
+
length: 0,
|
|
99
|
+
key: () => null,
|
|
100
|
+
getItem: () => null,
|
|
101
|
+
setItem: () => {
|
|
102
|
+
throw new Error('QuotaExceededError')
|
|
103
|
+
},
|
|
104
|
+
removeItem: () => {},
|
|
105
|
+
} as unknown as Storage
|
|
106
|
+
const store = createLocalStore({ prefix: nextPrefix(), storage: throwing })
|
|
107
|
+
|
|
108
|
+
expect(() => store.set('k', 1)).not.toThrow()
|
|
109
|
+
expect(store.get('k')).toBe(null)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `localStorage`-backed key/value store for caching resource data.
|
|
3
|
+
*
|
|
4
|
+
* The synchronous sibling of {@link createIdbStore}: values are JSON-encoded, so
|
|
5
|
+
* anything `JSON.parse(JSON.stringify(value))` round-trips is safe to cache.
|
|
6
|
+
* Every call is guarded for SSR — with no `localStorage` the store silently
|
|
7
|
+
* falls back to an in-memory `Map`, so importing and using it on the server
|
|
8
|
+
* never throws. It is a plain API (no Solid reactivity): a cache is not a view.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The storage backend a {@link LocalStore} reads and writes. */
|
|
12
|
+
type LocalStoreBackend = Storage | null
|
|
13
|
+
|
|
14
|
+
/** A synchronous, JSON, key/value cache. */
|
|
15
|
+
export interface LocalStore<T = unknown> {
|
|
16
|
+
/** The stored value, or `null` when the key is absent or not JSON. */
|
|
17
|
+
get(key: string): T | null
|
|
18
|
+
/** JSON-encode and persist a value. */
|
|
19
|
+
set(key: string, value: T): void
|
|
20
|
+
/** Remove a key. */
|
|
21
|
+
delete(key: string): void
|
|
22
|
+
/** Whether the key exists. */
|
|
23
|
+
has(key: string): boolean
|
|
24
|
+
/** Every key owned by this store, with the prefix stripped. */
|
|
25
|
+
keys(): string[]
|
|
26
|
+
/** Remove every key owned by this store. */
|
|
27
|
+
clear(): void
|
|
28
|
+
/** False when the store fell back to memory (SSR, or storage disabled). */
|
|
29
|
+
readonly persistent: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Options for {@link createLocalStore}. */
|
|
33
|
+
export interface LocalStoreOptions {
|
|
34
|
+
/** Key prefix, so several stores can share one `localStorage`. */
|
|
35
|
+
prefix?: string
|
|
36
|
+
/** Storage backend. Defaults to the global `localStorage`. */
|
|
37
|
+
storage?: LocalStoreBackend
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultStorage(): LocalStoreBackend {
|
|
41
|
+
try {
|
|
42
|
+
return typeof localStorage === 'undefined' ? null : localStorage
|
|
43
|
+
} catch {
|
|
44
|
+
// A sandboxed or disabled `localStorage` throws on access, not on use.
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a synchronous cache over `localStorage`.
|
|
51
|
+
*
|
|
52
|
+
* Keys are namespaced by `prefix` (default `newui:`), so a store owns only the
|
|
53
|
+
* entries it wrote: `keys()` and `clear()` never touch another store's data.
|
|
54
|
+
* A storage that throws (quota exceeded, disabled) degrades to a no-op write
|
|
55
|
+
* rather than failing the caller.
|
|
56
|
+
*/
|
|
57
|
+
export function createLocalStore<T = unknown>(
|
|
58
|
+
options: LocalStoreOptions = {},
|
|
59
|
+
): LocalStore<T> {
|
|
60
|
+
const prefix = options.prefix ?? 'newui:'
|
|
61
|
+
const storage =
|
|
62
|
+
options.storage === undefined ? defaultStorage() : options.storage
|
|
63
|
+
const memory = new Map<string, string>()
|
|
64
|
+
|
|
65
|
+
const scoped = (key: string): string => `${prefix}${key}`
|
|
66
|
+
|
|
67
|
+
function get(key: string): T | null {
|
|
68
|
+
let raw: string | null
|
|
69
|
+
if (storage) {
|
|
70
|
+
try {
|
|
71
|
+
raw = storage.getItem(scoped(key))
|
|
72
|
+
} catch {
|
|
73
|
+
raw = null
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
raw = memory.get(key) ?? null
|
|
77
|
+
}
|
|
78
|
+
if (raw === null) return null
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(raw) as T
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function set(key: string, value: T): void {
|
|
87
|
+
const raw = JSON.stringify(value)
|
|
88
|
+
if (storage) {
|
|
89
|
+
try {
|
|
90
|
+
storage.setItem(scoped(key), raw)
|
|
91
|
+
} catch {
|
|
92
|
+
// Quota or disabled storage: the write is dropped, not fatal.
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
memory.set(key, raw)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function remove(key: string): void {
|
|
100
|
+
if (storage) {
|
|
101
|
+
try {
|
|
102
|
+
storage.removeItem(scoped(key))
|
|
103
|
+
} catch {
|
|
104
|
+
// Nothing to do — the entry is already unreachable.
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
memory.delete(key)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function has(key: string): boolean {
|
|
112
|
+
if (storage) {
|
|
113
|
+
try {
|
|
114
|
+
return storage.getItem(scoped(key)) !== null
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return memory.has(key)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function keys(): string[] {
|
|
123
|
+
if (!storage) return [...memory.keys()]
|
|
124
|
+
const owned: string[] = []
|
|
125
|
+
try {
|
|
126
|
+
for (let index = 0; index < storage.length; index++) {
|
|
127
|
+
const key = storage.key(index)
|
|
128
|
+
if (key?.startsWith(prefix)) {
|
|
129
|
+
owned.push(key.slice(prefix.length))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
return []
|
|
134
|
+
}
|
|
135
|
+
return owned
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function clear(): void {
|
|
139
|
+
if (!storage) {
|
|
140
|
+
memory.clear()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
for (const key of keys()) remove(key)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
get,
|
|
148
|
+
set,
|
|
149
|
+
delete: remove,
|
|
150
|
+
has,
|
|
151
|
+
keys,
|
|
152
|
+
clear,
|
|
153
|
+
persistent: storage !== null,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -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
|
+
})
|