@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.
- package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
- package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/index.js +1386 -1173
- package/dist/runtime/buildFormRuntimeSnapshot.d.ts +16 -0
- package/dist/runtime/buildFormRuntimeSnapshot.d.ts.map +1 -0
- package/dist/runtime/formLiveOptionsContext.d.ts +20 -0
- package/dist/runtime/formLiveOptionsContext.d.ts.map +1 -0
- package/dist/runtime/formRuntimeConnection.d.ts +35 -0
- package/dist/runtime/formRuntimeConnection.d.ts.map +1 -0
- package/dist/runtime/formRuntimeContext.d.ts +2 -2
- package/dist/runtime/formRuntimeContext.d.ts.map +1 -1
- package/dist/runtime/formRuntimeWs.d.ts +8 -15
- package/dist/runtime/formRuntimeWs.d.ts.map +1 -1
- package/dist/runtime/types.d.ts +11 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/catalog/skoponCaseSelect.tsx +12 -1
- package/src/catalog/skoponResumeSelect.tsx +12 -1
- package/src/components/AskUserFormCard.tsx +112 -13
- package/src/runtime/buildFormRuntimeSnapshot.test.ts +97 -0
- package/src/runtime/buildFormRuntimeSnapshot.ts +61 -0
- package/src/runtime/formLiveOptionsContext.tsx +118 -0
- package/src/runtime/formRuntimeConnection.test.ts +155 -0
- package/src/runtime/formRuntimeConnection.ts +251 -0
- package/src/runtime/formRuntimeContext.tsx +13 -11
- package/src/runtime/formRuntimeWs.ts +24 -134
- package/src/runtime/types.ts +12 -0
|
@@ -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: (
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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((
|
|
81
|
+
const reportData = useCallback((payload: FormRuntimeDataPayload) => {
|
|
80
82
|
const ws = clientRef.current
|
|
81
83
|
if (!ws) return
|
|
82
|
-
ws.send('form_runtime_data',
|
|
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 => {
|