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/package.json +28 -1
- package/runtime/animation.js +535 -0
- package/runtime/dom-advanced.js +116 -37
- package/runtime/i18n.js +434 -0
- package/runtime/index.js +20 -0
- package/runtime/logger.js +5 -1
- package/runtime/persistence.js +492 -0
- package/runtime/sse.js +393 -0
- package/runtime/sw.js +250 -0
- package/sw/index.js +240 -0
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
|
+
};
|