@skopon-cool/form-sdk 0.3.1 → 0.4.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 (30) hide show
  1. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
  2. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  3. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  4. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  5. package/dist/index.js +1624 -1382
  6. package/dist/runtime/buildFormRuntimeSnapshot.d.ts +16 -0
  7. package/dist/runtime/buildFormRuntimeSnapshot.d.ts.map +1 -0
  8. package/dist/runtime/formLiveOptionsContext.d.ts +20 -0
  9. package/dist/runtime/formLiveOptionsContext.d.ts.map +1 -0
  10. package/dist/runtime/formRuntimeConnection.d.ts +35 -0
  11. package/dist/runtime/formRuntimeConnection.d.ts.map +1 -0
  12. package/dist/runtime/formRuntimeContext.d.ts +2 -2
  13. package/dist/runtime/formRuntimeContext.d.ts.map +1 -1
  14. package/dist/runtime/formRuntimeWs.d.ts +8 -13
  15. package/dist/runtime/formRuntimeWs.d.ts.map +1 -1
  16. package/dist/runtime/types.d.ts +11 -0
  17. package/dist/runtime/types.d.ts.map +1 -1
  18. package/package.json +1 -1
  19. package/src/catalog/skoponCaseSelect.tsx +12 -1
  20. package/src/catalog/skoponResumeSelect.tsx +12 -1
  21. package/src/catalog/useSkoponBoundField.ts +21 -0
  22. package/src/components/AskUserFormCard.tsx +141 -31
  23. package/src/runtime/buildFormRuntimeSnapshot.test.ts +97 -0
  24. package/src/runtime/buildFormRuntimeSnapshot.ts +61 -0
  25. package/src/runtime/formLiveOptionsContext.tsx +118 -0
  26. package/src/runtime/formRuntimeConnection.test.ts +155 -0
  27. package/src/runtime/formRuntimeConnection.ts +251 -0
  28. package/src/runtime/formRuntimeContext.tsx +13 -11
  29. package/src/runtime/formRuntimeWs.ts +24 -129
  30. package/src/runtime/types.ts +12 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, expect, it } from 'vitest'
