@swoff/cli 0.0.2 → 0.1.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.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Swoff Files Generator
3
3
  *
4
- * Generates all pattern files (sw-injector, hooks, components, utils, build scripts, manifest)
5
- * based on swoff.config.json features and project language.
4
+ * Generates framework-agnostic pattern files based on swoff.config.json features.
5
+ * All files go into swoff/ directory (or public/ for manifest).
6
6
  *
7
7
  * CLI Usage:
8
8
  * node swoff-files-generator.js --project-root <path> --package-dir <path> --language <ts|js> --config-path <path>
@@ -24,6 +24,7 @@ const defaultConfig = {
24
24
  version: "from-package",
25
25
  minSupportedVersion: "0.0.0",
26
26
  serviceWorker: {
27
+ autoRegister: true,
27
28
  autoUpdate: false,
28
29
  defaultStrategy: "cache-first",
29
30
  strategies: {},
@@ -38,11 +39,19 @@ const defaultConfig = {
38
39
  crossTabSync: true,
39
40
  tagInvalidation: true,
40
41
  clientRegistration: true,
42
+ indexeddb: false,
43
+ },
44
+ pwa: {
45
+ preventDefaultInstall: false,
41
46
  },
42
47
  build: {
43
48
  outputDir: "dist",
44
49
  swFilename: "sw",
45
50
  },
51
+ database: {
52
+ name: "app-db",
53
+ stores: [],
54
+ },
46
55
  };
47
56
  let config = defaultConfig;
48
57
  if (existsSync(configPath)) {
@@ -57,1004 +66,1322 @@ else {
57
66
  console.log("No config found, using defaults");
58
67
  }
59
68
  const ext = language === "ts" ? "ts" : "js";
69
+ const swoffDir = join(projectRoot, "swoff");
60
70
  const generatedFiles = [];
71
+ function ensureSwoffDir() {
72
+ if (!existsSync(swoffDir))
73
+ mkdirSync(swoffDir, { recursive: true });
74
+ }
61
75
  function generateSwInjector() {
62
- const outputDir = join(projectRoot, "src");
63
- if (!existsSync(outputDir))
64
- mkdirSync(outputDir, { recursive: true });
65
- if (language === "ts") {
66
- const code = `import { useEffect, useState } from 'react';
76
+ ensureSwoffDir();
77
+ const autoRegister = config.serviceWorker.autoRegister;
78
+ const autoUpdate = config.serviceWorker.autoUpdate;
79
+ const code = `/**
80
+ * Swoff Service Worker Injector
81
+ * Framework-agnostic client registration with version checking.
82
+ *
83
+ * Usage:
84
+ * import { initServiceWorker, shouldRegisterSW } from './swoff/sw-injector.js';
85
+ *
86
+ * // Call in your app entry point (e.g., main.tsx, app.js):
87
+ * if (shouldRegisterSW()) {
88
+ * initServiceWorker();
89
+ * }
90
+ *
91
+ * // Or defer until after onboarding:
92
+ * function myShouldRegister() {
93
+ * return localStorage.getItem('onboarding-complete') === 'true';
94
+ * }
95
+ * if (myShouldRegister()) {
96
+ * initServiceWorker();
97
+ * }
98
+ *
99
+ * Window events:
100
+ * sw-version-detected - Version info available on window
101
+ * sw-update-available - New version ready for user consent (detail: { version })
102
+ * sw-progress - Download progress (detail: { percent, downloaded, total })
103
+ * sw-ready - SW active and controlling page
104
+ * sw-error - SW registration failed
105
+ *
106
+ * Window properties:
107
+ * window.latestSWVersion - Latest version from version.json
108
+ * window.currentSWVersion - Active SW version
109
+ * window.swAvailableVersion - Pending update version
110
+ * window.swUpdateRequired - Forced update needed (version < minSupportedVersion)
111
+ * window.swMinSupportedVersion - Minimum supported version from version.json
112
+ * window.swReady - SW is active
113
+ * window.swError - Registration failed
114
+ */
67
115
 
68
- export function shouldRegister(): boolean {
69
- return typeof window !== 'undefined' && 'serviceWorker' in navigator;
116
+ const AUTO_REGISTER = ${autoRegister};
117
+ const AUTO_UPDATE = ${autoUpdate};
118
+
119
+ async function checkForUpdate() {
120
+ const response = await fetch("/version.json");
121
+ if (!response.ok) {
122
+ throw new Error("Failed to fetch version.json");
123
+ }
124
+ return response.json();
70
125
  }
71
126
 
72
- export function useServiceWorkerRegistration(options: {
73
- onSuccess?: (registration: ServiceWorkerRegistration) => void;
74
- onUpdate?: (registration: ServiceWorkerRegistration) => void;
75
- onError?: (error: Error) => void;
76
- swPath?: string;
77
- } = {}) {
78
- const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
79
- const [isReady, setIsReady] = useState(false);
127
+ async function doRegisterServiceWorker(version) {
128
+ const swUrl = \`/sw-v\${version}.js\`;
129
+ const registration = await navigator.serviceWorker.register(swUrl);
130
+ localStorage.setItem("swRegisteredVersion", version);
131
+ window.currentSWVersion = version;
132
+ window.swRegisteredVersion = version;
133
+ window.dispatchEvent(new CustomEvent("sw-version-detected"));
134
+ window.dispatchEvent(new CustomEvent("sw-ready"));
135
+ return registration;
136
+ }
80
137
 
81
- useEffect(() => {
82
- if (!shouldRegister()) return;
138
+ export function shouldRegisterSW() {
139
+ if (!AUTO_REGISTER) return false;
83
140
 
84
- const register = async () => {
85
- try {
86
- const swPath = options.swPath || '/sw.js';
87
- const reg = await navigator.serviceWorker.register(swPath);
88
-
89
- reg.addEventListener('updatefound', () => {
90
- const newWorker = reg.installing;
91
- if (newWorker) {
92
- newWorker.addEventListener('statechange', () => {
93
- if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
94
- options.onUpdate?.(reg);
95
- }
96
- });
97
- }
98
- });
141
+ // Add custom conditions here. Return false to prevent registration.
142
+ // Examples:
143
+ // - Check if user completed onboarding
144
+ // - Check if user accepted terms
145
+ // - Check if user is on a slow connection
146
+ //
147
+ // if (!localStorage.getItem("onboarding-complete")) return false;
148
+ // if (navigator.connection?.effectiveType === "slow-2g") return false;
149
+
150
+ return true;
151
+ }
152
+
153
+ export async function initServiceWorker() {
154
+ if (!("serviceWorker" in navigator)) {
155
+ console.warn("Service Workers not supported");
156
+ return;
157
+ }
99
158
 
100
- if (reg.active) {
101
- options.onSuccess?.(reg);
159
+ try {
160
+ const manifest = await checkForUpdate();
161
+ const currentVersion = localStorage.getItem("swRegisteredVersion");
162
+ window.latestSWVersion = manifest.version;
163
+ window.swMinSupportedVersion = manifest.minSupportedVersion || "0.0.0";
164
+
165
+ if (currentVersion === manifest.version) {
166
+ const registration = await navigator.serviceWorker.getRegistration();
167
+ if (registration && registration.active) {
168
+ window.currentSWVersion = currentVersion;
169
+ window.dispatchEvent(new CustomEvent("sw-version-detected"));
170
+ window.dispatchEvent(new CustomEvent("sw-ready"));
171
+ }
172
+ } else if (currentVersion && currentVersion !== manifest.version) {
173
+ window.swAvailableVersion = manifest.version;
174
+ window.swUpdateRequired =
175
+ currentVersion < (manifest.minSupportedVersion || "0.0.0");
176
+
177
+ if (AUTO_UPDATE) {
178
+ const registration = await navigator.serviceWorker.getRegistration();
179
+ if (registration && registration.waiting) {
180
+ registration.waiting.postMessage({ type: "SKIP_WAITING" });
102
181
  } else {
103
- reg.addEventListener('statechange', (e) => {
104
- if ((e.target as ServiceWorker).state === 'activated') {
105
- options.onSuccess?.(reg);
106
- }
107
- });
182
+ await doRegisterServiceWorker(manifest.version);
108
183
  }
109
-
110
- setRegistration(reg);
111
- setIsReady(true);
112
- } catch (err) {
113
- options.onError?.(err instanceof Error ? err : new Error(String(err)));
184
+ } else {
185
+ window.dispatchEvent(
186
+ new CustomEvent("sw-update-available", {
187
+ detail: { version: manifest.version },
188
+ })
189
+ );
114
190
  }
115
- };
116
-
117
- register();
118
- }, []);
119
-
120
- return { registration, isReady };
121
- }
122
-
123
- export function registerServiceWorker(swPath?: string): Promise<ServiceWorkerRegistration> {
124
- if (!shouldRegister()) {
125
- return Promise.reject(new Error('Service workers not supported'));
191
+ } else {
192
+ await doRegisterServiceWorker(manifest.version);
193
+ }
194
+ } catch (error) {
195
+ console.error("Service Worker initialization failed:", error);
196
+ window.swError = true;
197
+ window.dispatchEvent(new CustomEvent("sw-error"));
126
198
  }
127
- return navigator.serviceWorker.register(swPath || '/sw.js');
128
199
  }
129
200
 
130
- export function unregisterServiceWorker(): Promise<void> {
131
- return navigator.serviceWorker.ready.then((reg) => {
132
- return reg.unregister();
201
+ export function handleUpdateApproved(newVersion) {
202
+ return navigator.serviceWorker.getRegistration().then((registration) => {
203
+ if (registration && registration.waiting) {
204
+ registration.waiting.postMessage({ type: "SKIP_WAITING" });
205
+ registration.addEventListener("controllerchange", () => {
206
+ window.location.reload();
207
+ });
208
+ } else {
209
+ return doRegisterServiceWorker(newVersion).then(() => {
210
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
211
+ window.location.reload();
212
+ });
213
+ });
214
+ }
133
215
  });
134
216
  }
135
- `;
136
- writeFileSync(join(outputDir, `sw-injector.${ext}`), code);
137
- generatedFiles.push(`src/sw-injector.${ext}`);
217
+
218
+ export function skipWaiting() {
219
+ return navigator.serviceWorker.ready.then((registration) => {
220
+ if (registration.waiting) {
221
+ registration.waiting.postMessage({ type: "SKIP_WAITING" });
138
222
  }
139
- else {
140
- const code = `export function shouldRegister() {
141
- return typeof window !== 'undefined' && 'serviceWorker' in navigator;
223
+ });
142
224
  }
143
225
 
144
- export async function registerServiceWorker(swPath) {
145
- if (!shouldRegister()) {
146
- throw new Error('Service workers not supported');
147
- }
148
- return navigator.serviceWorker.register(swPath || '/sw.js');
226
+ if (typeof window !== "undefined" && "serviceWorker" in navigator) {
227
+ navigator.serviceWorker.addEventListener("message", (event) => {
228
+ if (event.data.type === "SW_PROGRESS") {
229
+ const { percent, downloaded, total } = event.data;
230
+ window.dispatchEvent(
231
+ new CustomEvent("sw-progress", {
232
+ detail: { percent, downloaded, total },
233
+ })
234
+ );
235
+ }
236
+ if (event.data.type === "BACKGROUND_SYNC_COMPLETE") {
237
+ const { succeeded, failed, tags } = event.data.detail;
238
+ window.dispatchEvent(
239
+ new CustomEvent("mutation-sync-complete", {
240
+ detail: { succeeded, failed },
241
+ })
242
+ );
243
+ if (tags && tags.length > 0) {
244
+ window.dispatchEvent(
245
+ new CustomEvent("cache-invalidated", { detail: { tags } })
246
+ );
247
+ }
248
+ window.dispatchEvent(new CustomEvent("mutation-queue-changed"));
249
+ }
250
+ });
149
251
  }
150
-
151
- export async function unregisterServiceWorker() {
152
- const reg = await navigator.serviceWorker.ready;
153
- return reg.unregister();
252
+ `;
253
+ writeFileSync(join(swoffDir, `sw-injector.${ext}`), code);
254
+ generatedFiles.push(`swoff/sw-injector.${ext}`);
154
255
  }
256
+ function generateFetchWrapper() {
257
+ ensureSwoffDir();
258
+ const code = `/**
259
+ * Swoff Fetch Wrapper
260
+ * Framework-agnostic fetch with cache strategy, tags, and query deduplication.
261
+ *
262
+ * Usage:
263
+ * import { fetchWithCache } from './swoff/fetch-wrapper.js';
264
+ *
265
+ * // GET - cached with tag
266
+ * const todos = await fetchWithCache("/api/todos", { tags: ["todos"] }).then(r => r.json());
267
+ *
268
+ * // POST - mutation (passes through to server)
269
+ * await fetchWithCache("/api/todos", {
270
+ * method: "POST",
271
+ * body: JSON.stringify({ title: "New task" }),
272
+ * });
273
+ *
274
+ * // Stale-while-revalidate
275
+ * const data = await fetchWithCache("/api/data", {
276
+ * tags: ["data"],
277
+ * staleWhileRevalidate: true,
278
+ * }).then(r => r.json());
279
+ */
155
280
 
156
- export function useServiceWorkerRegistration(options = {}) {
157
- const [registration, setRegistration] = useState(null);
158
- const [isReady, setIsReady] = useState(false);
281
+ const inFlightRequests = new Map();
159
282
 
160
- useEffect(() => {
161
- if (!shouldRegister()) return;
283
+ export async function fetchWithCache(input, options = {}) {
284
+ const headers = new Headers(options.headers);
285
+ const method = options.method || "GET";
162
286
 
163
- registerServiceWorker(options.swPath)
164
- .then((reg) => {
165
- setRegistration(reg);
166
- setIsReady(true);
167
- options.onSuccess?.(reg);
168
- })
169
- .catch((err) => {
170
- options.onError?.(err);
171
- });
172
- }, []);
287
+ if (!headers.has("X-SW-Cache-Strategy")) {
288
+ headers.set(
289
+ "X-SW-Cache-Strategy",
290
+ method === "GET" || method === "HEAD" ? "read" : "mutation"
291
+ );
292
+ }
173
293
 
174
- return { registration, isReady };
175
- }
176
- `;
177
- writeFileSync(join(outputDir, `sw-injector.${ext}`), code);
178
- generatedFiles.push(`src/sw-injector.${ext}`);
179
- }
180
- }
181
- function generateSwGeneratorBuildScript() {
182
- const outputDir = join(projectRoot, "swoff");
183
- if (!existsSync(outputDir))
184
- mkdirSync(outputDir, { recursive: true });
185
- if (language === "ts") {
186
- const code = `#!/usr/bin/env node
294
+ if (options.staleWhileRevalidate) {
295
+ headers.set("X-SW-Stale", "true");
296
+ }
187
297
 
188
- import { spawn } from 'child_process';
189
- import { existsSync } from 'fs';
190
- import { join } from 'path';
298
+ if (options.tags && options.tags.length > 0) {
299
+ headers.set("X-SW-Cache-Tags", options.tags.join(","));
300
+ }
191
301
 
192
- const SWOFF_CONFIG = 'swoff.config.json';
193
- const DIST_DIR = 'dist';
302
+ if (method === "GET" || method === "HEAD") {
303
+ const url = typeof input === "string" ? input : input.url;
304
+ if (inFlightRequests.has(url)) {
305
+ return inFlightRequests.get(url).then((r) => r.clone());
306
+ }
307
+ const promise = fetch(input, { ...options, headers }).finally(() => {
308
+ inFlightRequests.delete(url);
309
+ });
310
+ inFlightRequests.set(url, promise);
311
+ return promise;
312
+ }
194
313
 
195
- if (!existsSync(SWOFF_CONFIG)) {
196
- console.error('Error: swoff.config.json not found');
197
- console.log('Run "npx @swoff/cli init" to create one');
198
- process.exit(1);
314
+ return fetch(input, { ...options, headers });
199
315
  }
200
316
 
201
- console.log('šŸ”§ Building service worker...');
202
-
203
- const proc = spawn(
204
- 'npx',
205
- ['@swoff/cli', 'generate', '--sw-only'],
206
- { stdio: 'inherit', shell: true }
207
- );
208
-
209
- proc.on('close', (code) => {
210
- if (code === 0) {
211
- console.log('āœ… Service worker build complete');
212
- } else {
213
- console.error('āŒ Build failed');
214
- process.exit(code || 1);
317
+ export async function fetchWithCacheOrQueue(input, options = {}) {
318
+ if (!navigator.onLine) {
319
+ if (options.method === "GET" || options.method === "HEAD") {
320
+ const cached = await caches.match(input);
321
+ if (cached) return cached;
322
+ throw new Error("Offline: no cached data");
323
+ } else {
324
+ throw new Error("Offline: mutation queued");
325
+ }
215
326
  }
216
- });
327
+ return fetchWithCache(input, options);
328
+ }
217
329
  `;
218
- writeFileSync(join(outputDir, `sw-generator.${ext}`), code);
219
- generatedFiles.push(`swoff/sw-generator.${ext}`);
220
- }
221
- else {
222
- const code = `#!/usr/bin/env node
330
+ writeFileSync(join(swoffDir, "fetch-wrapper.js"), code);
331
+ generatedFiles.push("swoff/fetch-wrapper.js");
332
+ }
333
+ function generateCache() {
334
+ ensureSwoffDir();
335
+ const code = `/**
336
+ * Swoff Cache Invalidation & Cross-Tab Sync
337
+ * Framework-agnostic cache tag invalidation and cross-tab synchronization.
338
+ *
339
+ * Usage:
340
+ * import { invalidateByTag, initCrossTabSync } from './swoff/cache.js';
341
+ *
342
+ * // Call once during app init
343
+ * initCrossTabSync();
344
+ *
345
+ * // After a mutation, invalidate related cache
346
+ * await invalidateByTag("todos");
347
+ */
223
348
 
224
- const { spawn } = require('child_process');
225
- const { existsSync } = require('fs');
226
- const path = require('path');
349
+ export async function invalidateByTag(tag) {
350
+ if (!navigator.serviceWorker?.controller) return;
227
351
 
228
- const SWOFF_CONFIG = 'swoff.config.json';
352
+ navigator.serviceWorker.controller.postMessage({
353
+ type: "INVALIDATE_TAG",
354
+ tag,
355
+ });
229
356
 
230
- if (!existsSync(SWOFF_CONFIG)) {
231
- console.error('Error: swoff.config.json not found');
232
- console.log('Run "npx @swoff/cli init" to create one');
233
- process.exit(1);
357
+ window.dispatchEvent(
358
+ new CustomEvent("cache-invalidated", { detail: { tags: [tag] } })
359
+ );
234
360
  }
235
361
 
236
- console.log('šŸ”§ Building service worker...');
237
-
238
- const proc = spawn(
239
- 'npx',
240
- ['@swoff/cli', 'generate', '--sw-only'],
241
- { stdio: 'inherit', shell: true }
242
- );
243
-
244
- proc.on('close', (code) => {
245
- if (code === 0) {
246
- console.log('āœ… Service worker build complete');
247
- } else {
248
- console.error('āŒ Build failed');
249
- process.exit(code || 1);
362
+ export async function invalidateByTags(tags) {
363
+ for (const tag of tags) {
364
+ await invalidateByTag(tag);
250
365
  }
251
- });
252
- `;
253
- writeFileSync(join(outputDir, `sw-generator.${ext}`), code);
254
- generatedFiles.push(`swoff/sw-generator.${ext}`);
255
- }
256
366
  }
257
- function generateSwTemplate() {
258
- const outputDir = join(projectRoot, "swoff");
259
- if (!existsSync(outputDir))
260
- mkdirSync(outputDir, { recursive: true });
261
- const template = `let CACHE_NAME = "";
262
- let ASSETS_TO_CACHE = [];
263
367
 
264
- // [[INSTALL_HANDLER]]
265
- // [[ACTIVATE_HANDLER]]
266
- // [[FETCH_HANDLER]]
368
+ export function initCrossTabSync() {
369
+ if (!navigator.serviceWorker) return;
267
370
 
268
- const SWOFF = {
269
- cache: {
270
- async get(key) {
271
- const cache = await caches.open(CACHE_NAME);
272
- return cache.match(key);
273
- },
274
- async put(request, response) {
275
- const cache = await caches.open(CACHE_NAME);
276
- await cache.put(request, response.clone());
277
- return response;
278
- },
279
- async delete(key) {
280
- const cache = await caches.open(CACHE_NAME);
281
- return cache.delete(key);
282
- },
283
- async keys() {
284
- const cache = await caches.open(CACHE_NAME);
285
- return cache.keys();
371
+ navigator.serviceWorker.addEventListener("message", (event) => {
372
+ if (event.data.type === "TAG_INVALIDATED" && event.data.tag) {
373
+ window.dispatchEvent(
374
+ new CustomEvent("cache-invalidated", {
375
+ detail: { tags: [event.data.tag] },
376
+ })
377
+ );
286
378
  }
287
- }
288
- };
289
-
290
- if (typeof self !== 'undefined') {
291
- self.SWOFF = SWOFF;
379
+ });
292
380
  }
293
381
  `;
294
- writeFileSync(join(outputDir, "sw-template.js"), template);
295
- generatedFiles.push("swoff/sw-template.js");
296
- }
297
- function generateTypeDefinitions() {
298
- if (language !== "ts")
299
- return;
300
- const outputDir = join(projectRoot, "src");
301
- if (!existsSync(outputDir))
302
- mkdirSync(outputDir, { recursive: true });
303
- const code = `declare module '*.service-worker' {
304
- const serviceWorker: ServiceWorkerGlobalScope;
305
- export default serviceWorker;
306
- }
307
-
308
- interface SWOFFCache {
309
- get(key: Request | string): Promise<Response | undefined>;
310
- put(request: Request | string, response: Response): Promise<Response>;
311
- delete(key: Request | string): Promise<boolean>;
312
- keys(): Promise<Request[]>;
382
+ writeFileSync(join(swoffDir, "cache.js"), code);
383
+ generatedFiles.push("swoff/cache.js");
313
384
  }
385
+ function generateMutationQueue() {
386
+ ensureSwoffDir();
387
+ const code = `/**
388
+ * Swoff Mutation Queue
389
+ * Queue offline writes and sync when connection returns.
390
+ *
391
+ * Usage:
392
+ * import { queueMutation, processMutationQueue, getPendingCount } from './swoff/mutation-queue.js';
393
+ *
394
+ * // Queue a mutation
395
+ * await queueMutation({
396
+ * method: "POST",
397
+ * url: "/api/todos",
398
+ * body: { title: "Grocery" },
399
+ * tags: ["todos"],
400
+ * storeName: "todos",
401
+ * tempId: "temp_abc123",
402
+ * });
403
+ *
404
+ * // Auto-processes on online event
405
+ */
314
406
 
315
- interface SWOFF {
316
- cache: SWOFFCache;
407
+ const DB_NAME = "swoff-queue";
408
+ const STORE_NAME = "mutations";
409
+ const MAX_RETRIES = 5;
410
+
411
+ function openQueueDB() {
412
+ return new Promise((resolve, reject) => {
413
+ const request = indexedDB.open(DB_NAME, 1);
414
+ request.onupgradeneeded = (e) => {
415
+ const db = e.target.result;
416
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
417
+ const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
418
+ store.createIndex("by-timestamp", "timestamp");
419
+ }
420
+ };
421
+ request.onsuccess = (e) => resolve(e.target.result);
422
+ request.onerror = (e) => reject(e.target.error);
423
+ });
317
424
  }
318
425
 
319
- declare const SWOFF: SWOFF | undefined;
426
+ let isSyncing = false;
427
+
428
+ export async function queueMutation(mutation) {
429
+ const db = await openQueueDB();
430
+ const tx = db.transaction(STORE_NAME, "readwrite");
431
+ const store = tx.objectStore(STORE_NAME);
432
+
433
+ store.add({
434
+ id: crypto.randomUUID(),
435
+ method: mutation.method,
436
+ url: mutation.url,
437
+ body: mutation.body,
438
+ headers: mutation.headers || {},
439
+ previousData: mutation.previousData || null,
440
+ timestamp: Date.now(),
441
+ retryCount: 0,
442
+ tags: mutation.tags || [],
443
+ storeName: mutation.storeName || null,
444
+ tempId: mutation.tempId || null,
445
+ });
320
446
 
321
- declare let CACHE_NAME: string;
322
- declare let ASSETS_TO_CACHE: string[];
323
- `;
324
- writeFileSync(join(outputDir, "swoff.d.ts"), code);
325
- generatedFiles.push("src/swoff.d.ts");
326
- }
327
- function generateOfflineHooks() {
328
- const outputDir = join(projectRoot, "src", "hooks");
329
- if (!existsSync(outputDir))
330
- mkdirSync(outputDir, { recursive: true });
331
- const useOffline = language === "ts"
332
- ? `import { useState, useEffect } from 'react';
447
+ await new Promise((resolve, reject) => {
448
+ tx.oncomplete = () => resolve();
449
+ tx.onerror = () => reject(tx.error);
450
+ });
333
451
 
334
- export interface UseOfflineResult {
335
- isOnline: boolean;
336
- isOffline: boolean;
452
+ window.dispatchEvent(new CustomEvent("mutation-queue-changed"));
337
453
  }
338
454
 
339
- export function useOffline(): UseOfflineResult {
340
- const [isOnline, setIsOnline] = useState(() => navigator.onLine);
455
+ export async function processMutationQueue() {
456
+ if (!navigator.onLine || isSyncing) return;
457
+ isSyncing = true;
458
+
459
+ try {
460
+ const db = await openQueueDB();
461
+ const tx = db.transaction(STORE_NAME, "readonly");
462
+ const store = tx.objectStore(STORE_NAME);
463
+ const index = store.index("by-timestamp");
464
+ const queue = await new Promise((resolve, reject) => {
465
+ const request = index.getAll();
466
+ request.onsuccess = () => resolve(request.result);
467
+ request.onerror = () => reject(request.error);
468
+ });
469
+
470
+ let succeeded = 0;
471
+ let failed = 0;
472
+
473
+ for (const item of queue) {
474
+ if (item.retryCount >= MAX_RETRIES) {
475
+ await rollbackMutation(item);
476
+ await removeFromQueue(item.id);
477
+ failed++;
478
+ continue;
479
+ }
341
480
 
342
- useEffect(() => {
343
- const handleOnline = () => setIsOnline(true);
344
- const handleOffline = () => setIsOnline(false);
481
+ try {
482
+ const response = await fetch(item.url, {
483
+ method: item.method,
484
+ headers: {
485
+ "Content-Type": "application/json",
486
+ ...item.headers,
487
+ },
488
+ body: JSON.stringify(item.body),
489
+ });
345
490
 
346
- window.addEventListener('online', handleOnline);
347
- window.addEventListener('offline', handleOffline);
491
+ if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
348
492
 
349
- return () => {
350
- window.removeEventListener('online', handleOnline);
351
- window.removeEventListener('offline', handleOffline);
352
- };
353
- }, []);
493
+ const serverData = await response.json();
354
494
 
355
- return { isOnline, isOffline: !isOnline };
356
- }
357
- `
358
- : `import { useState, useEffect } from 'react';
495
+ if (item.tempId && item.storeName) {
496
+ await reconcileRecord(item.storeName, item.tempId, serverData);
497
+ }
359
498
 
360
- export function useOffline() {
361
- const [isOnline, setIsOnline] = useState(() => navigator.onLine);
499
+ if (item.tags && item.tags.length > 0) {
500
+ const { invalidateByTags } = await import("./cache.js");
501
+ await invalidateByTags(item.tags);
502
+ }
362
503
 
363
- useEffect(() => {
364
- const handleOnline = () => setIsOnline(true);
365
- const handleOffline = () => setIsOnline(false);
504
+ await removeFromQueue(item.id);
505
+ succeeded++;
506
+ } catch {
507
+ item.retryCount++;
508
+ await updateInQueue(item);
509
+ failed++;
510
+ }
511
+ }
366
512
 
367
- window.addEventListener('online', handleOnline);
368
- window.addEventListener('offline', handleOffline);
513
+ window.dispatchEvent(
514
+ new CustomEvent("mutation-sync-complete", {
515
+ detail: { succeeded, failed },
516
+ })
517
+ );
518
+ } finally {
519
+ isSyncing = false;
520
+ window.dispatchEvent(new CustomEvent("mutation-queue-changed"));
521
+ }
522
+ }
369
523
 
370
- return () => {
371
- window.removeEventListener('online', handleOnline);
372
- window.removeEventListener('offline', handleOffline);
373
- };
374
- }, []);
524
+ async function removeFromQueue(id) {
525
+ const db = await openQueueDB();
526
+ const tx = db.transaction(STORE_NAME, "readwrite");
527
+ tx.objectStore(STORE_NAME).delete(id);
528
+ await new Promise((resolve, reject) => {
529
+ tx.oncomplete = () => resolve();
530
+ tx.onerror = () => reject(tx.error);
531
+ });
532
+ }
375
533
 
376
- return { isOnline, isOffline: !isOnline };
534
+ async function updateInQueue(item) {
535
+ const db = await openQueueDB();
536
+ const tx = db.transaction(STORE_NAME, "readwrite");
537
+ tx.objectStore(STORE_NAME).put(item);
538
+ await new Promise((resolve, reject) => {
539
+ tx.oncomplete = () => resolve();
540
+ tx.onerror = () => reject(tx.error);
541
+ });
377
542
  }
378
- `;
379
- writeFileSync(join(outputDir, `useOffline.${ext}`), useOffline);
380
- generatedFiles.push(`src/hooks/useOffline.${ext}`);
381
- const useApiData = language === "ts"
382
- ? `import { useState, useEffect, useCallback } from 'react';
383
543
 
384
- export interface UseApiDataOptions extends RequestInit {
385
- skip?: boolean;
544
+ async function rollbackMutation(item) {
545
+ if (!item.storeName) return;
546
+
547
+ if (item.method === "POST" && item.tempId) {
548
+ const { deleteRecord } = await import("./store.js");
549
+ await deleteRecord(item.storeName, item.tempId);
550
+ } else if (
551
+ (item.method === "PUT" || item.method === "PATCH") &&
552
+ item.previousData
553
+ ) {
554
+ const { putRecord } = await import("./store.js");
555
+ await putRecord(item.storeName, { ...item.previousData, $synced: true });
556
+ } else if (item.method === "DELETE" && item.tempId && item.previousData) {
557
+ const { putRecord } = await import("./store.js");
558
+ await putRecord(item.storeName, { ...item.previousData, $synced: true });
559
+ }
560
+
561
+ window.dispatchEvent(
562
+ new CustomEvent("mutation-rollback", {
563
+ detail: {
564
+ method: item.method,
565
+ url: item.url,
566
+ tempId: item.tempId,
567
+ previousData: item.previousData,
568
+ },
569
+ })
570
+ );
386
571
  }
387
572
 
388
- export interface UseApiDataResult<T> {
389
- data: T | null;
390
- loading: boolean;
391
- error: Error | null;
392
- refetch: () => Promise<T | null>;
573
+ async function reconcileRecord(storeName, tempId, serverData) {
574
+ const { getRecord, putRecord, deleteRecord } = await import("./store.js");
575
+ const existing = await getRecord(storeName, tempId);
576
+ if (!existing) return;
577
+
578
+ const reconciled = {
579
+ ...existing,
580
+ ...serverData,
581
+ id: serverData.id,
582
+ $synced: true,
583
+ $syncedAt: Date.now(),
584
+ };
585
+
586
+ await putRecord(storeName, reconciled);
587
+
588
+ if (String(tempId) !== String(serverData.id)) {
589
+ await deleteRecord(storeName, tempId);
590
+ }
393
591
  }
394
592
 
395
- export function useApiData<T = unknown>(
396
- endpoint: string,
397
- options: UseApiDataOptions = {}
398
- ): UseApiDataResult<T> {
399
- const [data, setData] = useState<T | null>(null);
400
- const [loading, setLoading] = useState(true);
401
- const [error, setError] = useState<Error | null>(null);
593
+ export async function getPendingCount() {
594
+ const db = await openQueueDB();
595
+ return new Promise((resolve, reject) => {
596
+ const tx = db.transaction(STORE_NAME, "readonly");
597
+ const request = tx.objectStore(STORE_NAME).count();
598
+ request.onsuccess = () => resolve(request.result);
599
+ request.onerror = () => reject(request.error);
600
+ });
601
+ }
402
602
 
403
- const fetchData = useCallback(async (fetchOptions: RequestInit = {}) => {
404
- try {
405
- setLoading(true);
406
- setError(null);
603
+ window.addEventListener("online", processMutationQueue);
604
+ `;
605
+ writeFileSync(join(swoffDir, "mutation-queue.js"), code);
606
+ generatedFiles.push("swoff/mutation-queue.js");
607
+ }
608
+ function generateBackgroundSync() {
609
+ ensureSwoffDir();
610
+ const code = `/**
611
+ * Swoff Background Sync
612
+ * Register sync events for processing mutation queue after tab close.
613
+ * Falls back to online event listener in unsupported browsers.
614
+ *
615
+ * Usage:
616
+ * import { syncWhenPossible } from './swoff/background-sync.js';
617
+ *
618
+ * await syncWhenPossible({
619
+ * method: "POST",
620
+ * url: "/api/todos",
621
+ * body: { title: "Grocery" },
622
+ * tags: ["todos"],
623
+ * storeName: "todos",
624
+ * tempId: "temp_abc123",
625
+ * });
626
+ */
407
627
 
408
- const response = await fetch(endpoint, { ...options, ...fetchOptions });
628
+ import { queueMutation, processMutationQueue, getPendingCount } from "./mutation-queue.js";
409
629
 
410
- if (!response.ok) {
411
- throw new Error(\`HTTP error! status: \${response.status}\`);
412
- }
630
+ const SYNC_TAG = "sync-mutations";
413
631
 
414
- const result: T = await response.json();
415
- setData(result);
416
- return result;
417
- } catch (err) {
418
- const errorMessage = err instanceof Error ? err.message : 'An error occurred';
419
- setError(new Error(errorMessage));
632
+ async function registerSync() {
633
+ if (!("serviceWorker" in navigator) || !("SyncManager" in window)) {
634
+ window.addEventListener("online", processMutationQueue, { once: true });
635
+ return;
636
+ }
420
637
 
421
- if (!navigator.onLine && data) {
422
- return data;
423
- }
638
+ try {
639
+ const registration = await navigator.serviceWorker.ready;
640
+ await registration.sync.register(SYNC_TAG);
641
+ } catch {
642
+ window.addEventListener("online", processMutationQueue, { once: true });
643
+ }
644
+ }
424
645
 
425
- return null;
426
- } finally {
427
- setLoading(false);
428
- }
429
- }, [endpoint, JSON.stringify(options), data]);
646
+ export async function syncWhenPossible(mutation) {
647
+ await queueMutation(mutation);
648
+ await registerSync();
649
+ }
430
650
 
431
- useEffect(() => {
432
- if (!options.skip) {
433
- fetchData();
434
- }
435
- }, [endpoint]);
651
+ export async function retrySync() {
652
+ if (!("serviceWorker" in navigator) || !("SyncManager" in window)) return;
653
+ const count = await getPendingCount();
654
+ if (count > 0) {
655
+ await registerSync();
656
+ }
657
+ }
436
658
 
437
- return { data, loading, error, refetch: fetchData };
659
+ window.addEventListener("mutation-sync-complete", retrySync);
660
+ `;
661
+ writeFileSync(join(swoffDir, "background-sync.js"), code);
662
+ generatedFiles.push("swoff/background-sync.js");
438
663
  }
439
- `
440
- : `import { useState, useEffect, useCallback } from 'react';
664
+ function generateSwGeneratorBuildScript() {
665
+ ensureSwoffDir();
666
+ const code = `#!/usr/bin/env node
667
+ /**
668
+ * Swoff SW Generator Build Script
669
+ * Reads swoff/sw-template.js and generates versioned SW output.
670
+ *
671
+ * Add to package.json:
672
+ * "build": "your-build && node swoff/sw-generator.js"
673
+ */
441
674
 
442
- export function useApiData(endpoint, options = {}) {
443
- const [data, setData] = useState(null);
444
- const [loading, setLoading] = useState(true);
445
- const [error, setError] = useState(null);
675
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
676
+ import { join, dirname } from 'path';
677
+ import { fileURLToPath } from 'url';
446
678
 
447
- const fetchData = useCallback(async (fetchOptions = {}) => {
448
- try {
449
- setLoading(true);
450
- setError(null);
679
+ const __dirname = dirname(fileURLToPath(import.meta.url));
680
+ const projectRoot = join(__dirname, '..');
451
681
 
452
- const response = await fetch(endpoint, { ...options, ...fetchOptions });
682
+ const pkgPath = join(projectRoot, 'package.json');
683
+ const templatePath = join(__dirname, 'sw-template.js');
684
+ const configPath = join(projectRoot, 'swoff.config.json');
453
685
 
454
- if (!response.ok) {
455
- throw new Error(\`HTTP error! status: \${response.status}\`);
456
- }
686
+ if (!existsSync(configPath)) {
687
+ console.error('Error: swoff.config.json not found');
688
+ console.log('Run "npx @swoff/cli init" to create one');
689
+ process.exit(1);
690
+ }
457
691
 
458
- const result = await response.json();
459
- setData(result);
460
- return result;
461
- } catch (err) {
462
- const errorMessage = err instanceof Error ? err.message : 'An error occurred';
463
- setError(new Error(errorMessage));
692
+ if (!existsSync(templatePath)) {
693
+ console.error('Error: swoff/sw-template.js not found');
694
+ process.exit(1);
695
+ }
464
696
 
465
- if (!navigator.onLine && data) {
466
- return data;
467
- }
697
+ const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, 'utf8')) : { version: '1.0.0' };
698
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
699
+ const template = readFileSync(templatePath, 'utf8');
468
700
 
469
- return null;
470
- } finally {
471
- setLoading(false);
472
- }
473
- }, [endpoint, JSON.stringify(options), data]);
701
+ const version = config.version === 'from-package' ? pkg.version || '1.0.0' : config.version;
702
+ const outputDir = config.build?.outputDir || 'dist';
703
+ const swFilename = config.build?.swFilename || 'sw';
474
704
 
475
- useEffect(() => {
476
- if (!options.skip) {
477
- fetchData();
478
- }
479
- }, [endpoint]);
705
+ let sw = template;
706
+ sw = sw.replace('// [[CACHE_NAME]]', \`CACHE_NAME = 'sw-v\${version}'\`);
480
707
 
481
- return { data, loading, error, refetch: fetchData };
482
- }
483
- `;
484
- writeFileSync(join(outputDir, `useApiData.${ext}`), useApiData);
485
- generatedFiles.push(`src/hooks/useApiData.${ext}`);
486
- }
487
- function generateMutationQueueHooks() {
488
- const outputDir = join(projectRoot, "src", "hooks");
489
- if (!existsSync(outputDir))
490
- mkdirSync(outputDir, { recursive: true });
491
- const useMutationQueue = language === "ts"
492
- ? `import { useState, useEffect, useCallback } from 'react';
708
+ const baseAssets = ['/', '/index.html'];
709
+ const pwaAssets = config.features?.pwa ? ['/manifest.json'] : [];
710
+ const assetsToCache = [...baseAssets, ...pwaAssets];
711
+ sw = sw.replace('// [[ASSETS_LIST]]', \`ASSETS_TO_CACHE = \${JSON.stringify(assetsToCache.map(url => ({ url, options: {} })), null, 2)}\`);
712
+ sw = sw.replace('// [[AUTO_SKIP_WAITING]]', \`const AUTO_SKIP_WAITING = \${config.serviceWorker?.autoUpdate || false};\`);
493
713
 
494
- export interface Mutation {
495
- id: string;
496
- timestamp: number;
497
- status: 'pending' | 'synced' | 'failed';
498
- retries: number;
499
- data: unknown;
500
- endpoint: string;
501
- method?: string;
714
+ const outDir = join(projectRoot, outputDir);
715
+ if (!existsSync(outDir)) {
716
+ import('fs').then(fs => fs.mkdirSync(outDir, { recursive: true }));
502
717
  }
503
718
 
504
- export interface UseMutationQueueOptions {
505
- onSync?: (mutation: Mutation) => Promise<void>;
506
- maxRetries?: number;
507
- storageKey?: string;
508
- autoSync?: boolean;
509
- }
719
+ writeFileSync(join(projectRoot, outputDir, \`\${swFilename}-v\${version}.js\`), sw);
720
+ writeFileSync(join(projectRoot, outputDir, 'version.json'), JSON.stringify({
721
+ version,
722
+ minSupportedVersion: config.minSupportedVersion || '0.0.0',
723
+ generatedAt: new Date().toISOString(),
724
+ }, null, 2));
510
725
 
511
- export interface UseMutationQueueResult {
512
- queueMutation: (mutation: Omit<Mutation, 'id' | 'timestamp' | 'status' | 'retries'>) => string;
513
- pendingMutations: Mutation[];
514
- isSyncing: boolean;
515
- syncMutations: () => Promise<void>;
516
- clearQueue: () => void;
517
- retryMutation: (id: string) => Promise<void>;
726
+ console.log(\`Service worker built: \${outputDir}/\${swFilename}-v\${version}.js\`);
727
+ `;
728
+ writeFileSync(join(swoffDir, "sw-generator.js"), code);
729
+ generatedFiles.push("swoff/sw-generator.js");
518
730
  }
731
+ function generateSwTemplate() {
732
+ ensureSwoffDir();
733
+ const code = `/**
734
+ * Swoff Service Worker Template
735
+ *
736
+ * This file is processed by swoff/sw-generator.js to create
737
+ * a versioned service worker. Placeholders are replaced during build.
738
+ *
739
+ * Placeholders:
740
+ * // [[CACHE_NAME]] - Replaced with versioned cache name
741
+ * // [[ASSETS_LIST]] - Replaced with assets to cache
742
+ * // [[AUTO_SKIP_WAITING]] - Replaced with autoUpdate config
743
+ *
744
+ * You can customize this template before running the build script.
745
+ */
519
746
 
520
- export function useMutationQueue(options: UseMutationQueueOptions = {}): UseMutationQueueResult {
521
- const {
522
- onSync,
523
- maxRetries = 3,
524
- storageKey = 'swoff-mutation-queue',
525
- autoSync = true
526
- } = options;
747
+ let CACHE_NAME = "";
748
+ let ASSETS_TO_CACHE = [];
527
749
 
528
- const [pendingMutations, setPendingMutations] = useState<Mutation[]>([]);
529
- const [isSyncing, setIsSyncing] = useState(false);
750
+ // [[CACHE_NAME]]
751
+ // [[ASSETS_LIST]]
752
+ // [[AUTO_SKIP_WAITING]]
530
753
 
531
- useEffect(() => {
532
- try {
533
- const stored = localStorage.getItem(storageKey);
534
- if (stored) {
535
- const parsed = JSON.parse(stored);
536
- if (Array.isArray(parsed)) {
537
- setPendingMutations(parsed);
754
+ const CACHE_NAME_RUNTIME = "swoff-runtime";
755
+
756
+ // Install - download assets with progress tracking
757
+ self.addEventListener("install", (event) => {
758
+ event.waitUntil(
759
+ (async () => {
760
+ const cache = await caches.open(CACHE_NAME);
761
+ let downloaded = 0;
762
+ for (const asset of ASSETS_TO_CACHE) {
763
+ try {
764
+ const request = new Request(asset.url, asset.options);
765
+ await cache.add(request);
766
+ downloaded++;
767
+ const percent = Math.round((downloaded / ASSETS_TO_CACHE.length) * 100);
768
+ const clients = await self.clients.matchAll({ includeUncontrolled: true });
769
+ clients.forEach((client) => {
770
+ client.postMessage({
771
+ type: "SW_PROGRESS",
772
+ percent,
773
+ downloaded,
774
+ total: ASSETS_TO_CACHE.length,
775
+ });
776
+ });
777
+ } catch (err) {
778
+ console.error(\`Failed to cache \${asset.url}:\`, err);
538
779
  }
539
780
  }
540
- } catch {
541
- // ignore
542
- }
543
- }, [storageKey]);
781
+ if (AUTO_SKIP_WAITING) self.skipWaiting();
782
+ })(),
783
+ );
784
+ });
544
785
 
545
- useEffect(() => {
546
- try {
547
- localStorage.setItem(storageKey, JSON.stringify(pendingMutations));
548
- } catch {
549
- // ignore
550
- }
551
- }, [pendingMutations, storageKey]);
552
-
553
- const queueMutation = useCallback((
554
- mutation: Omit<Mutation, 'id' | 'timestamp' | 'status' | 'retries'>
555
- ): string => {
556
- const newMutation: Mutation = {
557
- id: \`\${Date.now()}-\${Math.random().toString(36).substr(2, 9)}\`,
558
- timestamp: Date.now(),
559
- status: 'pending',
560
- retries: 0,
561
- ...mutation
562
- };
786
+ // Activate - clean old caches
787
+ self.addEventListener("activate", (event) => {
788
+ event.waitUntil(
789
+ caches.keys().then((keys) =>
790
+ Promise.all(
791
+ keys.filter((key) => key !== CACHE_NAME && key !== CACHE_NAME_RUNTIME).map((key) => caches.delete(key))
792
+ )
793
+ )
794
+ );
795
+ });
563
796
 
564
- setPendingMutations((prev) => [...prev, newMutation]);
797
+ // Message - skip waiting and cache invalidation
798
+ self.addEventListener("message", (event) => {
799
+ if (event.data.type === "SKIP_WAITING") {
800
+ self.skipWaiting();
801
+ }
802
+ });
565
803
 
566
- if (autoSync && navigator.onLine) {
567
- syncMutations();
568
- }
804
+ // Fetch - cache strategies
805
+ self.addEventListener("fetch", (event) => {
806
+ if (event.request.method !== "GET" && event.request.method !== "HEAD") return;
569
807
 
570
- return newMutation.id;
571
- }, [autoSync]);
808
+ event.respondWith(
809
+ (async () => {
810
+ const cache = await caches.open(CACHE_NAME);
811
+ const runtimeCache = await caches.open(CACHE_NAME_RUNTIME);
812
+ const url = new URL(event.request.url);
572
813
 
573
- const syncMutations = useCallback(async () => {
574
- if (isSyncing || !navigator.onLine) return;
814
+ const byPath = await cache.match(url.pathname);
815
+ if (byPath) return byPath;
575
816
 
576
- const pending = pendingMutations.filter((m) => m.status === 'pending');
577
- if (pending.length === 0) return;
817
+ const byRequest = await runtimeCache.match(event.request);
818
+ if (byRequest) return byRequest;
578
819
 
579
- setIsSyncing(true);
820
+ if (event.request.mode === "navigate") {
821
+ const spa = await cache.match("/index.html");
822
+ if (spa) return spa;
823
+ }
580
824
 
581
- for (const mutation of pending) {
582
825
  try {
583
- if (onSync) {
584
- await onSync(mutation);
585
- } else {
586
- await fetch(mutation.endpoint, {
587
- method: mutation.method || 'POST',
588
- headers: { 'Content-Type': 'application/json' },
589
- body: JSON.stringify(mutation.data),
590
- });
826
+ const response = await fetch(event.request);
827
+ if (response.ok) {
828
+ await runtimeCache.put(event.request, response.clone());
591
829
  }
592
- setPendingMutations((prev) =>
593
- prev.map((m) => (m.id === mutation.id ? { ...m, status: 'synced' } : m))
594
- );
830
+ return response;
595
831
  } catch {
596
- setPendingMutations((prev) =>
597
- prev.map((m) =>
598
- m.id === mutation.id
599
- ? { ...m, retries: m.retries + 1, status: m.retries >= maxRetries ? 'failed' : 'pending' }
600
- : m
601
- )
602
- );
832
+ return new Response("Offline: content not available", { status: 503 });
603
833
  }
834
+ })(),
835
+ );
836
+ });
837
+
838
+ const SWOFF = {
839
+ cache: {
840
+ async get(key) {
841
+ const cache = await caches.open(CACHE_NAME);
842
+ return cache.match(key);
843
+ },
844
+ async put(request, response) {
845
+ const cache = await caches.open(CACHE_NAME);
846
+ await cache.put(request, response);
847
+ },
848
+ async delete(request) {
849
+ const cache = await caches.open(CACHE_NAME);
850
+ return cache.delete(request);
604
851
  }
852
+ }
853
+ };
605
854
 
606
- setIsSyncing(false);
607
- }, [isSyncing, onSync, maxRetries, pendingMutations]);
855
+ if (typeof self !== 'undefined') {
856
+ self.SWOFF = SWOFF;
857
+ }
858
+ `;
859
+ writeFileSync(join(swoffDir, "sw-template.js"), code);
860
+ generatedFiles.push("swoff/sw-template.js");
861
+ }
862
+ function generateStore() {
863
+ ensureSwoffDir();
864
+ const dbName = config.database?.name || "app-db";
865
+ const code = `/**
866
+ * Swoff IndexedDB Store
867
+ * Generic CRUD operations for app's IndexedDB database.
868
+ *
869
+ * Usage:
870
+ * import { getRecord, putRecord, deleteRecord, openAppDB } from './swoff/store.js';
871
+ *
872
+ * const record = await getRecord('todos', 'todo-123');
873
+ * await putRecord('todos', { id: 'todo-123', title: 'New task', $synced: false });
874
+ * await deleteRecord('todos', 'todo-123');
875
+ */
608
876
 
609
- useEffect(() => {
610
- if (!autoSync) return;
877
+ const DB_NAME = "${dbName}";
611
878
 
612
- const handleOnline = () => syncMutations();
613
- window.addEventListener('online', handleOnline);
614
- return () => window.removeEventListener('online', handleOnline);
615
- }, [syncMutations, autoSync]);
879
+ export function openAppDB() {
880
+ return new Promise((resolve, reject) => {
881
+ const request = indexedDB.open(DB_NAME);
882
+ request.onsuccess = (e) => resolve(e.target.result);
883
+ request.onerror = (e) => reject(e.target.error);
884
+ });
885
+ }
616
886
 
617
- const clearQueue = useCallback(() => {
618
- setPendingMutations([]);
619
- localStorage.removeItem(storageKey);
620
- }, [storageKey]);
887
+ export async function getRecord(storeName, id) {
888
+ const db = await openAppDB();
889
+ return new Promise((resolve, reject) => {
890
+ const tx = db.transaction(storeName, "readonly");
891
+ const store = tx.objectStore(storeName);
892
+ const request = store.get(id);
893
+ request.onsuccess = () => resolve(request.result);
894
+ request.onerror = () => reject(request.error);
895
+ });
896
+ }
621
897
 
622
- const retryMutation = useCallback(async (id: string) => {
623
- setPendingMutations((prev) =>
624
- prev.map((m) => (m.id === id ? { ...m, status: 'pending', retries: 0 } : m))
625
- );
626
- await syncMutations();
627
- }, [syncMutations]);
898
+ export async function putRecord(storeName, record) {
899
+ const db = await openAppDB();
900
+ return new Promise((resolve, reject) => {
901
+ const tx = db.transaction(storeName, "readwrite");
902
+ const store = tx.objectStore(storeName);
903
+ const request = store.put(record);
904
+ tx.oncomplete = () => resolve(request.result);
905
+ tx.onerror = () => reject(tx.error);
906
+ });
907
+ }
628
908
 
629
- return { queueMutation, pendingMutations, isSyncing, syncMutations, clearQueue, retryMutation };
909
+ export async function deleteRecord(storeName, id) {
910
+ const db = await openAppDB();
911
+ return new Promise((resolve, reject) => {
912
+ const tx = db.transaction(storeName, "readwrite");
913
+ const store = tx.objectStore(storeName);
914
+ const request = store.delete(id);
915
+ tx.oncomplete = () => resolve();
916
+ tx.onerror = () => reject(tx.error);
917
+ });
630
918
  }
631
- `
632
- : `import { useState, useEffect, useCallback } from 'react';
633
919
 
634
- export function useMutationQueue(options = {}) {
635
- const {
636
- onSync,
637
- maxRetries = 3,
638
- storageKey = 'swoff-mutation-queue',
639
- autoSync = true
640
- } = options;
920
+ export async function getAllRecords(storeName) {
921
+ const db = await openAppDB();
922
+ return new Promise((resolve, reject) => {
923
+ const tx = db.transaction(storeName, "readonly");
924
+ const store = tx.objectStore(storeName);
925
+ const request = store.getAll();
926
+ request.onsuccess = () => resolve(request.result);
927
+ request.onerror = () => reject(request.error);
928
+ });
929
+ }
930
+ `;
931
+ writeFileSync(join(swoffDir, "store.js"), code);
932
+ generatedFiles.push("swoff/store.js");
933
+ }
934
+ function generateReconcile() {
935
+ ensureSwoffDir();
936
+ const code = `/**
937
+ * Swoff ID Reconciliation
938
+ * Update local records with server data after mutation sync.
939
+ *
940
+ * Usage:
941
+ * import { reconcileRecord } from './swoff/reconcile.js';
942
+ *
943
+ * await reconcileRecord('todos', 'temp_abc123', serverData);
944
+ */
641
945
 
642
- const [pendingMutations, setPendingMutations] = useState([]);
643
- const [isSyncing, setIsSyncing] = useState(false);
946
+ import { getRecord, putRecord, deleteRecord } from './store.js';
644
947
 
645
- useEffect(() => {
646
- try {
647
- const stored = localStorage.getItem(storageKey);
648
- if (stored) {
649
- const parsed = JSON.parse(stored);
650
- if (Array.isArray(parsed)) {
651
- setPendingMutations(parsed);
652
- }
653
- }
654
- } catch {
655
- // ignore
656
- }
657
- }, [storageKey]);
948
+ export async function reconcileRecord(storeName, tempId, serverData) {
949
+ const existing = await getRecord(storeName, tempId);
950
+ if (!existing) return;
658
951
 
659
- useEffect(() => {
660
- try {
661
- localStorage.setItem(storageKey, JSON.stringify(pendingMutations));
662
- } catch {
663
- // ignore
664
- }
665
- }, [pendingMutations, storageKey]);
666
-
667
- const queueMutation = useCallback((mutation) => {
668
- const newMutation = {
669
- id: \`\${Date.now()}-\${Math.random().toString(36).substr(2, 9)}\`,
670
- timestamp: Date.now(),
671
- status: 'pending',
672
- retries: 0,
673
- ...mutation
674
- };
952
+ const reconciled = {
953
+ ...existing,
954
+ ...serverData,
955
+ id: serverData.id,
956
+ $synced: true,
957
+ $syncedAt: Date.now(),
958
+ };
675
959
 
676
- setPendingMutations((prev) => [...prev, newMutation]);
960
+ await putRecord(storeName, reconciled);
677
961
 
678
- if (autoSync && navigator.onLine) {
679
- syncMutations();
680
- }
962
+ if (String(tempId) !== String(serverData.id)) {
963
+ await deleteRecord(storeName, tempId);
964
+ }
681
965
 
682
- return newMutation.id;
683
- }, [autoSync]);
966
+ await reconcileReferences(storeName, tempId, serverData.id);
967
+ }
684
968
 
685
- const syncMutations = useCallback(async () => {
686
- if (isSyncing || !navigator.onLine) return;
969
+ export async function reconcileReferences(storeName, oldId, newId) {
970
+ // Override this for your app's schema.
971
+ // Example: update foreign-key references in related stores.
972
+ //
973
+ // const txns = await getAllRecords('transactions');
974
+ // for (const txn of txns) {
975
+ // if (txn.todoId === oldId) {
976
+ // txn.todoId = newId;
977
+ // await putRecord('transactions', txn);
978
+ // }
979
+ // }
980
+ }
981
+ `;
982
+ writeFileSync(join(swoffDir, "reconcile.js"), code);
983
+ generatedFiles.push("swoff/reconcile.js");
984
+ }
985
+ function generateIndexedDB() {
986
+ ensureSwoffDir();
987
+ const dbName = config.database?.name || "app-db";
988
+ const stores = config.database?.stores || [];
989
+ const code = `/**
990
+ * Swoff IndexedDB Setup
991
+ * Database initialization with schema migrations.
992
+ *
993
+ * Usage:
994
+ * import { openDB } from './swoff/indexeddb.js';
995
+ *
996
+ * const db = await openDB();
997
+ */
687
998
 
688
- const pending = pendingMutations.filter((m) => m.status === 'pending');
689
- if (pending.length === 0) return;
999
+ const DB_NAME = "${dbName}";
1000
+ const DB_VERSION = 1;
690
1001
 
691
- setIsSyncing(true);
1002
+ export function openDB() {
1003
+ return new Promise((resolve, reject) => {
1004
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
692
1005
 
693
- for (const mutation of pending) {
694
- try {
695
- if (onSync) {
696
- await onSync(mutation);
697
- } else {
698
- await fetch(mutation.endpoint, {
699
- method: mutation.method || 'POST',
700
- headers: { 'Content-Type': 'application/json' },
701
- body: JSON.stringify(mutation.data),
702
- });
703
- }
704
- setPendingMutations((prev) =>
705
- prev.map((m) => (m.id === mutation.id ? { ...m, status: 'synced' } : m))
706
- );
707
- } catch {
708
- setPendingMutations((prev) =>
709
- prev.map((m) =>
710
- m.id === mutation.id
711
- ? { ...m, retries: m.retries + 1, status: m.retries >= maxRetries ? 'failed' : 'pending' }
712
- : m
713
- )
714
- );
715
- }
716
- }
1006
+ request.onupgradeneeded = (e) => {
1007
+ const db = e.target.result;
1008
+ const oldVersion = e.oldVersion;
717
1009
 
718
- setIsSyncing(false);
719
- }, [isSyncing, onSync, maxRetries, pendingMutations]);
1010
+ ${stores.length > 0 ? stores.map((store, i) => ` if (oldVersion < ${i + 1}) {
1011
+ db.createObjectStore("${store}", { keyPath: "id" });
1012
+ }`).join("\n\n") : ` // Create your object stores here:
1013
+ // if (oldVersion < 1) {
1014
+ // const todos = db.createObjectStore("todos", { keyPath: "id" });
1015
+ // todos.createIndex("by-date", "date");
1016
+ // }`}
1017
+ };
720
1018
 
721
- useEffect(() => {
722
- if (!autoSync) return;
1019
+ request.onsuccess = (e) => resolve(e.target.result);
1020
+ request.onerror = (e) => reject(e.target.error);
1021
+ });
1022
+ }
723
1023
 
724
- const handleOnline = () => syncMutations();
725
- window.addEventListener('online', handleOnline);
726
- return () => window.removeEventListener('online', handleOnline);
727
- }, [syncMutations, autoSync]);
1024
+ export async function requestPersistentStorage() {
1025
+ if (!navigator.storage?.persist) return false;
728
1026
 
729
- const clearQueue = useCallback(() => {
730
- setPendingMutations([]);
731
- localStorage.removeItem(storageKey);
732
- }, [storageKey]);
1027
+ const isPersisted = await navigator.storage.persisted();
1028
+ if (isPersisted) return true;
733
1029
 
734
- const retryMutation = useCallback(async (id) => {
735
- setPendingMutations((prev) =>
736
- prev.map((m) => (m.id === id ? { ...m, status: 'pending', retries: 0 } : m))
737
- );
738
- await syncMutations();
739
- }, [syncMutations]);
1030
+ try {
1031
+ return await navigator.storage.persist();
1032
+ } catch {
1033
+ return false;
1034
+ }
1035
+ }
1036
+
1037
+ export async function monitorStorage() {
1038
+ const estimate = await navigator.storage.estimate();
1039
+ const ratio = estimate.usage / estimate.quota;
740
1040
 
741
- return { queueMutation, pendingMutations, isSyncing, syncMutations, clearQueue, retryMutation };
1041
+ return {
1042
+ usage: estimate.usage,
1043
+ quota: estimate.quota,
1044
+ ratio,
1045
+ status: ratio >= 0.95 ? "critical" : ratio >= 0.8 ? "warning" : "ok",
1046
+ };
742
1047
  }
743
1048
  `;
744
- writeFileSync(join(outputDir, `useMutationQueue.${ext}`), useMutationQueue);
745
- generatedFiles.push(`src/hooks/useMutationQueue.${ext}`);
1049
+ writeFileSync(join(swoffDir, "indexeddb.js"), code);
1050
+ generatedFiles.push("swoff/indexeddb.js");
746
1051
  }
747
- function generatePWAComponents() {
748
- const outputDir = join(projectRoot, "src", "components");
749
- if (!existsSync(outputDir))
750
- mkdirSync(outputDir, { recursive: true });
751
- const offlineIndicator = `import { useOffline } from '../hooks/useOffline';
752
-
753
- export interface OfflineIndicatorProps {
754
- position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
755
- message?: string;
756
- className?: string;
1052
+ function generateTypeDefinitions() {
1053
+ if (language !== "ts")
1054
+ return;
1055
+ ensureSwoffDir();
1056
+ const code = `interface BeforeInstallPromptEvent extends Event {
1057
+ prompt(): Promise<void>;
1058
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
757
1059
  }
758
1060
 
759
- const positionClasses = {
760
- 'top-left': 'top-4 left-4',
761
- 'top-right': 'top-4 right-4',
762
- 'bottom-left': 'bottom-4 left-4',
763
- 'bottom-right': 'bottom-4 right-4'
764
- };
765
-
766
- export function OfflineIndicator({
767
- position = 'bottom-right',
768
- message = 'You are offline. Some features may be limited.',
769
- className = ''
770
- }: OfflineIndicatorProps) {
771
- const { isOffline } = useOffline();
772
-
773
- if (!isOffline) return null;
774
-
775
- return (
776
- <div
777
- className={\`fixed \${positionClasses[position]} bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50 flex items-center gap-2 \${className}\`}
778
- role="alert"
779
- aria-live="polite"
780
- >
781
- <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
782
- <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
783
- </svg>
784
- <span className="text-sm font-medium">{message}</span>
785
- </div>
786
- );
1061
+ declare global {
1062
+ interface Window {
1063
+ deferredInstallPrompt: BeforeInstallPromptEvent | null;
1064
+ latestSWVersion?: string;
1065
+ currentSWVersion?: string;
1066
+ swRegisteredVersion?: string;
1067
+ swAvailableVersion?: string;
1068
+ swUpdateRequired?: boolean;
1069
+ swMinSupportedVersion?: string;
1070
+ swReady?: boolean;
1071
+ swError?: boolean;
1072
+ }
787
1073
  }
788
- `;
789
- writeFileSync(join(outputDir, "OfflineIndicator.tsx"), offlineIndicator);
790
- generatedFiles.push("src/components/OfflineIndicator.tsx");
791
- const pwaInstallButton = `import { useState, useEffect } from 'react';
792
-
793
- export interface PWAInstallButtonProps {
794
- installLabel?: string;
795
- installedLabel?: string;
796
- className?: string;
797
- }
798
-
799
- export function PWAInstallButton({
800
- installLabel = 'Install App',
801
- installedLabel = 'Installed',
802
- className = ''
803
- }: PWAInstallButtonProps) {
804
- const [isInstallable, setIsInstallable] = useState(false);
805
- const [isInstalled, setIsInstalled] = useState(false);
806
- const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
807
-
808
- useEffect(() => {
809
- const handleBeforeInstallPrompt = (e: Event) => {
810
- e.preventDefault();
811
- setDeferredPrompt(e as BeforeInstallPromptEvent);
812
- setIsInstallable(true);
813
- };
814
1074
 
815
- const handleAppInstalled = () => {
816
- setIsInstalled(true);
817
- setIsInstallable(false);
818
- };
819
-
820
- window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
821
- window.addEventListener('appinstalled', handleAppInstalled);
822
-
823
- return () => {
824
- window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
825
- window.removeEventListener('appinstalled', handleAppInstalled);
826
- };
827
- }, []);
828
-
829
- const handleInstall = async () => {
830
- if (!deferredPrompt) return;
831
-
832
- await deferredPrompt.prompt();
833
- const { outcome } = await deferredPrompt.userChoice;
1075
+ export interface SWOFFCache {
1076
+ get(key: Request | string): Promise<Response | undefined>;
1077
+ put(request: Request | string, response: Response): Promise<void>;
1078
+ delete(request: Request | string): Promise<boolean>;
1079
+ }
834
1080
 
835
- if (outcome === 'accepted') {
836
- setIsInstalled(true);
837
- }
838
- setIsInstallable(false);
839
- setDeferredPrompt(null);
1081
+ export interface SWOFF {
1082
+ cache: SWOFFCache;
1083
+ network: {
1084
+ fetch(request: Request | string, options?: RequestInit): Promise<Response>;
840
1085
  };
1086
+ }
841
1087
 
842
- if (!isInstallable && !isInstalled) return null;
1088
+ export interface FetchWithCacheOptions extends RequestInit {
1089
+ strategy?: "read" | "mutation";
1090
+ tags?: string[];
1091
+ staleWhileRevalidate?: boolean;
1092
+ }
843
1093
 
844
- return (
845
- <button
846
- onClick={handleInstall}
847
- disabled={isInstalled}
848
- className={\`fixed bottom-4 right-4 bg-blue-500 hover:bg-blue-600 disabled:bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg font-medium z-50 \${className}\`}
849
- >
850
- {isInstalled ? installedLabel : installLabel}
851
- </button>
852
- );
1094
+ export interface MutationQueueItem {
1095
+ id: string;
1096
+ method: string;
1097
+ url: string;
1098
+ body: unknown;
1099
+ headers: Record<string, string>;
1100
+ previousData: unknown | null;
1101
+ timestamp: number;
1102
+ retryCount: number;
1103
+ tags: string[];
1104
+ storeName: string | null;
1105
+ tempId: string | null;
853
1106
  }
854
1107
 
855
- interface BeforeInstallPromptEvent extends Event {
856
- prompt(): Promise<{ outcome: 'accepted' | 'dismissed' }>;
857
- userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
1108
+ export interface MutationQueueResult {
1109
+ succeeded: number;
1110
+ failed: number;
858
1111
  }
1112
+
1113
+ export {};
859
1114
  `;
860
- writeFileSync(join(outputDir, "PWAInstallButton.tsx"), pwaInstallButton);
861
- generatedFiles.push("src/components/PWAInstallButton.tsx");
1115
+ writeFileSync(join(swoffDir, "swoff.d.ts"), code);
1116
+ generatedFiles.push("swoff/swoff.d.ts");
862
1117
  }
863
- function generateCrossTabSync() {
864
- const outputDir = join(projectRoot, "src", "utils");
1118
+ function generateManifest() {
1119
+ const outputDir = join(projectRoot, "public");
865
1120
  if (!existsSync(outputDir))
866
1121
  mkdirSync(outputDir, { recursive: true });
867
- const crossTabSync = language === "ts"
868
- ? `export class CrossTabSync {
869
- private channel: BroadcastChannel;
870
- private listeners: Map<string, Set<(value: unknown, type: string) => void>>;
871
-
872
- constructor(channelName = 'swoff-sync') {
873
- this.channel = new BroadcastChannel(channelName);
874
- this.listeners = new Map();
875
-
876
- this.channel.onmessage = (event: MessageEvent) => {
877
- const { key, value, type } = event.data;
878
- const callbacks = this.listeners.get(key);
879
- if (callbacks) {
880
- callbacks.forEach((callback) => callback(value, type));
881
- }
1122
+ const manifest = {
1123
+ name: "Swoff App",
1124
+ short_name: "Swoff",
1125
+ description: "Offline-first web application",
1126
+ start_url: "/",
1127
+ display: "standalone",
1128
+ background_color: "#ffffff",
1129
+ theme_color: "#000000",
1130
+ icons: [
1131
+ {
1132
+ src: "/icon-192.png",
1133
+ sizes: "192x192",
1134
+ type: "image/png",
1135
+ },
1136
+ {
1137
+ src: "/icon-512.png",
1138
+ sizes: "512x512",
1139
+ type: "image/png",
1140
+ },
1141
+ ],
882
1142
  };
883
- }
1143
+ writeFileSync(join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
1144
+ generatedFiles.push("public/manifest.json");
1145
+ }
1146
+ function generatePwaInstall() {
1147
+ ensureSwoffDir();
1148
+ const preventDefault = config.pwa?.preventDefaultInstall ?? false;
1149
+ const code = `/**
1150
+ * Swoff PWA Install Prompt Handler
1151
+ * Captures beforeinstallprompt event and provides manual install trigger.
1152
+ *
1153
+ * Usage:
1154
+ * import { isInstallable, promptInstall } from './swoff/pwa-install.js';
1155
+ *
1156
+ * // Listen for installable event
1157
+ * window.addEventListener('pwa-installable', (e) => {
1158
+ * console.log('PWA can be installed:', e.detail.isInstallable);
1159
+ * // Show your custom install button
1160
+ * });
1161
+ *
1162
+ * // When user clicks install button
1163
+ * async function onInstallClick() {
1164
+ * const result = await promptInstall();
1165
+ * console.log('User choice:', result);
1166
+ * }
1167
+ *
1168
+ * Window events:
1169
+ * pwa-installable - PWA can be installed (detail: { isInstallable: true })
1170
+ * pwa-installed - User accepted install (detail: { outcome: 'accepted' })
1171
+ * pwa-dismissed - User dismissed install (detail: { outcome: 'dismissed' })
1172
+ *
1173
+ * Window properties:
1174
+ * window.deferredInstallPrompt - The captured BeforeInstallPromptEvent
1175
+ * window.pwaInstallable - Whether PWA can be installed
1176
+ */
884
1177
 
885
- subscribe(
886
- key: string,
887
- callback: (value: unknown, type: string) => void
888
- ): () => void {
889
- if (!this.listeners.has(key)) {
890
- this.listeners.set(key, new Set());
891
- }
892
- this.listeners.get(key)!.add(callback);
1178
+ const PREVENT_DEFAULT_INSTALL = ${preventDefault};
893
1179
 
894
- return () => {
895
- this.listeners.get(key)?.delete(callback);
896
- };
897
- }
1180
+ window.addEventListener("beforeinstallprompt", (e) => {
1181
+ // Always capture the event so we never lose it
1182
+ window.deferredInstallPrompt = e;
1183
+ window.pwaInstallable = true;
898
1184
 
899
- set(key: string, value: unknown): void {
900
- try {
901
- localStorage.setItem(\`swoff-sync-\${key}\`, JSON.stringify(value));
902
- } catch {
903
- // ignore
904
- }
905
- this.channel.postMessage({ type: 'set', key, value });
1185
+ if (PREVENT_DEFAULT_INSTALL) {
1186
+ // Suppress browser's native prompt
1187
+ e.preventDefault();
906
1188
  }
1189
+ // When false, browser shows native prompt naturally
1190
+ // but we still capture the event for manual triggering
907
1191
 
908
- get(key: string): unknown {
909
- try {
910
- const stored = localStorage.getItem(\`swoff-sync-\${key}\`);
911
- return stored ? JSON.parse(stored) : null;
912
- } catch {
913
- return null;
914
- }
915
- }
1192
+ window.dispatchEvent(
1193
+ new CustomEvent("pwa-installable", {
1194
+ detail: { isInstallable: true },
1195
+ })
1196
+ );
1197
+ });
1198
+
1199
+ window.addEventListener("appinstalled", () => {
1200
+ window.deferredInstallPrompt = null;
1201
+ window.pwaInstallable = false;
916
1202
 
917
- delete(key: string): void {
918
- localStorage.removeItem(\`swoff-sync-\${key}\`);
919
- this.channel.postMessage({ type: 'delete', key, value: null });
1203
+ window.dispatchEvent(
1204
+ new CustomEvent("pwa-installed", {
1205
+ detail: { outcome: "accepted" },
1206
+ })
1207
+ );
1208
+ });
1209
+
1210
+ export function isInstallable() {
1211
+ return !!window.deferredInstallPrompt;
1212
+ }
1213
+
1214
+ export async function promptInstall() {
1215
+ if (!window.deferredInstallPrompt) {
1216
+ throw new Error("Install prompt not available");
920
1217
  }
921
1218
 
922
- close(): void {
923
- this.channel.close();
924
- this.listeners.clear();
1219
+ const promptEvent = window.deferredInstallPrompt;
1220
+ await promptEvent.prompt();
1221
+
1222
+ const choice = await promptEvent.userChoice;
1223
+
1224
+ if (choice.outcome === "accepted") {
1225
+ window.dispatchEvent(
1226
+ new CustomEvent("pwa-installed", {
1227
+ detail: { outcome: "accepted" },
1228
+ })
1229
+ );
1230
+ } else {
1231
+ window.dispatchEvent(
1232
+ new CustomEvent("pwa-dismissed", {
1233
+ detail: { outcome: "dismissed" },
1234
+ })
1235
+ );
925
1236
  }
926
- }
927
1237
 
928
- export function createCrossTabSync(channelName = 'swoff-sync'): CrossTabSync {
929
- return new CrossTabSync(channelName);
1238
+ window.deferredInstallPrompt = null;
1239
+ return choice;
1240
+ }
1241
+ `;
1242
+ writeFileSync(join(swoffDir, "pwa-install.js"), code);
1243
+ generatedFiles.push("swoff/pwa-install.js");
930
1244
  }
931
- `
932
- : `export class CrossTabSync {
933
- constructor(channelName = 'swoff-sync') {
934
- this.channel = new BroadcastChannel(channelName);
935
- this.listeners = new Map();
1245
+ function generateInvalidationTags() {
1246
+ ensureSwoffDir();
1247
+ const code = `/**
1248
+ * Swoff Invalidation Tags Helper
1249
+ * URL-based tag generation from REST endpoints for automatic cache invalidation.
1250
+ *
1251
+ * Usage:
1252
+ * import { generateTags, invalidateUrl } from './swoff/invalidation-tags.js';
1253
+ *
1254
+ * // Generate tags from URL
1255
+ * generateTags("/api/todos"); // ["todos"]
1256
+ * generateTags("/api/todos/42"); // ["todos", "todo:42"]
1257
+ * generateTags("/api/todos/42/comments"); // ["todos", "todo:42", "comments"]
1258
+ *
1259
+ * // Use with fetch wrapper
1260
+ * const data = await fetchWithCache("/api/todos", {
1261
+ * tags: generateTags("/api/todos"),
1262
+ * });
1263
+ *
1264
+ * // Invalidate after mutation
1265
+ * await invalidateUrl("/api/todos/42");
1266
+ */
936
1267
 
937
- this.channel.onmessage = (event) => {
938
- const { key, value, type } = event.data;
939
- const callbacks = this.listeners.get(key);
940
- if (callbacks) {
941
- callbacks.forEach((callback) => callback(value, type));
942
- }
943
- };
944
- }
1268
+ import { invalidateByTag, invalidateByTags } from "./cache.js";
945
1269
 
946
- subscribe(key, callback) {
947
- if (!this.listeners.has(key)) {
948
- this.listeners.set(key, new Set());
949
- }
950
- this.listeners.get(key).add(callback);
1270
+ export function generateTags(url) {
1271
+ const parsed = typeof url === "string" ? new URL(url, window.location.origin) : url;
1272
+ const segments = parsed.pathname.split("/").filter(Boolean);
951
1273
 
952
- return () => {
953
- this.listeners.get(key)?.delete(callback);
954
- };
1274
+ if (segments.length === 0) return ["root"];
1275
+
1276
+ // Skip common API prefixes
1277
+ const skipPrefixes = ["api", "v1", "v2", "v3", "rest", "graphql", "gql"];
1278
+ let startIdx = 0;
1279
+ while (startIdx < segments.length && skipPrefixes.includes(segments[startIdx])) {
1280
+ startIdx++;
955
1281
  }
956
1282
 
957
- set(key, value) {
958
- try {
959
- localStorage.setItem(\`swoff-sync-\${key}\`, JSON.stringify(value));
960
- } catch {
961
- // ignore
962
- }
963
- this.channel.postMessage({ type: 'set', key, value });
1283
+ const resourceSegments = segments.slice(startIdx);
1284
+ if (resourceSegments.length === 0) return ["root"];
1285
+
1286
+ const tags = [];
1287
+
1288
+ // Collection tag: /api/todos -> "todos"
1289
+ tags.push(resourceSegments[0]);
1290
+
1291
+ // Resource tag: /api/todos/42 -> "todo:42"
1292
+ if (resourceSegments.length >= 2 && !isNaN(Number(resourceSegments[1]))) {
1293
+ const collection = resourceSegments[0];
1294
+ const id = resourceSegments[1];
1295
+ const singular = collection.replace(/s$/, "");
1296
+ tags.push(\`\${singular}:\${id}\`);
964
1297
  }
965
1298
 
966
- get(key) {
967
- try {
968
- const stored = localStorage.getItem(\`swoff-sync-\${key}\`);
969
- return stored ? JSON.parse(stored) : null;
970
- } catch {
971
- return null;
1299
+ // Sub-resource tags: /api/todos/42/comments -> "comments"
1300
+ for (let i = 2; i < resourceSegments.length; i++) {
1301
+ if (isNaN(Number(resourceSegments[i]))) {
1302
+ tags.push(resourceSegments[i]);
972
1303
  }
973
1304
  }
974
1305
 
975
- delete(key) {
976
- localStorage.removeItem(\`swoff-sync-\${key}\`);
977
- this.channel.postMessage({ type: 'delete', key, value: null });
978
- }
1306
+ return tags;
1307
+ }
979
1308
 
980
- close() {
981
- this.channel.close();
982
- this.listeners.clear();
1309
+ export function generateTagsFromMethod(method, url) {
1310
+ const tags = generateTags(url);
1311
+
1312
+ if (method === "GET" || method === "HEAD") {
1313
+ return tags;
983
1314
  }
1315
+
1316
+ // For mutations, add method prefix
1317
+ return tags.map((tag) => \`\${method.toLowerCase()}-\${tag}\`);
984
1318
  }
985
1319
 
986
- export function createCrossTabSync(channelName = 'swoff-sync') {
987
- return new CrossTabSync(channelName);
1320
+ export async function invalidateUrl(url) {
1321
+ const tags = generateTags(url);
1322
+ await invalidateByTags(tags);
988
1323
  }
989
- `;
990
- writeFileSync(join(outputDir, `crossTabSync.${ext}`), crossTabSync);
991
- generatedFiles.push(`src/utils/crossTabSync.${ext}`);
1324
+
1325
+ export async function invalidateByMethod(method, url) {
1326
+ const tags = generateTagsFromMethod(method, url);
1327
+ await invalidateByTags(tags);
992
1328
  }
993
- function generateManifest() {
994
- const outputDir = join(projectRoot, "public");
995
- if (!existsSync(outputDir))
996
- mkdirSync(outputDir, { recursive: true });
997
- const manifest = {
998
- name: 'Swoff App',
999
- short_name: 'Swoff',
1000
- description: 'Offline-first web application',
1001
- start_url: '/',
1002
- display: 'standalone',
1003
- background_color: '#ffffff',
1004
- theme_color: '#000000',
1005
- icons: [
1006
- {
1007
- src: '/icon-192.png',
1008
- sizes: '192x192',
1009
- type: 'image/png',
1010
- },
1011
- {
1012
- src: '/icon-512.png',
1013
- sizes: '512x512',
1014
- type: 'image/png',
1015
- },
1016
- ],
1017
- };
1018
- writeFileSync(join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
1019
- generatedFiles.push("public/manifest.json");
1329
+ `;
1330
+ writeFileSync(join(swoffDir, "invalidation-tags.js"), code);
1331
+ generatedFiles.push("swoff/invalidation-tags.js");
1020
1332
  }
1021
- console.log('šŸ”§ Swoff Files Generator');
1022
- console.log('========================\n');
1333
+ console.log("Generating Swoff files...");
1023
1334
  console.log(`Language: ${language}`);
1024
1335
  console.log(`Project: ${projectRoot}`);
1025
- console.log('');
1336
+ console.log("");
1026
1337
  if (!config.enabled) {
1027
- console.log('āš ļø Config generation disabled');
1338
+ console.log("Config generation disabled");
1028
1339
  process.exit(0);
1029
1340
  }
1030
- console.log('šŸ“¦ Generating pattern files...');
1031
- generateSwInjector();
1032
- console.log(' • sw-injector');
1033
- generateSwGeneratorBuildScript();
1034
- console.log(' • sw-generator build script');
1341
+ console.log("Generating pattern files...");
1035
1342
  generateSwTemplate();
1036
- console.log(' • sw-template.js');
1037
- generateTypeDefinitions();
1038
- console.log(' • swoff.d.ts');
1039
- if (config.features.offlineReads) {
1040
- generateOfflineHooks();
1041
- console.log(' • offline hooks (useOffline, useApiData)');
1343
+ console.log(" sw-template");
1344
+ if (config.features.clientRegistration) {
1345
+ generateSwInjector();
1346
+ console.log(" sw-injector");
1347
+ }
1348
+ generateFetchWrapper();
1349
+ console.log(" fetch-wrapper");
1350
+ if (config.features.tagInvalidation || config.features.crossTabSync) {
1351
+ generateCache();
1352
+ console.log(" cache");
1042
1353
  }
1043
1354
  if (config.features.mutationQueue) {
1044
- generateMutationQueueHooks();
1045
- console.log(' • mutation queue hook (useMutationQueue)');
1355
+ generateStore();
1356
+ console.log(" store");
1357
+ generateReconcile();
1358
+ console.log(" reconcile");
1359
+ generateMutationQueue();
1360
+ console.log(" mutation-queue");
1361
+ }
1362
+ if (config.features.backgroundSync) {
1363
+ generateBackgroundSync();
1364
+ console.log(" background-sync");
1046
1365
  }
1366
+ if (config.features.indexeddb) {
1367
+ generateIndexedDB();
1368
+ console.log(" indexeddb");
1369
+ }
1370
+ generateSwGeneratorBuildScript();
1371
+ console.log(" sw-generator");
1372
+ generateTypeDefinitions();
1373
+ console.log(" swoff.d.ts");
1047
1374
  if (config.features.pwa) {
1048
- generatePWAComponents();
1049
- console.log(' • PWA components (OfflineIndicator, PWAInstallButton)');
1375
+ generatePwaInstall();
1376
+ console.log(" pwa-install");
1050
1377
  generateManifest();
1051
- console.log(' • manifest.json');
1378
+ console.log(" manifest.json");
1052
1379
  }
1053
- if (config.features.crossTabSync) {
1054
- generateCrossTabSync();
1055
- console.log(' • cross-tab sync (crossTabSync)');
1380
+ if (config.features.tagInvalidation) {
1381
+ generateInvalidationTags();
1382
+ console.log(" invalidation-tags");
1056
1383
  }
1057
- console.log('\nāœ… Generated files:');
1058
- generatedFiles.forEach((file) => console.log(` • ${file}`));
1059
- console.log("\n✨ Total: " + generatedFiles.length + " files generated");
1384
+ console.log("\nGenerated files:");
1385
+ generatedFiles.forEach((file) => console.log(` ${file}`));
1386
+ console.log(`\nTotal: ${generatedFiles.length} files`);
1060
1387
  //# sourceMappingURL=swoff-files-generator.js.map