@skopon-cool/form-sdk 0.3.2 → 0.4.1

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,118 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from 'react'
10
+ import type { FormBlockOption } from '../types/index'
11
+
12
+ export type PublishLiveOptionsFn = (
13
+ fieldName: string,
14
+ options: FormBlockOption[],
15
+ ) => void
16
+
17
+ export interface FormLiveOptionsContextValue {
18
+ publishLiveOptions: PublishLiveOptionsFn
19
+ /** 当前 live options 的不可变快照(供读取)。 */
20
+ liveOptions: ReadonlyMap<string, FormBlockOption[]>
21
+ /** 最近一次签名变化导致的变更字段(debounce 归集前由 Provider 消费后清空)。 */
22
+ pendingChangeFields: readonly string[]
23
+ /** 版本号:live options 有实质变更时递增,用于触发上报 effect。 */
24
+ version: number
25
+ clearPendingChangeFields: () => void
26
+ }
27
+
28
+ const FormLiveOptionsContext = createContext<FormLiveOptionsContextValue | null>(
29
+ null,
30
+ )
31
+
32
+ function optionsSignature(options: FormBlockOption[]): string {
33
+ try {
34
+ return JSON.stringify(options)
35
+ } catch {
36
+ return String(options.length)
37
+ }
38
+ }
39
+
40
+ const noopLiveOptions: FormLiveOptionsContextValue = {
41
+ publishLiveOptions: () => {},
42
+ liveOptions: new Map(),
43
+ pendingChangeFields: [],
44
+ version: 0,
45
+ clearPendingChangeFields: () => {},
46
+ }
47
+
48
+ export interface FormLiveOptionsProviderProps {
49
+ children: ReactNode
50
+ }
51
+
52
+ export function FormLiveOptionsProvider({ children }: FormLiveOptionsProviderProps) {
53
+ const optionsRef = useRef(new Map<string, FormBlockOption[]>())
54
+ const signaturesRef = useRef(new Map<string, string>())
55
+ const pendingRef = useRef<string[]>([])
56
+ const [version, setVersion] = useState(0)
57
+ const [liveOptions, setLiveOptions] = useState<
58
+ ReadonlyMap<string, FormBlockOption[]>
59
+ >(() => new Map())
60
+ const [pendingChangeFields, setPendingChangeFields] = useState<readonly string[]>(
61
+ [],
62
+ )
63
+
64
+ const publishLiveOptions = useCallback(
65
+ (fieldName: string, options: FormBlockOption[]) => {
66
+ const name = fieldName.trim()
67
+ if (!name) return
68
+ const next = options.map((opt) => ({
69
+ value: String(opt.value ?? ''),
70
+ label: String(opt.label ?? opt.value ?? ''),
71
+ }))
72
+ const sig = optionsSignature(next)
73
+ if (signaturesRef.current.get(name) === sig) return
74
+ signaturesRef.current.set(name, sig)
75
+ optionsRef.current.set(name, next)
76
+ if (!pendingRef.current.includes(name)) {
77
+ pendingRef.current = [...pendingRef.current, name]
78
+ }
79
+ setLiveOptions(new Map(optionsRef.current))
80
+ setPendingChangeFields(pendingRef.current)
81
+ setVersion((v) => v + 1)
82
+ },
83
+ [],
84
+ )
85
+
86
+ const clearPendingChangeFields = useCallback(() => {
87
+ pendingRef.current = []
88
+ setPendingChangeFields([])
89
+ }, [])
90
+
91
+ const value = useMemo(
92
+ (): FormLiveOptionsContextValue => ({
93
+ publishLiveOptions,
94
+ liveOptions,
95
+ pendingChangeFields,
96
+ version,
97
+ clearPendingChangeFields,
98
+ }),
99
+ [
100
+ publishLiveOptions,
101
+ liveOptions,
102
+ pendingChangeFields,
103
+ version,
104
+ clearPendingChangeFields,
105
+ ],
106
+ )
107
+
108
+ return (
109
+ <FormLiveOptionsContext.Provider value={value}>
110
+ {children}
111
+ </FormLiveOptionsContext.Provider>
112
+ )
113
+ }
114
+
115
+ /** 无 Provider 时 no-op(编辑器预览不受影响)。 */
116
+ export function useFormLiveOptions(): FormLiveOptionsContextValue {
117
+ return useContext(FormLiveOptionsContext) ?? noopLiveOptions
118
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import {
6
+ acquireFormRuntimeConnection,
7
+ resetFormRuntimeConnectionsForTests,
8
+ } from './formRuntimeConnection'
9
+ import { FormRuntimeWsClient } from './formRuntimeWs'
10
+
11
+ type WsListener = (event?: { data?: unknown }) => void
12
+
13
+ class MockWebSocket {
14
+ static OPEN = 1
15
+ static CONNECTING = 0
16
+ static CLOSING = 2
17
+ static CLOSED = 3
18
+ static instances: MockWebSocket[] = []
19
+
20
+ readyState = MockWebSocket.CONNECTING
21
+ onopen: WsListener | null = null
22
+ onmessage: WsListener | null = null
23
+ onclose: WsListener | null = null
24
+ onerror: WsListener | null = null
25
+ sent: string[] = []
26
+ url: string
27
+
28
+ constructor(url: string) {
29
+ this.url = url
30
+ MockWebSocket.instances.push(this)
31
+ queueMicrotask(() => {
32
+ this.readyState = MockWebSocket.OPEN
33
+ this.onopen?.()
34
+ })
35
+ }
36
+
37
+ send(data: string): void {
38
+ this.sent.push(data)
39
+ }
40
+
41
+ close(): void {
42
+ this.readyState = MockWebSocket.CLOSED
43
+ this.onclose?.()
44
+ }
45
+
46
+ /** 模拟服务端推送。 */
47
+ emitMessage(data: string): void {
48
+ this.onmessage?.({ data })
49
+ }
50
+ }
51
+
52
+ describe('FormRuntimeConnection singleton', () => {
53
+ beforeEach(() => {
54
+ MockWebSocket.instances = []
55
+ vi.stubGlobal('WebSocket', MockWebSocket)
56
+ resetFormRuntimeConnectionsForTests()
57
+ vi.useFakeTimers()
58
+ })
59
+
60
+ afterEach(() => {
61
+ resetFormRuntimeConnectionsForTests()
62
+ vi.useRealTimers()
63
+ vi.unstubAllGlobals()
64
+ })
65
+
66
+ it('reuses one socket per URL across sessions', async () => {
67
+ const a = new FormRuntimeWsClient({
68
+ url: 'ws://test/runtime',
69
+ askUserMessageId: 'flow-a',
70
+ })
71
+ const b = new FormRuntimeWsClient({
72
+ url: 'ws://test/runtime',
73
+ askUserMessageId: 'flow-b',
74
+ })
75
+ a.connect()
76
+ b.connect()
77
+ await vi.runAllTimersAsync()
78
+ expect(MockWebSocket.instances).toHaveLength(1)
79
+ a.close()
80
+ b.close()
81
+ })
82
+
83
+ it('routes inbound auto_control by ask_user_message_id', async () => {
84
+ const onA = vi.fn()
85
+ const onB = vi.fn()
86
+ const a = new FormRuntimeWsClient({
87
+ url: 'ws://test/runtime',
88
+ askUserMessageId: 'id-a',
89
+ onAutoControl: onA,
90
+ })
91
+ const b = new FormRuntimeWsClient({
92
+ url: 'ws://test/runtime',
93
+ askUserMessageId: 'id-b',
94
+ onAutoControl: onB,
95
+ })
96
+ a.connect()
97
+ await vi.runAllTimersAsync()
98
+ const ws = MockWebSocket.instances[0]
99
+ ws.emitMessage(
100
+ JSON.stringify({
101
+ type: 'form_auto_control_event',
102
+ ask_user_message_id: 'id-a',
103
+ data: { action: 'change', field: 'name', value: 'x' },
104
+ }),
105
+ )
106
+ expect(onA).toHaveBeenCalledTimes(1)
107
+ expect(onB).not.toHaveBeenCalled()
108
+ a.close()
109
+ b.close()
110
+ })
111
+
112
+ it('tags outbound messages with ask_user_message_id', async () => {
113
+ const client = new FormRuntimeWsClient({
114
+ url: 'ws://test/runtime',
115
+ askUserMessageId: 'flow-x',
116
+ })
117
+ client.connect()
118
+ await vi.runAllTimersAsync()
119
+ client.send('form_runtime_data', {
120
+ form_schema: { blocks: [] },
121
+ form_values: {},
122
+ form_change: [],
123
+ })
124
+ const ws = MockWebSocket.instances[0]
125
+ expect(ws.sent).toHaveLength(1)
126
+ const parsed = JSON.parse(ws.sent[0]) as {
127
+ type: string
128
+ ask_user_message_id: string
129
+ data: Record<string, unknown>
130
+ }
131
+ expect(parsed.type).toBe('form_runtime_data')
132
+ expect(parsed.ask_user_message_id).toBe('flow-x')
133
+ expect(parsed.data.form_change).toEqual([])
134
+ client.close()
135
+ })
136
+
137
+ it('keeps connection during release grace period', async () => {
138
+ const conn = acquireFormRuntimeConnection('ws://test/grace')
139
+ conn.connect()
140
+ await vi.runAllTimersAsync()
141
+ expect(MockWebSocket.instances).toHaveLength(1)
142
+ conn.release()
143
+ // still within grace
144
+ vi.advanceTimersByTime(500)
145
+ expect(MockWebSocket.instances[0].readyState).toBe(MockWebSocket.OPEN)
146
+ // re-acquire cancels teardown
147
+ const again = acquireFormRuntimeConnection('ws://test/grace')
148
+ expect(again).toBe(conn)
149
+ vi.advanceTimersByTime(2000)
150
+ expect(MockWebSocket.instances[0].readyState).toBe(MockWebSocket.OPEN)
151
+ again.release()
152
+ vi.advanceTimersByTime(1600)
153
+ expect(MockWebSocket.instances[0].readyState).toBe(MockWebSocket.CLOSED)
154
+ })
155
+ })
@@ -0,0 +1,251 @@
1
+ import type {
2
+ FormAutoControlEventData,
3
+ FormRuntimeEnvelope,
4
+ FormRuntimeOutboundType,
5
+ } from './types'
6
+
7
+ const SEND_QUEUE_MAX = 100
8
+ const RECONNECT_MAX_MS = 30_000
9
+ const RELEASE_GRACE_MS = 1_500
10
+
11
+ export type FormAutoControlHandler = (
12
+ data: FormAutoControlEventData,
13
+ envelope: FormRuntimeEnvelope<FormAutoControlEventData>,
14
+ ) => void
15
+
16
+ const connections = new Map<string, FormRuntimeConnection>()
17
+
18
+ /** 测试用:清空所有单例连接。 */
19
+ export function resetFormRuntimeConnectionsForTests(): void {
20
+ for (const conn of connections.values()) {
21
+ conn.forceClose()
22
+ }
23
+ connections.clear()
24
+ }
25
+
26
+ export function acquireFormRuntimeConnection(url: string): FormRuntimeConnection {
27
+ const trimmed = url.trim()
28
+ let conn = connections.get(trimmed)
29
+ if (!conn) {
30
+ conn = new FormRuntimeConnection(trimmed)
31
+ connections.set(trimmed, conn)
32
+ }
33
+ conn.acquire()
34
+ return conn
35
+ }
36
+
37
+ export class FormRuntimeConnection {
38
+ private readonly url: string
39
+ private ws: WebSocket | null = null
40
+ private refCount = 0
41
+ private connectRequested = false
42
+ private reconnectAttempt = 0
43
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
44
+ private releaseTimer: ReturnType<typeof setTimeout> | null = null
45
+ private sendQueue: string[] = []
46
+ private readonly handlers = new Map<string, Set<FormAutoControlHandler>>()
47
+
48
+ constructor(url: string) {
49
+ this.url = url
50
+ }
51
+
52
+ acquire(): void {
53
+ this.refCount += 1
54
+ this.clearReleaseTimer()
55
+ }
56
+
57
+ release(): void {
58
+ this.refCount = Math.max(0, this.refCount - 1)
59
+ if (this.refCount > 0) return
60
+ this.clearReleaseTimer()
61
+ this.releaseTimer = setTimeout(() => {
62
+ this.releaseTimer = null
63
+ if (this.refCount > 0) return
64
+ this.teardown()
65
+ if (connections.get(this.url) === this) {
66
+ connections.delete(this.url)
67
+ }
68
+ }, RELEASE_GRACE_MS)
69
+ }
70
+
71
+ /** 强制关闭(测试用),跳过宽限期。 */
72
+ forceClose(): void {
73
+ this.refCount = 0
74
+ this.teardown()
75
+ if (connections.get(this.url) === this) {
76
+ connections.delete(this.url)
77
+ }
78
+ }
79
+
80
+ subscribe(askUserMessageId: string, handler: FormAutoControlHandler): () => void {
81
+ const id = askUserMessageId.trim()
82
+ if (!id) return () => {}
83
+ let set = this.handlers.get(id)
84
+ if (!set) {
85
+ set = new Set()
86
+ this.handlers.set(id, set)
87
+ }
88
+ set.add(handler)
89
+ return () => {
90
+ const current = this.handlers.get(id)
91
+ if (!current) return
92
+ current.delete(handler)
93
+ if (current.size === 0) this.handlers.delete(id)
94
+ }
95
+ }
96
+
97
+ /** 懒建连:首个会话 connect() 时才真正打开 socket。 */
98
+ connect(): void {
99
+ if (typeof WebSocket === 'undefined') return
100
+ this.connectRequested = true
101
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
102
+ return
103
+ }
104
+ this.openSocket()
105
+ }
106
+
107
+ send(
108
+ askUserMessageId: string,
109
+ type: FormRuntimeOutboundType,
110
+ data: Record<string, unknown>,
111
+ ): void {
112
+ if (this.refCount <= 0 && !this.connectRequested) return
113
+ const payload: FormRuntimeEnvelope = {
114
+ type,
115
+ ask_user_message_id: askUserMessageId,
116
+ data,
117
+ }
118
+ let serialized: string
119
+ try {
120
+ serialized = JSON.stringify(payload)
121
+ } catch (error) {
122
+ console.warn('[FormRuntimeConnection] serialize failed', error)
123
+ return
124
+ }
125
+ if (this.ws?.readyState === WebSocket.OPEN) {
126
+ this.ws.send(serialized)
127
+ return
128
+ }
129
+ this.enqueue(serialized)
130
+ }
131
+
132
+ private openSocket(): void {
133
+ if (!this.connectRequested || this.refCount <= 0) return
134
+ this.clearReconnectTimer()
135
+ this.disposeSocket()
136
+ try {
137
+ const ws = new WebSocket(this.url)
138
+ this.ws = ws
139
+ ws.onopen = () => {
140
+ this.reconnectAttempt = 0
141
+ this.flushQueue()
142
+ }
143
+ ws.onmessage = (event) => {
144
+ this.handleMessage(event.data)
145
+ }
146
+ ws.onclose = () => {
147
+ if (this.ws === ws) this.ws = null
148
+ this.scheduleReconnect()
149
+ }
150
+ ws.onerror = () => {
151
+ // onclose will follow
152
+ }
153
+ } catch (error) {
154
+ console.warn('[FormRuntimeConnection] connect failed', error)
155
+ this.scheduleReconnect()
156
+ }
157
+ }
158
+
159
+ private teardown(): void {
160
+ this.connectRequested = false
161
+ this.clearReconnectTimer()
162
+ this.clearReleaseTimer()
163
+ this.sendQueue = []
164
+ this.handlers.clear()
165
+ this.disposeSocket()
166
+ }
167
+
168
+ private disposeSocket(): void {
169
+ if (!this.ws) return
170
+ this.ws.onopen = null
171
+ this.ws.onmessage = null
172
+ this.ws.onclose = null
173
+ this.ws.onerror = null
174
+ try {
175
+ this.ws.close()
176
+ } catch {
177
+ // ignore
178
+ }
179
+ this.ws = null
180
+ }
181
+
182
+ private enqueue(serialized: string): void {
183
+ this.sendQueue.push(serialized)
184
+ while (this.sendQueue.length > SEND_QUEUE_MAX) {
185
+ this.sendQueue.shift()
186
+ }
187
+ }
188
+
189
+ private flushQueue(): void {
190
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
191
+ const pending = this.sendQueue
192
+ this.sendQueue = []
193
+ for (const item of pending) {
194
+ this.ws.send(item)
195
+ }
196
+ }
197
+
198
+ private scheduleReconnect(): void {
199
+ if (!this.connectRequested || this.refCount <= 0) return
200
+ this.clearReconnectTimer()
201
+ const delay = Math.min(
202
+ RECONNECT_MAX_MS,
203
+ 1000 * 2 ** Math.min(this.reconnectAttempt, 5),
204
+ )
205
+ this.reconnectAttempt += 1
206
+ this.reconnectTimer = setTimeout(() => {
207
+ this.reconnectTimer = null
208
+ this.openSocket()
209
+ }, delay)
210
+ }
211
+
212
+ private clearReconnectTimer(): void {
213
+ if (this.reconnectTimer != null) {
214
+ clearTimeout(this.reconnectTimer)
215
+ this.reconnectTimer = null
216
+ }
217
+ }
218
+
219
+ private clearReleaseTimer(): void {
220
+ if (this.releaseTimer != null) {
221
+ clearTimeout(this.releaseTimer)
222
+ this.releaseTimer = null
223
+ }
224
+ }
225
+
226
+ private handleMessage(raw: unknown): void {
227
+ if (typeof raw !== 'string') return
228
+ let parsed: unknown
229
+ try {
230
+ parsed = JSON.parse(raw)
231
+ } catch {
232
+ return
233
+ }
234
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return
235
+ const envelope = parsed as FormRuntimeEnvelope<FormAutoControlEventData>
236
+ if (envelope.type !== 'form_auto_control_event') return
237
+ const id = String(envelope.ask_user_message_id ?? '').trim()
238
+ if (!id) return
239
+ const data = envelope.data
240
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return
241
+ const set = this.handlers.get(id)
242
+ if (!set || set.size === 0) return
243
+ for (const handler of [...set]) {
244
+ try {
245
+ handler(data, envelope)
246
+ } catch (error) {
247
+ console.warn('[FormRuntimeConnection] handler error', error)
248
+ }
249
+ }
250
+ }
251
+ }
@@ -7,11 +7,11 @@ import {
7
7
  type ReactNode,
8
8
  } from 'react'