5
+ import { buildFormRuntimeSnapshot } from './buildFormRuntimeSnapshot'
6
+ import type { FormBlock } from '../types/index'
7
+
8
+ describe('buildFormRuntimeSnapshot', () => {
9
+ const blocks: FormBlock[] = [
10
+ {
11
+ id: 'b1',
12
+ type: 'text',
13
+ name: 'title',
14
+ label: '标题',
15
+ required: true,
16
+ },
17
+ {
18
+ id: 'b2',
19
+ type: 'select',
20
+ name: 'city',
21
+ label: '城市',
22
+ options: [
23
+ { value: 'sh', label: '上海' },
24
+ { value: 'bj', label: '北京' },
25
+ ],
26
+ },
27
+ {
28
+ id: 'b3',
29
+ type: 'resume_multiselect',
30
+ name: 'resumes',
31
+ label: '简历',
32
+ options: [],
33
+ },
34
+ ]
35
+
36
+ it('builds flat form_values and schema blocks', () => {
37
+ const snapshot = buildFormRuntimeSnapshot({
38
+ title: 'Ask',
39
+ description: 'desc',
40
+ blocks,
41
+ values: {
42
+ title: 'hello',
43
+ city: 'sh',
44
+ resumes: ['r1'],
45
+ unused: undefined,
46
+ },
47
+ formChange: [],
48
+ })
49
+ expect(snapshot.form_schema.title).toBe('Ask')
50
+ expect(snapshot.form_schema.description).toBe('desc')
51
+ expect(snapshot.form_schema.blocks).toHaveLength(3)
52
+ expect(snapshot.form_values).toEqual({
53
+ title: 'hello',
54
+ city: 'sh',
55
+ resumes: ['r1'],
56
+ })
57
+ expect(snapshot.form_change).toEqual([])
58
+ })
59
+
60
+ it('overrides resume/case options from live registry', () => {
61
+ const live = new Map([
62
+ [
63
+ 'resumes',
64
+ [
65
+ { value: 'r1', label: 'Alice' },
66
+ { value: 'r2', label: 'Bob' },
67
+ ],
68
+ ],
69
+ ])
70
+ const snapshot = buildFormRuntimeSnapshot({
71
+ blocks,
72
+ values: { resumes: ['r1'] },
73
+ liveOptions: live,
74
+ formChange: ['resumes'],
75
+ })
76
+ const resumeBlock = snapshot.form_schema.blocks[2] as FormBlock
77
+ expect(resumeBlock.options).toEqual([
78
+ { value: 'r1', label: 'Alice' },
79
+ { value: 'r2', label: 'Bob' },
80
+ ])
81
+ expect(snapshot.form_change).toEqual(['resumes'])
82
+ })
83
+
84
+ it('does not mutate static select options via live map for other types', () => {
85
+ const live = new Map([['city', [{ value: 'gz', label: '广州' }]]])
86
+ const snapshot = buildFormRuntimeSnapshot({
87
+ blocks,
88
+ values: { city: 'sh' },
89
+ liveOptions: live,
90
+ })
91
+ const cityBlock = snapshot.form_schema.blocks[1] as FormBlock
92
+ expect(cityBlock.options).toEqual([
93
+ { value: 'sh', label: '上海' },
94
+ { value: 'bj', label: '北京' },
95
+ ])
96
+ })
97
+ })
@@ -0,0 +1,61 @@
1
+ import type { FormBlock, FormBlockOption } from '../types/index'
2
+ import type { FormRuntimeDataPayload } from './types'
3
+
4
+ export type LiveOptionsMap = ReadonlyMap<string, FormBlockOption[]>
5
+
6
+ function omitUndefinedValues(
7
+ values: Record<string, unknown>,
8
+ ): Record<string, unknown> {
9
+ const out: Record<string, unknown> = {}
10
+ for (const [key, value] of Object.entries(values)) {
11
+ if (value !== undefined) out[key] = value
12
+ }
13
+ return out
14
+ }
15
+
16
+ function applyLiveOptions(
17
+ block: FormBlock,
18
+ liveOptions: LiveOptionsMap | undefined,
19
+ ): FormBlock {
20
+ const name = block.name?.trim()
21
+ if (!name || !liveOptions) return { ...block }
22
+ const live = liveOptions.get(name)
23
+ if (!live) return { ...block }
24
+ if (block.type !== 'resume_multiselect' && block.type !== 'case_multiselect') {
25
+ return { ...block }
26
+ }
27
+ return { ...block, options: live.map((opt) => ({ ...opt })) }
28
+ }
29
+
30
+ export interface BuildFormRuntimeSnapshotInput {
31
+ title?: string
32
+ description?: string
33
+ blocks: FormBlock[]
34
+ values: Record<string, unknown>
35
+ liveOptions?: LiveOptionsMap
36
+ formChange?: string[]
37
+ }
38
+
39
+ /**
40
+ * 构建 form_runtime_data 快照:form_schema(block 格式)+ 扁平 form_values + form_change。
41
+ */
42
+ export function buildFormRuntimeSnapshot(
43
+ input: BuildFormRuntimeSnapshotInput,
44
+ ): FormRuntimeDataPayload {
45
+ const blocks = (input.blocks ?? []).map((block) =>
46
+ applyLiveOptions(block, input.liveOptions),
47
+ )
48
+ const form_schema: FormRuntimeDataPayload['form_schema'] = {
49
+ blocks,
50
+ }
51
+ const title = input.title?.trim()
52
+ const description = input.description?.trim()
53
+ if (title) form_schema.title = title
54
+ if (description) form_schema.description = description
55
+
56
+ return {
57
+ form_schema,
58
+ form_values: omitUndefinedValues(input.values ?? {}),
59
+ form_change: [...(input.formChange ?? [])],
60
+ }
61
+ }
@@ -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
+ }