@reidelsaltres/pureper 0.2.14 → 0.2.17

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.
Files changed (65) hide show
  1. package/out/foundation/Fetcher.d.ts.map +1 -1
  2. package/out/foundation/Fetcher.js +8 -13
  3. package/out/foundation/Fetcher.js.map +1 -1
  4. package/out/foundation/Injection.d.ts +87 -0
  5. package/out/foundation/Injection.d.ts.map +1 -0
  6. package/out/foundation/Injection.js +149 -0
  7. package/out/foundation/Injection.js.map +1 -0
  8. package/out/foundation/Theme.d.ts.map +1 -1
  9. package/out/foundation/Theme.js +16 -1
  10. package/out/foundation/Theme.js.map +1 -1
  11. package/out/foundation/Triplet.d.ts +30 -25
  12. package/out/foundation/Triplet.d.ts.map +1 -1
  13. package/out/foundation/Triplet.js +96 -115
  14. package/out/foundation/Triplet.js.map +1 -1
  15. package/out/foundation/TripletDecorator.d.ts +11 -0
  16. package/out/foundation/TripletDecorator.d.ts.map +1 -1
  17. package/out/foundation/TripletDecorator.js +25 -8
  18. package/out/foundation/TripletDecorator.js.map +1 -1
  19. package/out/foundation/component_api/Component.d.ts.map +1 -1
  20. package/out/foundation/component_api/Component.js +1 -0
  21. package/out/foundation/component_api/Component.js.map +1 -1
  22. package/out/foundation/component_api/UniHtml.d.ts +6 -1
  23. package/out/foundation/component_api/UniHtml.d.ts.map +1 -1
  24. package/out/foundation/component_api/UniHtml.js +32 -7
  25. package/out/foundation/component_api/UniHtml.js.map +1 -1
  26. package/out/foundation/engine/Expression.d.ts +2 -0
  27. package/out/foundation/engine/Expression.d.ts.map +1 -1
  28. package/out/foundation/engine/Expression.js +26 -11
  29. package/out/foundation/engine/Expression.js.map +1 -1
  30. package/out/foundation/engine/StylePreprocessor.js +2 -1
  31. package/out/foundation/engine/StylePreprocessor.js.map +1 -1
  32. package/out/foundation/engine/TemplateEngine.d.ts +3 -1
  33. package/out/foundation/engine/TemplateEngine.d.ts.map +1 -1
  34. package/out/foundation/engine/TemplateEngine.js +44 -15
  35. package/out/foundation/engine/TemplateEngine.js.map +1 -1
  36. package/out/foundation/worker/Router.d.ts.map +1 -1
  37. package/out/foundation/worker/Router.js +6 -0
  38. package/out/foundation/worker/Router.js.map +1 -1
  39. package/out/foundation/worker/ServiceWorker.d.ts +48 -17
  40. package/out/foundation/worker/ServiceWorker.d.ts.map +1 -1
  41. package/out/foundation/worker/ServiceWorker.js +186 -119
  42. package/out/foundation/worker/ServiceWorker.js.map +1 -1
  43. package/out/index.d.ts +3 -2
  44. package/out/index.d.ts.map +1 -1
  45. package/out/index.js +2 -1
  46. package/out/index.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/foundation/Fetcher.ts +9 -14
  49. package/src/foundation/Injection.ts +183 -0
  50. package/src/foundation/Theme.ts +17 -3
  51. package/src/foundation/Triplet.ts +114 -141
  52. package/src/foundation/TripletDecorator.ts +32 -8
  53. package/src/foundation/component_api/Component.ts +1 -0
  54. package/src/foundation/component_api/UniHtml.ts +35 -7
  55. package/src/foundation/engine/Expression.ts +34 -20
  56. package/src/foundation/engine/StylePreprocessor.ts +1 -1
  57. package/src/foundation/engine/TemplateEngine.ts +42 -15
  58. package/src/foundation/worker/Router.ts +10 -3
  59. package/src/foundation/worker/ServiceWorker.ts +203 -129
  60. package/src/foundation/worker/serviceworker.js +191 -0
  61. package/src/index.ts +7 -4
  62. package/out/foundation/worker/serviceworker.d.ts +0 -1
  63. package/out/foundation/worker/serviceworker.d.ts.map +0 -1
  64. package/out/foundation/worker/serviceworker.js +0 -2
  65. package/out/foundation/worker/serviceworker.js.map +0 -1