9
9
  import type { FormRuntimeWsClient } from './formRuntimeWs'
10
- import type { FormRuntimeEventData } from './types'
10
+ import type { FormRuntimeDataPayload, FormRuntimeEventData } from './types'
11
11
 
12
12
  export interface FormRuntimeContextValue {
13
13
  reportEvent: (event: FormRuntimeEventData) => void
14
- reportData: (values: Record<string, unknown>) => void
14
+ reportData: (payload: FormRuntimeDataPayload) => void
15
15
  /**
16
16
  * 在自动控上下文中执行 fn。若 fn 返回 Promise,depth 保持到 settle,
17
17
  * 使异步 submit 内的 reportEvent 仍为 form_auto_control_bac_event。
@@ -37,14 +37,16 @@ const noopRuntime: FormRuntimeContextValue = {
37
37
  isAutoControl: () => false,
38
38
  }
39
39
 
40
- function omitUndefinedValues(
41
- values: Record<string, unknown>,
42
- ): Record<string, unknown> {
43
- const out: Record<string, unknown> = {}
44
- for (const [key, value] of Object.entries(values)) {
45
- if (value !== undefined) out[key] = value
40
+ function sanitizePayload(payload: FormRuntimeDataPayload): FormRuntimeDataPayload {
41
+ const form_values: Record<string, unknown> = {}
42
+ for (const [key, value] of Object.entries(payload.form_values ?? {})) {
43
+ if (value !== undefined) form_values[key] = value
44
+ }
45
+ return {
46
+ form_schema: payload.form_schema,
47
+ form_values,
48
+ form_change: Array.isArray(payload.form_change) ? [...payload.form_change] : [],
46
49
  }
47
- return out
48
50
  }
49
51
 
50
52
  export interface FormRuntimeProviderProps {
@@ -76,10 +78,10 @@ export function FormRuntimeProvider({
76
78
  ws.send(type, data)
77
79
  }, [])
78
80
 
79
- const reportData = useCallback((values: Record<string, unknown>) => {
81
+ const reportData = useCallback((payload: FormRuntimeDataPayload) => {
80
82
  const ws = clientRef.current
81
83
  if (!ws) return
82
- ws.send('form_runtime_data', omitUndefinedValues(values))
84
+ ws.send('form_runtime_data', sanitizePayload(payload) as unknown as Record<string, unknown>)
83
85
  }, [])
84
86
 
85
87
  const runAutoControl = useCallback(<T,>(fn: () => T): T => {