pulse-js-framework 1.9.3 → 1.10.0

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/runtime/sse.js ADDED
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Pulse SSE (Server-Sent Events) Module
3
+ * Reactive SSE connections with auto-reconnect and exponential backoff.
4
+ *
5
+ * @module pulse-js-framework/runtime/sse
6
+ */
7
+
8
+ import { pulse, computed, effect, onCleanup } from './pulse.js';
9
+ import { loggers } from './logger.js';
10
+ import { RuntimeError } from './errors.js';
11
+
12
+ const log = loggers.websocket;
13
+
14
+ // =============================================================================
15
+ // CONSTANTS
16
+ // =============================================================================
17
+
18
+ const DEFAULT_OPTIONS = {
19
+ withCredentials: false,
20
+ reconnect: true,
21
+ maxRetries: 5,
22
+ baseDelay: 1000,
23
+ maxDelay: 30000,
24
+ events: ['message'],
25
+ parseJSON: true,
26
+ immediate: true,
27
+ onMessage: null,
28
+ onOpen: null,
29
+ onError: null,
30
+ };
31
+
32
+ // =============================================================================
33
+ // SSE ERROR
34
+ // =============================================================================
35
+
36
+ /**
37
+ * SSE-specific error with structured codes
38
+ */
39
+ export class SSEError extends RuntimeError {
40
+ /**
41
+ * @param {string} message
42
+ * @param {Object} [options]
43
+ * @param {string} [options.sseCode] - SSE error code
44
+ */
45
+ constructor(message, options = {}) {
46
+ super(message, { code: 'SSE_ERROR', ...options });
47
+ this.name = 'SSEError';
48
+ this.sseCode = options.sseCode ?? 'UNKNOWN';
49
+ }
50
+
51
+ static isSSEError(error) {
52
+ return error instanceof SSEError;
53
+ }
54
+
55
+ isConnectFailed() { return this.sseCode === 'CONNECT_FAILED'; }
56
+ isTimeout() { return this.sseCode === 'TIMEOUT'; }
57
+ isMaxRetries() { return this.sseCode === 'MAX_RETRIES'; }
58
+ isClosed() { return this.sseCode === 'CLOSED'; }
59
+ }
60
+
61
+ // =============================================================================
62
+ // INTERNAL HELPERS
63
+ // =============================================================================
64
+
65
+ function _validateUrl(url) {
66
+ if (typeof url !== 'string' || url.length === 0) {
67
+ throw new SSEError('SSE URL must be a non-empty string', {
68
+ sseCode: 'CONNECT_FAILED',
69
+ suggestion: 'Provide a valid URL string to createSSE() or useSSE()',
70
+ });
71
+ }
72
+ }
73
+
74
+ function _computeDelay(attempt, baseDelay, maxDelay) {
75
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
76
+ // Add jitter: ±25%
77
+ const jitter = delay * 0.25 * (Math.random() * 2 - 1);
78
+ return Math.max(0, Math.floor(delay + jitter));
79
+ }
80
+
81
+ // =============================================================================
82
+ // LOW-LEVEL: createSSE
83
+ // =============================================================================
84
+
85
+ /**
86
+ * Create a low-level SSE connection with auto-reconnect
87
+ *
88
+ * @param {string} url - SSE endpoint URL
89
+ * @param {Object} [options] - Configuration options
90
+ * @returns {Object} SSE instance with reactive state and control methods
91
+ */
92
+ export function createSSE(url, options = {}) {
93
+ _validateUrl(url);
94
+
95
+ const config = { ...DEFAULT_OPTIONS, ...options };
96
+
97
+ // Reactive state
98
+ const state = pulse('closed');
99
+ const connected = computed(() => state.get() === 'open');
100
+ const reconnecting = pulse(false);
101
+ const reconnectAttempt = pulse(0);
102
+ const error = pulse(null);
103
+ const lastEventId = pulse(null);
104
+
105
+ // Internal state
106
+ let eventSource = null;
107
+ let reconnectTimer = null;
108
+ let disposed = false;
109
+ const listeners = new Map(); // event -> Set<handler>
110
+
111
+ function _clearReconnectTimer() {
112
+ if (reconnectTimer !== null) {
113
+ clearTimeout(reconnectTimer);
114
+ reconnectTimer = null;
115
+ }
116
+ }
117
+
118
+ function _scheduleReconnect() {
119
+ if (disposed || !config.reconnect) return;
120
+
121
+ const attempt = reconnectAttempt.get();
122
+ if (attempt >= config.maxRetries) {
123
+ const err = new SSEError(
124
+ `Max reconnection attempts (${config.maxRetries}) exhausted`,
125
+ { sseCode: 'MAX_RETRIES' }
126
+ );
127
+ error.set(err);
128
+ reconnecting.set(false);
129
+ log.warn('SSE max retries reached for', url);
130
+ return;
131
+ }
132
+
133
+ reconnecting.set(true);
134
+ const delay = _computeDelay(attempt, config.baseDelay, config.maxDelay);
135
+ log.debug(`SSE reconnecting in ${delay}ms (attempt ${attempt + 1})`);
136
+
137
+ reconnectTimer = setTimeout(() => {
138
+ reconnectTimer = null;
139
+ reconnectAttempt.set(attempt + 1);
140
+ _connect();
141
+ }, delay);
142
+ }
143
+
144
+ function _connect() {
145
+ if (disposed) return;
146
+ if (typeof EventSource === 'undefined') {
147
+ log.warn('EventSource not available in this environment');
148
+ error.set(new SSEError('EventSource API not available', {
149
+ sseCode: 'CONNECT_FAILED',
150
+ suggestion: 'SSE requires a browser environment with EventSource support',
151
+ }));
152
+ return;
153
+ }
154
+
155
+ // Close existing connection
156
+ _closeSource();
157
+
158
+ state.set('connecting');
159
+ error.set(null);
160
+
161
+ try {
162
+ eventSource = new EventSource(url, {
163
+ withCredentials: config.withCredentials,
164
+ });
165
+
166
+ eventSource.onopen = () => {
167
+ if (disposed) return;
168
+ state.set('open');
169
+ reconnecting.set(false);
170
+ reconnectAttempt.set(0);
171
+ error.set(null);
172
+ log.info('SSE connected to', url);
173
+ config.onOpen?.();
174
+ };
175
+
176
+ eventSource.onerror = () => {
177
+ if (disposed) return;
178
+
179
+ const wasConnected = connected.get();
180
+ state.set('closed');
181
+
182
+ // EventSource auto-reconnects by default, but we manage it ourselves
183
+ // to have control over backoff and retries
184
+ _closeSource();
185
+
186
+ if (wasConnected) {
187
+ const err = new SSEError('SSE connection lost', { sseCode: 'CONNECT_FAILED' });
188
+ error.set(err);
189
+ config.onError?.(err);
190
+ log.warn('SSE connection lost for', url);
191
+ }
192
+
193
+ _scheduleReconnect();
194
+ };
195
+
196
+ // Register event listeners
197
+ for (const eventName of config.events) {
198
+ eventSource.addEventListener(eventName, _handleEvent);
199
+ }
200
+ } catch (e) {
201
+ state.set('closed');
202
+ const err = new SSEError(`Failed to create SSE connection: ${e.message}`, {
203
+ sseCode: 'CONNECT_FAILED',
204
+ });
205
+ error.set(err);
206
+ config.onError?.(err);
207
+ }
208
+ }
209
+
210
+ function _handleEvent(event) {
211
+ if (disposed) return;
212
+
213
+ if (event.lastEventId) {
214
+ lastEventId.set(event.lastEventId);
215
+ }
216
+
217
+ let data = event.data;
218
+ if (config.parseJSON) {
219
+ try {
220
+ data = JSON.parse(data);
221
+ } catch {
222
+ // Keep as string if not valid JSON
223
+ }
224
+ }
225
+
226
+ config.onMessage?.(data, event);
227
+
228
+ // Notify registered listeners
229
+ const handlers = listeners.get(event.type);
230
+ if (handlers) {
231
+ for (const handler of handlers) {
232
+ try {
233
+ handler(data, event);
234
+ } catch (e) {
235
+ log.error('SSE event handler error:', e);
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ function _closeSource() {
242
+ if (eventSource) {
243
+ for (const eventName of config.events) {
244
+ eventSource.removeEventListener(eventName, _handleEvent);
245
+ }
246
+ eventSource.close();
247
+ eventSource = null;
248
+ }
249
+ }
250
+
251
+ // Public methods
252
+ function connect() {
253
+ if (disposed) return;
254
+ _clearReconnectTimer();
255
+ reconnectAttempt.set(0);
256
+ reconnecting.set(false);
257
+ _connect();
258
+ }
259
+
260
+ function close() {
261
+ _clearReconnectTimer();
262
+ reconnecting.set(false);
263
+ _closeSource();
264
+ state.set('closed');
265
+ }
266
+
267
+ function addEventListener(event, handler) {
268
+ if (!listeners.has(event)) {
269
+ listeners.set(event, new Set());
270
+ }
271
+ listeners.get(event).add(handler);
272
+
273
+ // Also add to native EventSource if connected
274
+ if (eventSource && !config.events.includes(event)) {
275
+ eventSource.addEventListener(event, _handleEvent);
276
+ }
277
+ }
278
+
279
+ function removeEventListener(event, handler) {
280
+ const handlers = listeners.get(event);
281
+ if (handlers) {
282
+ handlers.delete(handler);
283
+ if (handlers.size === 0) {
284
+ listeners.delete(event);
285
+ }
286
+ }
287
+ }
288
+
289
+ function dispose() {
290
+ disposed = true;
291
+ close();
292
+ listeners.clear();
293
+ }
294
+
295
+ // Auto-connect if immediate
296
+ if (config.immediate) {
297
+ _connect();
298
+ }
299
+
300
+ return {
301
+ // Reactive state
302
+ state,
303
+ connected,
304
+ reconnecting,
305
+ reconnectAttempt,
306
+ error,
307
+ lastEventId,
308
+
309
+ // Methods
310
+ connect,
311
+ close,
312
+ addEventListener,
313
+ removeEventListener,
314
+ dispose,
315
+
316
+ // Aliases
317
+ on: addEventListener,
318
+ off: removeEventListener,
319
+ };
320
+ }
321
+
322
+ // =============================================================================
323
+ // HIGH-LEVEL HOOK: useSSE
324
+ // =============================================================================
325
+
326
+ /**
327
+ * Reactive SSE hook with automatic lifecycle management
328
+ *
329
+ * @param {string} url - SSE endpoint URL
330
+ * @param {Object} [options] - Configuration options
331
+ * @returns {Object} Reactive SSE state and control methods
332
+ */
333
+ export function useSSE(url, options = {}) {
334
+ const config = { ...DEFAULT_OPTIONS, ...options };
335
+
336
+ const data = pulse(null);
337
+ const messageHistory = config.messageHistorySize > 0 ? pulse([]) : null;
338
+
339
+ const sse = createSSE(url, {
340
+ ...config,
341
+ onMessage: (eventData, event) => {
342
+ data.set(eventData);
343
+
344
+ if (messageHistory && config.messageHistorySize > 0) {
345
+ messageHistory.update(history => {
346
+ const next = [...history, eventData];
347
+ if (next.length > config.messageHistorySize) {
348
+ return next.slice(next.length - config.messageHistorySize);
349
+ }
350
+ return next;
351
+ });
352
+ }
353
+
354
+ config.onMessage?.(eventData, event);
355
+ },
356
+ onOpen: () => config.onOpen?.(),
357
+ onError: (err) => config.onError?.(err),
358
+ });
359
+
360
+ // Cleanup on effect disposal
361
+ onCleanup(() => sse.dispose());
362
+
363
+ const result = {
364
+ data,
365
+ connected: sse.connected,
366
+ error: sse.error,
367
+ reconnecting: sse.reconnecting,
368
+ lastEventId: sse.lastEventId,
369
+
370
+ close: () => sse.close(),
371
+ reconnect: () => sse.connect(),
372
+
373
+ // Underlying instance
374
+ sse,
375
+ };
376
+
377
+ if (messageHistory) {
378
+ result.messages = messageHistory;
379
+ result.clearMessages = () => messageHistory.set([]);
380
+ }
381
+
382
+ return result;
383
+ }
384
+
385
+ // =============================================================================
386
+ // DEFAULT EXPORT
387
+ // =============================================================================
388
+
389
+ export default {
390
+ createSSE,
391
+ useSSE,
392
+ SSEError,
393
+ };
package/runtime/sw.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Pulse Service Worker Module (Main Thread)
3
+ * Registration helper with reactive lifecycle state and update notifications.
4
+ *
5
+ * @module pulse-js-framework/runtime/sw
6
+ */
7
+
8
+ import { pulse, effect, onCleanup } from './pulse.js';
9
+ import { loggers } from './logger.js';
10
+ import { RuntimeError } from './errors.js';
11
+
12
+ const log = loggers.pulse;
13
+
14
+ // =============================================================================
15
+ // CONSTANTS
16
+ // =============================================================================
17
+
18
+ const DEFAULT_OPTIONS = {
19
+ scope: '/',
20
+ updateInterval: 0,
21
+ immediate: true,
22
+ onUpdate: null,
23
+ onActivate: null,
24
+ onError: null,
25
+ };
26
+
27
+ // =============================================================================
28
+ // HELPERS
29
+ // =============================================================================
30
+
31
+ function _isSWSupported() {
32
+ return typeof navigator !== 'undefined' && 'serviceWorker' in navigator;
33
+ }
34
+
35
+ // =============================================================================
36
+ // registerServiceWorker()
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Register a service worker with lifecycle monitoring
41
+ *
42
+ * @param {string} url - Path to the service worker file
43
+ * @param {Object} [options] - Configuration options
44
+ * @returns {Object} { update, unregister, registration }
45
+ */
46
+ export function registerServiceWorker(url, options = {}) {
47
+ const config = { ...DEFAULT_OPTIONS, ...options };
48
+
49
+ let registration = null;
50
+ let updateTimer = null;
51
+
52
+ if (!_isSWSupported()) {
53
+ log.warn('Service Workers not supported in this environment');
54
+ return {
55
+ update: () => Promise.resolve(null),
56
+ unregister: () => Promise.resolve(false),
57
+ registration: null,
58
+ };
59
+ }
60
+
61
+ async function _register() {
62
+ try {
63
+ registration = await navigator.serviceWorker.register(url, {
64
+ scope: config.scope,
65
+ });
66
+
67
+ log.info(`Service worker registered: ${url}`);
68
+
69
+ // Listen for updates
70
+ registration.addEventListener('updatefound', () => {
71
+ const newWorker = registration.installing;
72
+ if (newWorker) {
73
+ newWorker.addEventListener('statechange', () => {
74
+ if (newWorker.state === 'activated') {
75
+ config.onActivate?.(registration);
76
+ }
77
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
78
+ // New content available
79
+ config.onUpdate?.(registration);
80
+ }
81
+ });
82
+ }
83
+ });
84
+
85
+ // Set up periodic update checks
86
+ if (config.updateInterval > 0) {
87
+ updateTimer = setInterval(() => {
88
+ registration?.update().catch(e => {
89
+ log.warn('SW update check failed:', e.message);
90
+ });
91
+ }, config.updateInterval);
92
+ }
93
+
94
+ return registration;
95
+ } catch (e) {
96
+ log.error('SW registration failed:', e.message);
97
+ config.onError?.(e);
98
+ return null;
99
+ }
100
+ }
101
+
102
+ if (config.immediate) {
103
+ _register();
104
+ }
105
+
106
+ return {
107
+ async update() {
108
+ if (registration) {
109
+ return registration.update();
110
+ }
111
+ return null;
112
+ },
113
+
114
+ async unregister() {
115
+ if (updateTimer) {
116
+ clearInterval(updateTimer);
117
+ updateTimer = null;
118
+ }
119
+ if (registration) {
120
+ const result = await registration.unregister();
121
+ log.info('Service worker unregistered');
122
+ return result;
123
+ }
124
+ return false;
125
+ },
126
+
127
+ get registration() { return registration; },
128
+ };
129
+ }
130
+
131
+ // =============================================================================
132
+ // useServiceWorker()
133
+ // =============================================================================
134
+
135
+ /**
136
+ * Reactive service worker hook for UI integration
137
+ *
138
+ * @param {string} url - Path to the service worker file
139
+ * @param {Object} [options] - Configuration options
140
+ * @returns {Object} Reactive SW state and control methods
141
+ */
142
+ export function useServiceWorker(url, options = {}) {
143
+ const supported = _isSWSupported();
144
+
145
+ const registered = pulse(false);
146
+ const installing = pulse(false);
147
+ const waiting = pulse(false);
148
+ const active = pulse(false);
149
+ const updateAvailable = pulse(false);
150
+ const error = pulse(null);
151
+
152
+ let swRegistration = null;
153
+
154
+ if (!supported) {
155
+ return {
156
+ supported,
157
+ registered,
158
+ installing,
159
+ waiting,
160
+ active,
161
+ updateAvailable,
162
+ error,
163
+ update: () => Promise.resolve(null),
164
+ skipWaiting: () => Promise.resolve(),
165
+ unregister: () => Promise.resolve(false),
166
+ };
167
+ }
168
+
169
+ function _updateState(reg) {
170
+ if (!reg) return;
171
+ installing.set(!!reg.installing);
172
+ waiting.set(!!reg.waiting);
173
+ active.set(!!reg.active);
174
+ }
175
+
176
+ const sw = registerServiceWorker(url, {
177
+ ...options,
178
+ onUpdate: (reg) => {
179
+ updateAvailable.set(true);
180
+ _updateState(reg);
181
+ options.onUpdate?.(reg);
182
+ },
183
+ onActivate: (reg) => {
184
+ _updateState(reg);
185
+ options.onActivate?.(reg);
186
+ },
187
+ onError: (err) => {
188
+ error.set(err);
189
+ options.onError?.(err);
190
+ },
191
+ });
192
+
193
+ // Monitor registration state
194
+ if (supported) {
195
+ navigator.serviceWorker.ready.then(reg => {
196
+ swRegistration = reg;
197
+ registered.set(true);
198
+ _updateState(reg);
199
+ }).catch(e => {
200
+ error.set(e);
201
+ });
202
+ }
203
+
204
+ async function update() {
205
+ return sw.update();
206
+ }
207
+
208
+ async function skipWaiting() {
209
+ if (swRegistration?.waiting) {
210
+ swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
211
+ }
212
+ }
213
+
214
+ async function unregister() {
215
+ const result = await sw.unregister();
216
+ registered.set(false);
217
+ active.set(false);
218
+ installing.set(false);
219
+ waiting.set(false);
220
+ updateAvailable.set(false);
221
+ return result;
222
+ }
223
+
224
+ onCleanup(() => {
225
+ // Don't unregister on cleanup — just stop monitoring
226
+ });
227
+
228
+ return {
229
+ supported,
230
+ registered,
231
+ installing,
232
+ waiting,
233
+ active,
234
+ updateAvailable,
235
+ error,
236
+
237
+ update,
238
+ skipWaiting,
239
+ unregister,
240
+ };
241
+ }
242
+
243
+ // =============================================================================
244
+ // DEFAULT EXPORT
245
+ // =============================================================================
246
+
247
+ export default {
248
+ registerServiceWorker,
249
+ useServiceWorker,
250
+ };