Skip to content

Commit 62e53ea

Browse files
committed
feat: implement WebSocket management with a dedicated worker and update packing logic
1 parent e44e97d commit 62e53ea

File tree

9 files changed

+382
-25
lines changed

9 files changed

+382
-25
lines changed

frontend/scripts/pack.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import path from 'path'
55
import AdmZip from 'adm-zip'
66

77
const root = path.resolve(process.cwd())
8-
const distIndex = path.join(root, 'dist', 'index.html')
9-
const outZipFrontend = path.join(root, 'dist', 'frontend.zip')
8+
const distDir = path.join(root, 'dist')
9+
const distIndex = path.join(distDir, 'index.html')
10+
const outZipFrontend = path.join(distDir, 'frontend.zip')
1011
const resourcesZip = path.resolve(root, '..', 'src', 'paradoc', 'resources', 'frontend.zip')
1112

1213
function ensureDir(p) {
@@ -22,9 +23,22 @@ function main() {
2223
process.exit(1)
2324
}
2425

25-
// Create zip with only index.html (renamed to index.html inside zip)
26+
// Create zip with everything from dist (excluding the zip itself), so workers/assets are preserved
2627
const zip = new AdmZip()
27-
zip.addLocalFile(distIndex, '', 'index.html')
28+
29+
// Add all files under dist, preserving paths
30+
const entries = fs.readdirSync(distDir)
31+
for (const entry of entries) {
32+
const full = path.join(distDir, entry)
33+
if (full === outZipFrontend) continue
34+
const stat = fs.statSync(full)
35+
if (stat.isFile()) {
36+
zip.addLocalFile(full, '')
37+
} else if (stat.isDirectory()) {
38+
zip.addLocalFolder(full, entry)
39+
}
40+
}
41+
2842
zip.writeZip(outZipFrontend)
2943
console.log(`[pack] Wrote ${outZipFrontend}`)
3044

frontend/src/App.tsx

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
22
import { Topbar } from './components/Topbar'
33
import { Navbar, TocItem } from './components/Navbar'
44

5-
const WS_URL = 'ws://localhost:13579'
5+
// WebSocket management is delegated to a Web Worker that reconnects and forwards messages
6+
// Vite will inline the worker into the single-file build
7+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
8+
// @ts-ignore
9+
const WorkerCtor = (URL as any) ? (url: string) => new Worker(new URL(url, import.meta.url), { type: 'module' }) : null
610

711
const MOCK_HTML = `
812
<main>
@@ -19,7 +23,7 @@ const MOCK_HTML = `
1923
export default function App() {
2024
const [connected, setConnected] = useState<boolean>(false)
2125
const [docHtml, setDocHtml] = useState<string>(MOCK_HTML)
22-
const wsRef = useRef<WebSocket | null>(null)
26+
const workerRef = useRef<Worker | null>(null)
2327

2428
// Parse HTML into a Document for TOC extraction
2529
const doc = useMemo(() => {
@@ -79,28 +83,35 @@ export default function App() {
7983
}, [doc])
8084

8185
useEffect(() => {
82-
const ws = new WebSocket(WS_URL)
83-
wsRef.current = ws
84-
ws.addEventListener('open', () => setConnected(true))
85-
ws.addEventListener('close', () => setConnected(false))
86-
ws.addEventListener('error', () => setConnected(false))
87-
88-
ws.addEventListener('message', (event) => {
89-
const data = typeof event.data === 'string' ? event.data : ''
90-
if (data.trim()) {
91-
setDocHtml(data)
86+
// Start the WS worker (runs in a dedicated thread)
87+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
88+
// @ts-ignore
89+
const worker: Worker = new Worker(new URL('./ws/worker.ts', import.meta.url), { type: 'module' })
90+
workerRef.current = worker
91+
92+
const onMessage = (event: MessageEvent) => {
93+
const msg = event.data
94+
if (!msg) return
95+
if (msg.type === 'status') {
96+
setConnected(!!msg.connected)
97+
} else if (msg.type === 'html' && typeof msg.html === 'string') {
98+
if (msg.html.trim()) setDocHtml(msg.html)
9299
}
93-
})
100+
}
101+
102+
worker.addEventListener('message', onMessage)
94103

95104
return () => {
96-
ws.close()
97-
wsRef.current = null
105+
try { worker.postMessage({ type: 'stop' }) } catch {}
106+
worker.removeEventListener('message', onMessage)
107+
worker.terminate()
108+
workerRef.current = null
98109
}
99110
}, [])
100111

101112
const sendHtml = (html: string) => {
102-
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
103-
wsRef.current.send(html)
113+
if (workerRef.current) {
114+
workerRef.current.postMessage({ type: 'send', html })
104115
}
105116
}
106117

frontend/src/main.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@ import React from 'react'
22
import { createRoot } from 'react-dom/client'
33
import App from './App'
44
import './styles.css'
5-
import { initWsListener } from './ws/listener'
6-
7-
// Initialize in-app WebSocket listener in development
8-
initWsListener()
95

106
const container = document.getElementById('root')!
117
const root = createRoot(container)

frontend/src/ws/worker.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Web Worker to maintain a persistent WebSocket connection and forward HTML to the UI
2+
// Runs in its own thread. Reconnects with backoff and sends heartbeats to keep the connection alive.
3+
4+
// In a worker, self is DedicatedWorkerGlobalScope
5+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6+
const ctx: any = self as any
7+
8+
const WS_URL = 'ws://localhost:13579'
9+
10+
let ws: WebSocket | null = null
11+
let stopped = false
12+
let reconnectAttempts = 0
13+
let heartbeatTimer: number | null = null
14+
15+
function scheduleReconnect() {
16+
if (stopped) return
17+
reconnectAttempts++
18+
const delay = Math.min(30000, 1000 * Math.pow(2, Math.min(5, reconnectAttempts))) // 1s → 32s, capped at 30s
19+
setTimeout(connect, delay)
20+
}
21+
22+
function clearHeartbeat() {
23+
if (heartbeatTimer !== null) {
24+
clearInterval(heartbeatTimer)
25+
heartbeatTimer = null
26+
}
27+
}
28+
29+
function startHeartbeat() {
30+
clearHeartbeat()
31+
// Send a ping every 20s to keep NATs/IDS from closing idle connections
32+
heartbeatTimer = setInterval(() => {
33+
try {
34+
if (ws && ws.readyState === WebSocket.OPEN) {
35+
ws.send('') // empty ping
36+
}
37+
} catch {
38+
// ignore
39+
}
40+
}, 20000) as unknown as number
41+
}
42+
43+
function connect() {
44+
if (stopped) return
45+
46+
try {
47+
ws = new WebSocket(WS_URL)
48+
} catch (e) {
49+
// Unable to construct; try again later
50+
scheduleReconnect()
51+
return
52+
}
53+
54+
ws.addEventListener('open', () => {
55+
reconnectAttempts = 0
56+
startHeartbeat()
57+
ctx.postMessage({ type: 'status', connected: true })
58+
})
59+
60+
ws.addEventListener('close', () => {
61+
ctx.postMessage({ type: 'status', connected: false })
62+
clearHeartbeat()
63+
scheduleReconnect()
64+
})
65+
66+
ws.addEventListener('error', () => {
67+
ctx.postMessage({ type: 'status', connected: false })
68+
})
69+
70+
ws.addEventListener('message', (event: MessageEvent) => {
71+
const data = typeof event.data === 'string' ? event.data : ''
72+
if (data && data.trim()) {
73+
ctx.postMessage({ type: 'html', html: data })
74+
}
75+
})
76+
}
77+
78+
// Allow main thread to forward HTML back to the server if needed
79+
ctx.addEventListener('message', (event: MessageEvent) => {
80+
const msg = event.data
81+
if (!msg) return
82+
if (msg.type === 'stop') {
83+
stopped = true
84+
try { ws?.close() } catch {}
85+
clearHeartbeat()
86+
return
87+
}
88+
if (msg.type === 'send' && typeof msg.html === 'string') {
89+
try {
90+
if (ws && ws.readyState === WebSocket.OPEN) {
91+
ws.send(msg.html)
92+
}
93+
} catch {}
94+
}
95+
})
96+
97+
connect()

pixi.lock

Lines changed: 90 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)