Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / src / chain.ts
web/src/chain.ts168 lines · 4737 bytes
1// Block stream: WS eth_subscribe newHeads; while the WS is down, poll eth_blockNumber over HTTP and reconnect the WS
2// in the background. Offline mode never starts it.
3import { CONFIG } from './config'
4import { CHAIN } from './runtime'
5
6export type Link = 'ws' | 'poll' | 'down' | 'offline'
7
8export class BlockStream {
9 latest = 0
10 /** Date.now() when the latest header arrived (drives the per-second cooldown clock between blocks) */
11 lastAt = 0
12 link: Link = 'down'
13 linkUrl = ''
14 private arrivals: { n: number; t: number }[] = []
15 private listeners = new Set<() => void>()
16 private ws: WebSocket | null = null
17 private wsIdx = 0
18 private httpIdx = 0
19 private pollTimer: ReturnType<typeof setInterval> | null = null
20 private stopped = false
21
22 onChange(fn: () => void) {
23 this.listeners.add(fn)
24 }
25
26 private emit() {
27 for (const fn of this.listeners) fn()
28 }
29
30 /** One block header (WS or poll) */
31 ingest(n: number) {
32 if (!Number.isFinite(n) || n <= 0 || n <= this.latest) return
33 this.latest = n
34 this.lastAt = Date.now()
35 this.arrivals.push({ n, t: performance.now() })
36 if (this.arrivals.length > CONFIG.blockTimeWindow) this.arrivals.shift()
37 this.emit()
38 }
39
40 /** Measured average block time (ms, by arrival); null with too few samples */
41 blockTimeMs(): number | null {
42 const a = this.arrivals
43 if (a.length < 6) return null
44 const dn = a[a.length - 1].n - a[0].n
45 return dn > 0 ? (a[a.length - 1].t - a[0].t) / dn : null
46 }
47
48 start() {
49 this.openWs()
50 }
51
52 /** Offline playback: no connection; the head is the recording's last block */
53 setOffline(head: number) {
54 this.stopped = true
55 this.link = 'offline'
56 this.latest = head
57 this.emit()
58 }
59
60 private openWs() {
61 if (this.stopped) return
62 if (CHAIN.ws.length === 0) {
63 this.startPoll()
64 return
65 }
66 const url = CHAIN.ws[this.wsIdx % CHAIN.ws.length]
67 let ws: WebSocket
68 try {
69 ws = new WebSocket(url)
70 } catch {
71 this.wsFailed()
72 return
73 }
74 this.ws = ws
75 ws.onopen = () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_subscribe', params: ['newHeads'] }))
76 ws.onmessage = (ev) => {
77 let msg: { id?: number; result?: unknown; error?: unknown; params?: { result?: { number?: string } } }
78 try {
79 msg = JSON.parse(String(ev.data))
80 } catch {
81 return
82 }
83 if (msg.id === 1) {
84 if (msg.error) return ws.close()
85 this.link = 'ws'
86 this.linkUrl = url
87 this.stopPoll()
88 this.emit()
89 // 12 s blocks: ask for the current number once instead of waiting for the next header
90 if (!this.latest && CHAIN.http.length)
91 void rpc<string>(CHAIN.http[0], 'eth_blockNumber', [])
92 .then((n) => this.ingest(parseInt(n, 16)))
93 .catch(() => {
94 /* the next header will do */
95 })
96 return
97 }
98 const n = msg.params?.result?.number
99 if (n) this.ingest(parseInt(n, 16))
100 }
101 ws.onclose = () => {
102 if (this.ws === ws) this.ws = null
103 this.wsFailed()
104 }
105 ws.onerror = () => {
106 /* onclose follows */
107 }
108 }
109
110 private wsFailed() {
111 if (this.stopped) return
112 this.wsIdx++
113 this.startPoll()
114 setTimeout(() => this.openWs(), 2000)
115 }
116
117 private startPoll() {
118 if (this.pollTimer !== null || CHAIN.http.length === 0) return
119 this.link = 'poll'
120 this.emit()
121 void this.pollOnce()
122 this.pollTimer = setInterval(() => void this.pollOnce(), CHAIN.pollMs)
123 }
124
125 private stopPoll() {
126 if (this.pollTimer !== null) {
127 clearInterval(this.pollTimer)
128 this.pollTimer = null
129 }
130 }
131
132 private async pollOnce() {
133 const url = CHAIN.http[this.httpIdx % CHAIN.http.length]
134 try {
135 const n = await rpc<string>(url, 'eth_blockNumber', [])
136 this.linkUrl = url
137 this.ingest(parseInt(n, 16))
138 } catch {
139 this.httpIdx++
140 if (this.link !== 'ws') {
141 this.link = 'down'
142 this.emit()
143 }
144 }
145 }
146}
147
148let rpcId = 100
149export async function rpc<T>(url: string, method: string, params: unknown[], timeoutMs = 3000): Promise<T> {
150 const ctl = new AbortController()
151 const t = setTimeout(() => ctl.abort(), timeoutMs)
152 try {
153 const r = await fetch(url, {
154 method: 'POST',
155 headers: { 'content-type': 'application/json' },
156 body: JSON.stringify({ jsonrpc: '2.0', id: rpcId++, method, params }),
157 signal: ctl.signal,
158 })
159 if (!r.ok) throw new Error(`HTTP ${r.status}`)
160 const j = await r.json()
161 if (j.error) throw new Error(j.error.message ?? 'rpc error')
162 return j.result as T
163 } finally {
164 clearTimeout(t)
165 }
166}
167
168export const blocks = new BlockStream()