File size: 3,476 Bytes
5db48a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f4cfcb
5db48a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f4cfcb
 
 
 
 
 
 
 
5db48a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f4cfcb
5db48a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// round-12 F2 — useRotateStream
// 包装 EventSource 订阅 /api/rotate/stream,把后端 default_machine 的 transition
// 事件以 reactive ref 暴露给组件 (TaskPanel 实时进度面板)。
//
// 行为契约:
// - onMounted 自动 connect (除非传 { immediate: false });onUnmounted 自动 disconnect
// - events: 最近 N=50 条转移记录,数组首条 = 最新 (UI 直接 v-for 即可倒序)
// - isConnected: 反映 EventSource.readyState === OPEN
// - 重连失败 5 次后停止 (EventSource 浏览器内置自动重连;我们额外做计数兜底,
//   防止后端 5xx 死循环)
// - 通过 onTransition(cb) 注册外部副作用 (例如 vue-query invalidate)
//
// 后端 SSE schema (api.py _build_sse_event_stream):
//   { email, from, to, reason, ts, extra }

import { onMounted, onUnmounted, ref } from 'vue'
import { getApiKey } from '../api.js'

const DEFAULT_URL = '/api/rotate/stream'
const MAX_EVENTS = 50
const MAX_FAILURES = 5

export function useRotateStream(opts = {}) {
  const url = opts.url || DEFAULT_URL
  const max = opts.maxEvents ?? MAX_EVENTS
  const immediate = opts.immediate !== false

  const events = ref([])
  const isConnected = ref(false)
  const lastError = ref(null)

  let source = null
  let failureCount = 0
  let stoppedByQuota = false
  const transitionCallbacks = new Set()

  function urlWithAuthKey() {
    const key = getApiKey()
    if (!key) return url
    const target = new URL(url, window.location.origin)
    target.searchParams.set('key', key)
    return `${target.pathname}${target.search}`
  }

  function _push(payload) {
    // 首条 = 最新;超过 max 截尾
    events.value = [payload, ...events.value].slice(0, max)
    for (const cb of transitionCallbacks) {
      try {
        cb(payload)
      } catch (e) {
        // 不让一个 callback 阻塞其他订阅者
        console.warn('[useRotateStream] transition callback raised', e)
      }
    }
  }

  function connect() {
    if (source || stoppedByQuota) return
    try {
      source = new EventSource(urlWithAuthKey())
    } catch (e) {
      console.warn('[useRotateStream] EventSource ctor failed', e)
      lastError.value = e
      return
    }
    source.onopen = () => {
      isConnected.value = true
      failureCount = 0
    }
    source.onmessage = (ev) => {
      try {
        const data = JSON.parse(ev.data)
        _push(data)
      } catch (e) {
        console.warn('[useRotateStream] bad SSE payload', ev.data, e)
      }
    }
    source.onerror = (e) => {
      isConnected.value = false
      lastError.value = e
      failureCount += 1
      if (failureCount >= MAX_FAILURES) {
        console.warn(
          `[useRotateStream] giving up after ${MAX_FAILURES} reconnect failures`,
        )
        stoppedByQuota = true
        disconnect()
      }
      // 否则浏览器 EventSource 会自动重连,不要手动 close
    }
  }

  function disconnect() {
    if (source) {
      try {
        source.close()
      } catch (_) { /* noop */ }
      source = null
    }
    isConnected.value = false
  }

  function onTransition(cb) {
    transitionCallbacks.add(cb)
    return () => transitionCallbacks.delete(cb)
  }

  function reset() {
    events.value = []
    failureCount = 0
    stoppedByQuota = false
  }

  if (immediate) {
    onMounted(connect)
    onUnmounted(disconnect)
  }

  return {
    events,
    isConnected,
    lastError,
    connect,
    disconnect,
    reset,
    onTransition,
  }
}