@@ -19,6 +19,9 @@ export default class Expression {
19
19
  private readonly isAsync: boolean;
20
20
  private readonly hasReturn: boolean;
21
21
 
22
+ private static fnCache = new Map<string, Function>();
23
+ private static readonly FN_CACHE_MAX = 500;
24
+
22
25
  constructor(code: string) {
23
26
  this.code = code.trim();
24
27
  this.isAsync = this.detectAsync(this.code);
@@ -79,7 +82,7 @@ export default class Expression {
79
82
  const identifiers: string[] = [];
80
83
  // Regex для идентификаторов (не после точки)
81
84
  const regex = /(?<![.\w])([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
82
-
85
+
83
86
  // Список встроенных глобальных объектов, которые нужно игнорировать
84
87
  const builtins = new Set([
85
88
  'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
@@ -110,7 +113,7 @@ export default class Expression {
110
113
  public transformCode(scope: Scope): string {
111
114
  const context = scope.getVariables();
112
115
  let transformedCode = this.code;
113
-
116
+
114
117
  // Находим Observable переменные
115
118
  const observableVars = new Set<string>();
116
119
  for (const [key, value] of Object.entries(context)) {
@@ -178,7 +181,7 @@ export default class Expression {
178
181
  // Handle reserved keywords used as variable names (e.g., "super")
179
182
  const reservedKeywords = ['super', 'this', 'arguments'];
180
183
  const keyMapping: Record<string, string> = {};
181
-
184
+
182
185
  const keys = Object.keys(context).map(key => {
183
186
  if (reservedKeywords.includes(key)) {
184
187
  const safeKey = `__${key}__`;
@@ -188,9 +191,9 @@ export default class Expression {
188
191
  return key;
189
192
  });
190
193
  const values = Object.values(context);
191
-
194
+
192
195
  let codeToExecute = codeOverride ?? this.code;
193
-
196
+
194
197
  // Replace reserved keywords in code with safe alternatives
195
198
  for (const [original, safe] of Object.entries(keyMapping)) {
196
199
  const regex = new RegExp(`\\b${original}\\b`, 'g');
@@ -208,19 +211,30 @@ export default class Expression {
208
211
  }
209
212
 
210
213
  try {
211
- // Create function with context variables as parameters
212
- const fn = new Function(...keys, functionBody);
213
- return fn.apply(null, values);
214
- } catch (syntaxError) {
215
- // If implicit return fails, try without it (for statements)
216
- if (!this.hasReturn) {
214
+ const cacheKey = keys.join('\0') + '\0' + functionBody;
215
+ let fn = Expression.fnCache.get(cacheKey);
216
+ if (!fn) {
217
217
  try {
218
- const fn = new Function(...keys, codeToExecute);
219
- return fn.apply(null, values);
220
- } catch {
221
- throw syntaxError;
218
+ fn = new Function(...keys, functionBody);
219
+ } catch (syntaxError) {
220
+ if (!this.hasReturn) {
221
+ try {
222
+ fn = new Function(...keys, codeToExecute);
223
+ } catch {
224
+ throw syntaxError;
225
+ }
226
+ } else {
227
+ throw syntaxError;
228
+ }
222
229
  }
230
+ if (Expression.fnCache.size >= Expression.FN_CACHE_MAX) {
231
+ const firstKey = Expression.fnCache.keys().next().value;
232
+ Expression.fnCache.delete(firstKey);
233
+ }
234
+ Expression.fnCache.set(cacheKey, fn);
223
235
  }
236
+ return fn.apply(null, values);
237
+ } catch (syntaxError) {
224
238
  throw syntaxError;
225
239
  }
226
240
  }
@@ -232,7 +246,7 @@ export default class Expression {
232
246
  // Handle reserved keywords used as variable names (e.g., "super")
233
247
  const reservedKeywords = ['super', 'this', 'arguments'];
234
248
  const keyMapping: Record<string, string> = {};
235
-
249
+
236
250
  const keys = Object.keys(context).map(key => {
237
251
  if (reservedKeywords.includes(key)) {
238
252
  const safeKey = `__${key}__`;
@@ -242,9 +256,9 @@ export default class Expression {
242
256
  return key;
243
257
  });
244
258
  const values = Object.values(context);
245
-
259
+
246
260
  let codeToExecute = codeOverride ?? this.code;
247
-
261
+
248
262
  // Replace reserved keywords in code with safe alternatives
249
263
  for (const [original, safe] of Object.entries(keyMapping)) {
250
264
  const regex = new RegExp(`\\b${original}\\b`, 'g');
@@ -261,13 +275,13 @@ export default class Expression {
261
275
 
262
276
  try {
263
277
  // Create async function
264
- const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
278
+ const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
265
279
  const fn = new AsyncFunction(...keys, functionBody);
266
280
  return await fn.apply(null, values);
267
281
  } catch (syntaxError) {
268
282
  if (!this.hasReturn) {
269
283
  try {
270
- const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
284
+ const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
271
285
  const fn = new AsyncFunction(...keys, codeToExecute);
272
286
  return await fn.apply(null, values);
273
287
  } catch {
@@ -160,7 +160,7 @@ export default class StylePreprocessor {
160
160
  ast.push(result.node);
161
161
 
162
162
  result = rule.walk(walker, tokens, data);
163
- tokens = result.tokens;
163
+ if (result) tokens = result.tokens;
164
164
  }
165
165
  }
166
166
 
@@ -25,7 +25,7 @@ export default class TemplateEngine {
25
25
  context!.data!.set(context!.refName, context!.element);
26
26
  this.globalScope!.set(context!.refName, context!.element);
27
27
 
28
- this.engine.bindings.set(context!.element, () => {
28
+ this.engine.addBinding(context!.element, () => {
29
29
  context!.data!.delete(context!.refName);
30
30
  this.globalScope!.delete(context!.refName);
31
31
  });
@@ -46,10 +46,12 @@ export default class TemplateEngine {
46
46
  const of = valueExpression.eval(data!);
47
47
  const value = of instanceof Observable ? of.getObject() : of;
48
48
  if (of instanceof Observable) {
49
- of.subscribe((newValue: any) => {
49
+ const handler = (newValue: any) => {
50
50
  this.engine.change();
51
51
  this.doWork({ element, name: attributeName, value: newValue });
52
- });
52
+ };
53
+ of.subscribe(handler);
54
+ this.engine.addBinding(element, () => of.unsubscribe(handler));
53
55
  }
54
56
  this.doWork({ element, name: attributeName, value });
55
57
  }
@@ -82,6 +84,7 @@ export default class TemplateEngine {
82
84
  };
83
85
 
84
86
  element.addEventListener(eventName, listener);
87
+ this.engine.addBinding(element, () => element.removeEventListener(eventName, listener));
85
88
  }
86
89
  return false;
87
90
  }
@@ -140,11 +143,13 @@ export default class TemplateEngine {
140
143
 
141
144
  const value = of instanceof Observable ? of.getObject() : of;
142
145
  if (of instanceof Observable) {
143
- of.subscribe((newValue: any[]) => {
146
+ const handler = (newValue: any[]) => {
144
147
  element.textContent = "";
145
148
  this.engine.change();
146
149
  this.doWork({ element, value: newValue, allowHtmlInjection: allowHtmlInjection });
147
- });
150
+ };
151
+ of.subscribe(handler);
152
+ this.engine.addBinding(element, () => of.unsubscribe(handler));
148
153
  }
149
154
  this.doWork({ element, value, allowHtmlInjection: allowHtmlInjection });
150
155
 
@@ -179,10 +184,12 @@ export default class TemplateEngine {
179
184
 
180
185
  const iterable = of instanceof Observable ? of.getObject() : of;
181
186
  if (of instanceof Observable) {
182
- of.subscribe((newValue: any[]) => {
187
+ const handler = (newValue: any[]) => {
183
188
  this.engine.change();
184
189
  this.doWork({ element, iterable: newValue, index, value, walker, shadow, data });
185
- });
190
+ };
191
+ of.subscribe(handler);
192
+ this.engine.addBinding(element, () => of.unsubscribe(handler));
186
193
  }
187
194
  this.doWork({ element, iterable: iterable, index, value, walker, shadow, data });
188
195
  }
@@ -204,6 +211,7 @@ export default class TemplateEngine {
204
211
  const data = context!.data;
205
212
 
206
213
  element.childNodes.forEach(n => this.engine.fullCleanup(n));
214
+ while (element.firstChild) element.removeChild(element.firstChild);
207
215
 
208
216
  const lenght = typeof iterable === "number" ? iterable : iterable.length;
209
217
 
@@ -258,10 +266,12 @@ export default class TemplateEngine {
258
266
  const condition = new Expression(conditionAtt).eval(data!);
259
267
 
260
268
  if (condition instanceof Observable) {
261
- condition.subscribe((newValue: boolean) => {
269
+ const handler = (newValue: boolean) => {
262
270
  this.engine.change();
263
271
  this.doWork({ walker, data, allParts: allParts });
264
- });
272
+ };
273
+ condition.subscribe(handler);
274
+ this.engine.addBinding(part.element, () => condition.unsubscribe(handler));
265
275
  }
266
276
  }
267
277
 
@@ -278,9 +288,8 @@ export default class TemplateEngine {
278
288
  const { walker, data, allParts } = context!;
279
289
 
280
290
  allParts.forEach(part => {
281
- this.engine.fullCleanup(part.element);
282
- /*part.element.childNodes.forEach(n =>
283
- this.engine.fullCleanup(n));*/
291
+ part.element.childNodes.forEach(n => this.engine.fullCleanup(n));
292
+ while (part.element.firstChild) part.element.removeChild(part.element.firstChild);
284
293
  });
285
294
 
286
295
  for (const part of allParts) {
@@ -316,9 +325,24 @@ export default class TemplateEngine {
316
325
  this.if_component
317
326
  ];
318
327
 
319
- public readonly bindings: Map<Element, () => void> = new Map();
328
+ public readonly bindings: Map<Node, (() => void)[]> = new Map();
320
329
  private readonly onChangeCallbacks: (() => void)[] = [];
321
330
  public processLogs: string[] = [];
331
+ public addBinding(node: Node, cleanup: () => void): void {
332
+ const existing = this.bindings.get(node);
333
+ if (existing) {
334
+ existing.push(cleanup);
335
+ } else {
336
+ this.bindings.set(node, [cleanup]);
337
+ }
338
+ }
339
+ public dispose(): void {
340
+ for (const [, cleanups] of this.bindings) {
341
+ cleanups.forEach(cleanup => cleanup());
342
+ }
343
+ this.bindings.clear();
344
+ this.onChangeCallbacks.length = 0;
345
+ }
322
346
  public static fullProcess(root: Node, scope: Scope): string[] {
323
347
  const engine = new TemplateEngine();
324
348
  engine.fullProcess(root, scope);
@@ -379,8 +403,11 @@ export default class TemplateEngine {
379
403
  let i = 0;
380
404
  walker.onEnterNode((node: Node, data?: Scope) => {
381
405
  const element = node as Element;
382
- const binding = this.bindings.get(element);
383
- if (binding) binding();
406
+ const bindings = this.bindings.get(element);
407
+ if (bindings) {
408
+ bindings.forEach(b => b());
409
+ this.bindings.delete(element);
410
+ }
384
411
 
385
412
  this.processLogs.push((" ").repeat(i) + "layer " + i + " Cleaning up Starting: " + node.nodeName);
386
413
  i++;
@@ -50,14 +50,21 @@ export abstract class Router {
50
50
  window.location.replace(url.href);
51
51
  }
52
52
  }
53
-
53
+
54
54
  public static tryRouteTo(url: URL, pushState: boolean = true) {
55
55
  const urlH = new URL(Fetcher.resolveUrl(url.href));
56
56
  try {
57
57
  const found: Route = this.tryFindRoute(urlH);
58
-
58
+
59
59
  let pageContainer = document.getElementById('page');
60
-
60
+
61
+ if (this.currentPage) {
62
+ this.currentPage.dispose();
63
+ }
64
+ while (pageContainer.firstChild) {
65
+ pageContainer.removeChild(pageContainer.firstChild);
66
+ }
67
+
61
68
  const page: UniHtml = this.createPage(found, urlH.searchParams);
62
69
  this.currentPage = page;
63
70
 
@@ -1,155 +1,229 @@
1
- /**
2
- * Service Worker for Pureper SPA
3
- * Handles client-side routing by intercepting navigation requests
4
- * and serving index.html for all SPA routes
5
- */
6
- import type { ExtendableEvent } from './api/ExtendableEvent.js';
7
- import type { FetchEvent } from './api/FetchEvent.js';
8
- import type { ServiceWorkerGlobalScope } from './api/ServiceWorkerGlobalScope.js';
9
-
10
- // Type assertion for Service Worker context
11
- declare let self: ServiceWorkerGlobalScope
12
- const swSelf = self;
13
-
14
- // Автоматически генерируем CACHE_NAME из base.json
15
- //import base from '../../../data/base.json';
16
- const CACHE_NAME = `pureper-v1`;
17
-
18
- const STATIC_ASSETS: string[] = [
19
- '/index.html'
20
- ];
21
-
22
- /*if ('serviceWorker' in navigator) {
23
- window.addEventListener('load', () => {
24
- navigator.serviceWorker.register('./serviceWorker.js', { type: 'module' })
25
- .then((registration) => {
26
- console.log('ServiceWorker registration successful:', registration.scope);
27
- })
28
- .catch((error) => {
29
- console.error('ServiceWorker registration failed:', error);
30
- });
31
- });
32
- window.addEventListener('fetch', (event: FetchEvent) => {
33
- event.respondWith(
34
- caches.match(event.request).then((cachedResponse) => {
35
- console.log(`[ServiceWorker]: Fetching ${event.request.url}`);
36
- return cachedResponse || fetch(event.request);
37
- })
38
- );
39
- });
40
- }
1
+ import Observable from "../api/Observer.js";
2
+
3
+ export type ServiceWorkerConfig = {
4
+ scriptURL?: string;
5
+ scope?: string;
6
+ };
41
7
 
42
8
  /**
43
- * Install event - cache static assets
9
+ * Client-side Service Worker manager for Purper SPA.
10
+ *
11
+ * Provides:
12
+ * - Registration with one call (`ServiceWorker.register()`)
13
+ * - Cache management: add / remove / list / clear
14
+ * - Connectivity detection with reactive `online` observable
15
+ *
16
+ * Usage:
17
+ * ```ts
18
+ * await ServiceWorker.register(); // defaults to './serviceworker.js'
19
+ * await ServiceWorker.addToCache('/data.json');
20
+ * await ServiceWorker.removeFromCache('/old.css');
21
+ * ServiceWorker.online.subscribe(v => console.log('online:', v));
22
+ * ```
44
23
  */
45
- /*window.addEventListener('install', (event: ExtendableEvent) => {
46
- console.log('ServiceWorker: Installing...');
47
- const assetsToCache = [
48
- ...STATIC_ASSETS
49
- ];
50
- // Remove duplicates
51
- const uniqueAssets = Array.from(new Set(assetsToCache));
52
- event.waitUntil(
53
- caches.open(CACHE_NAME)
54
- .then((cache: Cache) => {
55
- console.log('ServiceWorker: Caching static assets and SPA routes');
56
- return cache.addAll(uniqueAssets);
57
- })
58
- .then(() => {
59
- console.log('ServiceWorker: Installation complete');
60
- return swSelf.skipWaiting();
61
- })
62
- );
63
- });*/
64
-
65
24
  export default class ServiceWorker {
66
- /**
67
- * Sends a message to the service worker to cache a specific URL.
68
- * @param url The URL of the resource to cache.
69
- */
70
- static async addToCache(url: string): Promise<void> {
71
- if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
72
- navigator.serviceWorker.controller.postMessage({
73
- type: 'CACHE_URL',
74
- url: url
25
+ private static _registration?: ServiceWorkerRegistration;
26
+
27
+ /** Observable connectivity state subscribe for real-time changes. */
28
+ static readonly online: Observable<boolean> = new Observable(navigator.onLine);
29
+
30
+ // ── Connectivity listeners (bound once) ─────────────────────────
31
+ private static _connectivityBound = false;
32
+ private static _bindConnectivity(): void {
33
+ if (this._connectivityBound) return;
34
+ this._connectivityBound = true;
35
+
36
+ window.addEventListener('online', () => {
37
+ console.log('[ServiceWorker]: Browser went online');
38
+ this.online.setObject(true);
39
+ });
40
+ window.addEventListener('offline', () => {
41
+ console.log('[ServiceWorker]: Browser went offline');
42
+ this.online.setObject(false);
43
+ });
44
+ }
45
+
46
+ // ── Registration ────────────────────────────────────────────────
47
+ static async register(config?: ServiceWorkerConfig): Promise<ServiceWorkerRegistration | undefined> {
48
+ if (!('serviceWorker' in navigator)) {
49
+ console.warn('[ServiceWorker]: Service Workers are not supported in this browser');
50
+ return undefined;
51
+ }
52
+
53
+ this._bindConnectivity();
54
+
55
+ const scriptURL = config?.scriptURL ?? './serviceworker.js';
56
+ const scope = config?.scope ?? '/';
57
+
58
+ try {
59
+ const reg = await navigator.serviceWorker.register(scriptURL, { scope });
60
+ this._registration = reg;
61
+ console.log(`[ServiceWorker]: Registered "${scriptURL}" with scope "${reg.scope}"`);
62
+
63
+ reg.addEventListener('updatefound', () => {
64
+ const newWorker = reg.installing;
65
+ if (!newWorker) return;
66
+ console.log('[ServiceWorker]: New version installing...');
67
+ newWorker.addEventListener('statechange', () => {
68
+ if (newWorker.state === 'activated') {
69
+ console.log('[ServiceWorker]: New version activated');
70
+ }
71
+ });
75
72
  });
76
- } else {
77
- // Fallback: try to cache directly using the Cache API
78
- try {
79
- const cache = await caches.open('pureper-v1');
80
- const response = await fetch(url);
81
- if (response.ok) {
82
- await cache.put(url, response);
83
- console.log('[ServiceWorker]: Resource cached directly:', url);
84
- }
85
- } catch (e) {
86
- console.warn('[ServiceWorker]: Failed to cache resource:', url, e);
73
+
74
+ // Wait for the controller to be available
75
+ if (!navigator.serviceWorker.controller) {
76
+ await new Promise<void>((resolve) => {
77
+ navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
78
+ });
87
79
  }
80
+
81
+ return reg;
82
+ } catch (err) {
83
+ console.error('[ServiceWorker]: Registration failed', err);
84
+ return undefined;
88
85
  }
89
86
  }
90
87
 
91
- /**
92
- * Asks the service worker to skip waiting and activate the new version.
93
- */
94
- static skipWaiting(): void {
95
- if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
96
- navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' });
97
- }
88
+ // ── Helpers ─────────────────────────────────────────────────────
89
+ private static _postMessage(msg: object): void {
90
+ navigator.serviceWorker?.controller?.postMessage(msg);
98
91
  }
99
92
 
100
- /**
101
- * Gets the version of the currently active service worker.
102
- * @returns A promise that resolves with the version string.
103
- */
104
- static getVersion(): Promise<string> {
93
+ private static _request<T>(msg: object): Promise<T> {
105
94
  return new Promise((resolve, reject) => {
106
- if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
107
- const messageChannel = new MessageChannel();
108
- messageChannel.port1.onmessage = (event) => {
109
- if (event.data.error) {
110
- reject(event.data.error);
111
- } else {
112
- resolve(event.data.version);
113
- }
114
- };
115
- navigator.serviceWorker.controller.postMessage({ type: 'GET_VERSION' }, [messageChannel.port2]);
116
- } else {
117
- reject('Service worker not available.');
95
+ if (!navigator.serviceWorker?.controller) {
96
+ reject('[ServiceWorker]: No active controller');
97
+ return;
118
98
  }
99
+ const mc = new MessageChannel();
100
+ mc.port1.onmessage = (ev) => resolve(ev.data as T);
101
+ navigator.serviceWorker.controller.postMessage(msg, [mc.port2]);
119
102
  });
120
103
  }
121
104
 
122
- /**
123
- * Checks if the given URL is present in the Service Worker's cache.
124
- */
125
- static isCached(url: string): Promise<boolean> {
126
- return new Promise((resolve) => {
127
- if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
128
- const mc = new MessageChannel();
129
- mc.port1.onmessage = (ev) => {
130
- if (ev.data && typeof ev.data.cached === 'boolean') {
131
- resolve(ev.data.cached);
132
- } else {
133
- resolve(false);
134
- }
135
- };
136
- navigator.serviceWorker.controller.postMessage({ type: 'HAS_URL', url }, [mc.port2]);
137
- } else {
138
- // Fallback: try CacheStorage directly (same-origin only)
139
- caches.match(url).then(match => resolve(!!match)).catch(() => resolve(false));
105
+ // ── Cache Management ────────────────────────────────────────────
106
+
107
+ /** Add a single URL to the SW cache. */
108
+ static async addToCache(url: string): Promise<void> {
109
+ if (navigator.serviceWorker?.controller) {
110
+ this._postMessage({ type: 'CACHE_URL', url });
111
+ return;
112
+ }
113
+ // Fallback: use Cache API directly
114
+ try {
115
+ const cache = await caches.open('purper-v1');
116
+ const response = await fetch(url);
117
+ if (response.ok) {
118
+ await cache.put(url, response);
119
+ console.log('[ServiceWorker]: Resource cached directly:', url);
140
120
  }
141
- });
121
+ } catch (e) {
122
+ console.warn('[ServiceWorker]: Failed to cache resource:', url, e);
123
+ }
142
124
  }
143
125
 
126
+ /** Add multiple URLs to the SW cache in one batch. */
127
+ static addAllToCache(urls: string[]): void {
128
+ this._postMessage({ type: 'CACHE_URLS', urls });
129
+ }
130
+
131
+ /** Remove a URL from the SW cache. Returns true if it was found and deleted. */
132
+ static async removeFromCache(url: string): Promise<boolean> {
133
+ try {
134
+ const data = await this._request<{ deleted: boolean }>({ type: 'REMOVE_URL', url });
135
+ return data.deleted;
136
+ } catch {
137
+ // Fallback
138
+ try {
139
+ const cache = await caches.open('purper-v1');
140
+ return await cache.delete(url);
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ }
146
+
147
+ /** Return all URLs currently in the SW cache. */
148
+ static async getCacheKeys(): Promise<string[]> {
149
+ try {
150
+ const data = await this._request<{ keys: string[] }>({ type: 'GET_CACHE_KEYS' });
151
+ return data.keys;
152
+ } catch {
153
+ return [];
154
+ }
155
+ }
156
+
157
+ /** Wipe the entire SW cache. */
158
+ static async clearCache(): Promise<boolean> {
159
+ try {
160
+ const data = await this._request<{ cleared: boolean }>({ type: 'CLEAR_CACHE' });
161
+ return data.cleared;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ /** Check whether a URL exists in the SW cache. */
168
+ static async isCached(url: string): Promise<boolean> {
169
+ try {
170
+ const data = await this._request<{ cached: boolean }>({ type: 'HAS_URL', url });
171
+ return data.cached;
172
+ } catch {
173
+ // Fallback: CacheStorage directly
174
+ try {
175
+ return !!(await caches.match(url));
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+ }
181
+
182
+ // ── Version & lifecycle ─────────────────────────────────────────
183
+
184
+ /** Ask the waiting SW to activate immediately. */
185
+ static skipWaiting(): void {
186
+ this._postMessage({ type: 'SKIP_WAITING' });
187
+ }
188
+
189
+ /** Get the version string from the running SW. */
190
+ static async getVersion(): Promise<string> {
191
+ const data = await this._request<{ version: string }>({ type: 'GET_VERSION' });
192
+ return data.version;
193
+ }
194
+
195
+ // ── Connectivity ────────────────────────────────────────────────
196
+
144
197
  /**
145
- * Checks if the browser is online.
198
+ * Active connectivity probe makes a real network request.
199
+ * Unlike `online` observable (which relies on browser events), this
200
+ * detects captive portals and lie-fi.
146
201
  */
147
- static async isOnline(): Promise<boolean> {
202
+ static async isOnline(probeURL?: string): Promise<boolean> {
148
203
  try {
149
- const response = await fetch('./index.html', { cache: 'no-store' });
150
- return response && response.ok;
204
+ // Try SW-side probe first
205
+ const data = await this._request<{ online: boolean }>({
206
+ type: 'IS_ONLINE',
207
+ url: probeURL ?? '/index.html'
208
+ });
209
+ this.online.setObject(data.online);
210
+ return data.online;
151
211
  } catch {
152
- return false;
212
+ // Fallback: client-side probe
213
+ try {
214
+ const response = await fetch(probeURL ?? './index.html', { cache: 'no-store', method: 'HEAD' });
215
+ const result = response.ok;
216
+ this.online.setObject(result);
217
+ return result;
218
+ } catch {
219
+ this.online.setObject(false);
220
+ return false;
221
+ }
153
222
  }
154
223
  }
224
+
225
+ /** Current registration, if any. */
226
+ static get registration(): ServiceWorkerRegistration | undefined {
227
+ return this._registration;
228
+ }
155
229
  }