thatcher 1.0.47 → 1.0.49

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.
@@ -1,178 +0,0 @@
1
- // state-sync channel (client side): browser WebSocket client with
2
- // exponential-backoff reconnect + polling fallback, part of the
3
- // state-protocol.js quartet. See the header comment in state-protocol.js --
4
- // this is a structured, reconnect-aware protocol distinct from
5
- // realtime-server.js's simple in-process pub/sub. Zero real importers found
6
- // repo-wide as of this writing; currently dormant/unwired.
7
- import Protocol from './state-protocol.js'
8
- import { CONFIG, ReconnectManager } from './state-transport-reconnect.js'
9
- import { createLogger } from './logger.js'
10
-
11
- const log = createLogger('[StateTransport]')
12
-
13
- class StateTransportClient {
14
- constructor(config = {}) {
15
- this.config = { ...CONFIG, ...config }
16
- this.ws = null
17
- this.state = 'disconnected'
18
- this.listeners = new Map()
19
- this.messageQueue = []
20
- this.connectionTimeoutTimer = null
21
-
22
- this.reconnect = new ReconnectManager(this.config, {
23
- onReconnecting: (data) => { this.emit('reconnecting', data); this.connect() },
24
- onFallback: (msg) => this.emit('fallback', msg),
25
- onPollStart: () => this.emit('poll_start'),
26
- onPollSuccess: (result) => this.emit('poll_success', result),
27
- onPollError: (error) => this.emit('poll_error', error)
28
- })
29
- }
30
-
31
- connect(url) {
32
- this.url = url || this.url
33
- if (!this.url) {
34
- this.emit('error', new Error('No URL provided for connection'))
35
- return
36
- }
37
- this.reconnect.wsUrl = this.url
38
-
39
- if (this.state === 'connecting' || this.state === 'connected') return
40
-
41
- this.state = 'connecting'
42
- this.emit('connecting')
43
-
44
- try {
45
- this.ws = new WebSocket(this.url)
46
-
47
- this.connectionTimeoutTimer = setTimeout(() => {
48
- if (this.state === 'connecting') this.handleConnectionTimeout()
49
- }, this.config.connectionTimeout)
50
-
51
- this.ws.onopen = () => this.handleOpen()
52
- this.ws.onclose = (event) => this.handleClose(event)
53
- this.ws.onerror = (error) => this.handleError(error)
54
- this.ws.onmessage = (event) => this.handleMessage(event)
55
- } catch (error) {
56
- this.handleConnectionFailure(error)
57
- }
58
- }
59
-
60
- handleOpen() {
61
- clearTimeout(this.connectionTimeoutTimer)
62
- this.state = 'connected'
63
- this.reconnect.resetAttempts()
64
- this.emit('connected')
65
- this.reconnect.startPingInterval(
66
- (msg) => this.send(msg),
67
- () => this.ws.close()
68
- )
69
- this.flushMessageQueue()
70
- this.reconnect.stopPolling()
71
- }
72
-
73
- handleClose(event) {
74
- clearTimeout(this.connectionTimeoutTimer)
75
- this.state = 'disconnected'
76
- this.reconnect.stopPingInterval()
77
- this.emit('disconnected', { code: event.code, reason: event.reason })
78
- this.reconnect.scheduleReconnect()
79
- }
80
-
81
- handleError(error) {
82
- this.emit('error', error)
83
- }
84
-
85
- handleConnectionTimeout() {
86
- if (this.ws) this.ws.close()
87
- this.handleConnectionFailure(new Error('Connection timeout'))
88
- }
89
-
90
- handleConnectionFailure(error) {
91
- this.emit('error', error)
92
- this.state = 'disconnected'
93
-
94
- if (this.reconnect.shouldFallback) {
95
- this.reconnect.activateFallback()
96
- } else {
97
- this.reconnect.scheduleReconnect()
98
- }
99
- }
100
-
101
- handleMessage(event) {
102
- try {
103
- const message = JSON.parse(event.data)
104
- if (message.type === Protocol.MESSAGE_TYPES.PONG) {
105
- this.reconnect.recordPong()
106
- return
107
- }
108
- this.emit('message', message)
109
- } catch (error) {
110
- this.emit('error', { error, phase: 'message_parsing', data: event.data })
111
- }
112
- }
113
-
114
- send(message) {
115
- if (this.state === 'connected' && this.ws && this.ws.readyState === WebSocket.OPEN) {
116
- try {
117
- this.ws.send(JSON.stringify(message))
118
- return true
119
- } catch (error) {
120
- this.emit('error', { error, phase: 'send' })
121
- this.messageQueue.push(message)
122
- return false
123
- }
124
- }
125
- this.messageQueue.push(message)
126
- return false
127
- }
128
-
129
- flushMessageQueue() {
130
- while (this.messageQueue.length > 0 && this.state === 'connected') {
131
- const message = this.messageQueue.shift()
132
- this.send(message)
133
- }
134
- }
135
-
136
- disconnect() {
137
- this.reconnect.cleanup()
138
- if (this.ws) {
139
- this.ws.close()
140
- this.ws = null
141
- }
142
- this.state = 'disconnected'
143
- }
144
-
145
- on(event, handler) {
146
- if (!this.listeners.has(event)) this.listeners.set(event, [])
147
- this.listeners.get(event).push(handler)
148
- }
149
-
150
- off(event, handler) {
151
- if (!this.listeners.has(event)) return
152
- const handlers = this.listeners.get(event)
153
- const index = handlers.indexOf(handler)
154
- if (index > -1) handlers.splice(index, 1)
155
- }
156
-
157
- emit(event, data) {
158
- if (!this.listeners.has(event)) return
159
- for (const handler of this.listeners.get(event)) {
160
- try {
161
- handler(data)
162
- } catch (error) {
163
- log.error(`event handler error for ${event}:`, { message: error?.message || String(error) })
164
- }
165
- }
166
- }
167
-
168
- getState() {
169
- return this.state
170
- }
171
-
172
- isConnected() {
173
- return this.state === 'connected'
174
- }
175
- }
176
-
177
- export { StateTransportClient }
178
- export default StateTransportClient
@@ -1,126 +0,0 @@
1
- // state-sync channel (reconnect logic): exponential-backoff + polling-fallback
2
- // manager consumed by state-transport-client.js, part of the state-protocol.js
3
- // quartet. See the header comment in state-protocol.js for the full
4
- // ws-broadcast vs state-sync distinction. Zero real importers repo-wide
5
- // outside this quartet's own internal cross-imports as of this writing.
6
- import Protocol from './state-protocol.js'
7
-
8
- const CONFIG = {
9
- reconnectInitialDelay: 1000,
10
- reconnectMaxDelay: 30000,
11
- reconnectBackoffFactor: 2,
12
- pingInterval: 25000,
13
- connectionTimeout: 10000,
14
- pollingInterval: 5000,
15
- pollingFallbackDelay: 3000
16
- }
17
-
18
- class ReconnectManager {
19
- constructor(config, callbacks) {
20
- this.config = config
21
- this.callbacks = callbacks
22
- this.reconnectAttempts = 0
23
- this.reconnectTimer = null
24
- this.pingTimer = null
25
- this.pollingTimer = null
26
- this.lastPongTime = 0
27
- this.useFallback = false
28
- this.wsUrl = null
29
- }
30
-
31
- get shouldFallback() {
32
- return this.reconnectAttempts >= 3 && !this.useFallback
33
- }
34
-
35
- resetAttempts() {
36
- this.reconnectAttempts = 0
37
- this.useFallback = false
38
- }
39
-
40
- recordPong() {
41
- this.lastPongTime = Date.now()
42
- }
43
-
44
- scheduleReconnect() {
45
- if (this.reconnectTimer) {
46
- clearTimeout(this.reconnectTimer)
47
- }
48
-
49
- const delay = Math.min(
50
- this.config.reconnectInitialDelay * Math.pow(this.config.reconnectBackoffFactor, this.reconnectAttempts),
51
- this.config.reconnectMaxDelay
52
- )
53
-
54
- const jitter = Math.random() * 1000
55
- this.reconnectAttempts++
56
-
57
- this.reconnectTimer = setTimeout(() => {
58
- this.callbacks.onReconnecting({ attempt: this.reconnectAttempts })
59
- }, delay + jitter)
60
- }
61
-
62
- activateFallback() {
63
- this.useFallback = true
64
- this.callbacks.onFallback('Switching to polling mode')
65
- this.startPolling()
66
- }
67
-
68
- startPolling() {
69
- if (this.pollingTimer) return
70
-
71
- this.pollingTimer = setInterval(async () => {
72
- try {
73
- this.callbacks.onPollStart()
74
- const result = await this.pollState()
75
- this.callbacks.onPollSuccess(result)
76
- } catch (error) {
77
- this.callbacks.onPollError(error)
78
- }
79
- }, this.config.pollingInterval)
80
- }
81
-
82
- async pollState() {
83
- const httpUrl = this.wsUrl.replace('ws://', 'http://').replace('wss://', 'https://')
84
- const response = await fetch(`${httpUrl}/poll`)
85
- if (!response.ok) {
86
- throw new Error(`Polling failed: ${response.status}`)
87
- }
88
- return await response.json()
89
- }
90
-
91
- startPingInterval(sendFn, closeFn) {
92
- this.pingTimer = setInterval(() => {
93
- sendFn(Protocol.createPing())
94
- const timeSinceLastPong = Date.now() - this.lastPongTime
95
- if (timeSinceLastPong > this.config.pingInterval * 2) {
96
- closeFn()
97
- }
98
- }, this.config.pingInterval)
99
- }
100
-
101
- stopPingInterval() {
102
- if (this.pingTimer) {
103
- clearInterval(this.pingTimer)
104
- this.pingTimer = null
105
- }
106
- }
107
-
108
- stopPolling() {
109
- if (this.pollingTimer) {
110
- clearInterval(this.pollingTimer)
111
- this.pollingTimer = null
112
- }
113
- }
114
-
115
- cleanup() {
116
- if (this.reconnectTimer) {
117
- clearTimeout(this.reconnectTimer)
118
- this.reconnectTimer = null
119
- }
120
- this.stopPolling()
121
- this.stopPingInterval()
122
- }
123
- }
124
-
125
- export { CONFIG, ReconnectManager }
126
- export default ReconnectManager
@@ -1,189 +0,0 @@
1
- // state-sync channel (server side): real `ws`-backed WebSocketServer with
2
- // connection-guard IP/rate limiting, part of the state-protocol.js quartet.
3
- // See the header comment in state-protocol.js for the full picture -- this is
4
- // a structured, reconnect-aware state-sync protocol, DIFFERENT from and not a
5
- // replacement for realtime-server.js's simple in-process pub/sub
6
- // (the "ws-broadcast" channel actually used by the CRUD write path). As of
7
- // this writing this file has zero real importers repo-wide; nothing
8
- // constructs a StateTransportServer today.
9
- import { WebSocketServer } from 'ws'
10
- import { EventEmitter } from 'events'
11
- import Protocol from '@/lib/state-protocol.js'
12
- import ConnectionGuard from '@/lib/connection-guard.js'
13
-
14
- const CONFIG = {
15
- pingInterval: 30000,
16
- connectionTimeout: 60000,
17
- maxConnectionsPerIP: 10,
18
- messageRateLimit: 100
19
- }
20
-
21
- class StateTransportServer extends EventEmitter {
22
- constructor(server, config = {}) {
23
- super()
24
- this.config = { ...CONFIG, ...config }
25
- this.wss = null
26
- this.clients = new Map()
27
- this.server = server
28
- this.guard = new ConnectionGuard(this.config)
29
- this.setupServer()
30
- }
31
-
32
- setupServer() {
33
- try {
34
- this.wss = new WebSocketServer({ server: this.server, path: '/state-sync' })
35
- this.wss.on('connection', (ws, req) => this.handleConnection(ws, req))
36
- this.wss.on('error', (error) => this.handleError(error))
37
- this.guard.startPingInterval(this.clients, (id) => this.handleClose(id))
38
- this.emit('ready')
39
- } catch (error) {
40
- this.emit('error', error)
41
- setTimeout(() => this.setupServer(), 5000)
42
- }
43
- }
44
-
45
- handleConnection(ws, req) {
46
- const clientId = this.guard.generateClientId()
47
- const ip = req.socket.remoteAddress
48
-
49
- if (!this.guard.checkConnectionLimit(ip)) {
50
- ws.close(1008, 'Too many connections from this IP')
51
- return
52
- }
53
-
54
- const client = {
55
- id: clientId,
56
- ws,
57
- ip,
58
- alive: true,
59
- connectedAt: Date.now(),
60
- messageCount: 0
61
- }
62
-
63
- this.clients.set(clientId, client)
64
- this.guard.trackIPConnection(ip)
65
-
66
- ws.on('message', (data) => this.handleMessage(clientId, data))
67
- ws.on('close', () => this.handleClose(clientId))
68
- ws.on('error', (error) => this.handleClientError(clientId, error))
69
- ws.on('pong', () => { client.alive = true })
70
-
71
- this.emit('client_connected', clientId)
72
- }
73
-
74
- handleMessage(clientId, data) {
75
- try {
76
- const client = this.clients.get(clientId)
77
- if (!client) return
78
-
79
- if (!this.guard.checkRateLimit(clientId)) {
80
- this.sendMessage(clientId, Protocol.createStateNack(
81
- null,
82
- 'Rate limit exceeded',
83
- Protocol.ERROR_CODES.RATE_LIMITED
84
- ))
85
- return
86
- }
87
-
88
- const message = JSON.parse(data.toString())
89
- const validation = Protocol.MessageSchema.validate(message)
90
-
91
- if (!validation.valid) {
92
- this.sendMessage(clientId, Protocol.createStateNack(
93
- message.id,
94
- validation.error,
95
- Protocol.ERROR_CODES.INVALID_MESSAGE
96
- ))
97
- return
98
- }
99
-
100
- client.messageCount++
101
- this.emit('message', clientId, message)
102
-
103
- if (message.type === Protocol.MESSAGE_TYPES.PING) {
104
- this.sendMessage(clientId, Protocol.createPong(message.timestamp))
105
- }
106
- } catch (error) {
107
- this.emit('error', { clientId, error, phase: 'message_handling' })
108
- }
109
- }
110
-
111
- handleClose(clientId) {
112
- const client = this.clients.get(clientId)
113
- if (client) {
114
- this.guard.untrackIPConnection(client.ip)
115
- this.clients.delete(clientId)
116
- this.guard.clearRateLimit(clientId)
117
- this.emit('client_disconnected', clientId)
118
- }
119
- }
120
-
121
- handleClientError(clientId, error) {
122
- this.emit('error', { clientId, error, phase: 'client_error' })
123
- }
124
-
125
- handleError(error) {
126
- this.emit('error', { error, phase: 'server_error' })
127
- }
128
-
129
- sendMessage(clientId, message) {
130
- try {
131
- const client = this.clients.get(clientId)
132
- if (client && client.ws.readyState === 1) {
133
- client.ws.send(JSON.stringify(message))
134
- return true
135
- }
136
- return false
137
- } catch (error) {
138
- this.emit('error', { clientId, error, phase: 'send_message' })
139
- return false
140
- }
141
- }
142
-
143
- broadcast(message, excludeClientId = null) {
144
- const results = { sent: 0, failed: 0 }
145
- for (const [clientId] of this.clients) {
146
- if (clientId === excludeClientId) continue
147
- if (this.sendMessage(clientId, message)) {
148
- results.sent++
149
- } else {
150
- results.failed++
151
- }
152
- }
153
- return results
154
- }
155
-
156
- getClientCount() {
157
- return this.clients.size
158
- }
159
-
160
- getClient(clientId) {
161
- return this.clients.get(clientId)
162
- }
163
-
164
- close() {
165
- this.guard.destroy()
166
- for (const client of this.clients.values()) {
167
- client.ws.close()
168
- }
169
- this.clients.clear()
170
- if (this.wss) {
171
- this.wss.close()
172
- }
173
- }
174
- }
175
-
176
- if (!global.stateTransportServer) {
177
- global.stateTransportServer = null
178
- }
179
-
180
- export function createStateTransportServer(server, config) {
181
- if (global.stateTransportServer) {
182
- global.stateTransportServer.close()
183
- }
184
- global.stateTransportServer = new StateTransportServer(server, config)
185
- return global.stateTransportServer
186
- }
187
-
188
- export { StateTransportServer }
189
- export default StateTransportServer
@@ -1,97 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { fileURLToPath } from 'url';
4
-
5
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
- const ROOT = path.join(__dirname, '../..');
7
-
8
- export function serveStatic(pathname, req, res, compress, getCacheHeaders, _loadModule) {
9
- const acceptEncoding = req.headers['accept-encoding'] || '';
10
-
11
- if (pathname === '/favicon.ico') {
12
- res.setHeader('Content-Type', 'image/x-icon');
13
- res.setHeader('Cache-Control', 'public, max-age=86400');
14
- res.setHeader('Content-Length', '0');
15
- res.writeHead(200);
16
- res.end();
17
- return true;
18
- }
19
-
20
- if (pathname === '/manifest.json') {
21
- const manifest = JSON.stringify({ name: 'Thatcher', short_name: 'Thatcher', start_url: '/', display: 'standalone', background_color: '#f1f5f9', theme_color: '#04141f' });
22
- res.setHeader('Content-Type', 'application/json');
23
- res.setHeader('Cache-Control', 'public, max-age=86400');
24
- res.setHeader('Content-Length', Buffer.byteLength(manifest, 'utf-8'));
25
- res.writeHead(200);
26
- res.end(manifest);
27
- return true;
28
- }
29
-
30
- if (pathname === '/service-worker.js') {
31
- const swPath = path.join(ROOT, 'src/service-worker.js');
32
- if (fs.existsSync(swPath)) {
33
- const content = fs.readFileSync(swPath, 'utf-8');
34
- const cacheHeaders = getCacheHeaders('dynamic');
35
- Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
36
- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
37
- res.setHeader('Content-Length', Buffer.byteLength(content, 'utf-8'));
38
- res.writeHead(200);
39
- res.end(content);
40
- return true;
41
- }
42
- }
43
-
44
- if (pathname.startsWith('/lib/webjsx/')) {
45
- const file = pathname.slice(12);
46
- const filePath = path.join(ROOT, 'node_modules/webjsx/dist', file);
47
- if (!fs.existsSync(filePath)) return false;
48
- const content = fs.readFileSync(filePath, 'utf-8');
49
- const cacheHeaders = getCacheHeaders('static', 31536000);
50
- Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
51
- const { content: finalContent, encoding } = compress(content, acceptEncoding);
52
- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
53
- if (encoding) res.setHeader('Content-Encoding', encoding);
54
- res.setHeader('Content-Length', Buffer.byteLength(finalContent));
55
- res.writeHead(200);
56
- res.end(finalContent);
57
- return true;
58
- }
59
-
60
- if (pathname.startsWith('/ui/') && pathname.endsWith('.css')) {
61
- const cssPath = path.join(ROOT, 'src/ui', path.basename(pathname));
62
- if (!fs.existsSync(cssPath)) return false;
63
- let content = fs.readFileSync(cssPath, 'utf-8');
64
- const etag = `"${content.length}-${fs.statSync(cssPath).mtimeMs.toString(36)}"`;
65
- if (req.headers['if-none-match'] === etag) { res.writeHead(304); res.end(); return true; }
66
- res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
67
- res.setHeader('ETag', etag);
68
- const { content: finalContent, encoding } = compress(content, acceptEncoding);
69
- res.setHeader('Content-Type', 'text/css; charset=utf-8');
70
- if (encoding) res.setHeader('Content-Encoding', encoding);
71
- res.setHeader('Content-Length', Buffer.byteLength(finalContent));
72
- res.writeHead(200);
73
- res.end(finalContent);
74
- return true;
75
- }
76
-
77
- if (pathname === '/ui/client.js' || pathname === '/ui/event-delegation.js' || pathname === '/ui/common-handlers.js') {
78
- const jsPath = path.join(ROOT, 'src/ui', pathname.split('/').pop());
79
- if (!fs.existsSync(jsPath)) return false;
80
- const content = fs.readFileSync(jsPath, 'utf-8');
81
- const cacheHeaders = getCacheHeaders('static', 86400);
82
- Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
83
- const { content: finalContent, encoding } = compress(content, acceptEncoding);
84
- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
85
- if (encoding) res.setHeader('Content-Encoding', encoding);
86
- res.setHeader('Content-Length', Buffer.byteLength(finalContent));
87
- res.writeHead(200);
88
- res.end(finalContent);
89
- return true;
90
- }
91
-
92
- return false;
93
- }
94
-
95
- export function html404() {
96
- return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><script>(function(){try{var t=localStorage.getItem('thatcher-theme')||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light');document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}})();</script><meta name="viewport" content="width=device-width,initial-scale=1"><title>404 - Page Not Found | Thatcher</title><link href="/ui/rippleui.css" rel="stylesheet"><link href="/ui/styles2.css" rel="stylesheet"><style>body{margin:0;background:var(--color-bg,#f1f5f9);font-family:system-ui,sans-serif}.nav-shell{background:#04141f;padding:0 2rem;height:56px;display:flex;align-items:center}a.logo-link{color:#fff;text-decoration:none;font-weight:700;font-size:1.1rem}.error-shell{min-height:calc(100vh - 56px);display:flex;align-items:center;justify-content:center}.error-card{background:#fff;border-radius:12px;padding:3rem 4rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.1)}.error-code{font-size:4rem;font-weight:900;color:#04141f;line-height:1}.error-msg{font-size:1.2rem;color:#64748b;margin:0.5rem 0 2rem}.home-btn{display:inline-block;padding:0.75rem 2rem;background:#04141f;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.95rem}</style></head><body><nav class="nav-shell"><a href="/" class="logo-link">Thatcher</a></nav><div class="error-shell"><div class="error-card"><div class="error-code">404</div><p class="error-msg">Page not found</p><a href="/" class="home-btn">Go to Dashboard</a></div></div></body></html>`;
97
- }