@swoff/cli 0.0.1 → 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.
- package/bin/swoff +14 -12
- package/dist/index.js +659 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/generators/sw-generator.js +621 -0
- package/dist/lib/generators/sw-generator.js.map +1 -0
- package/dist/lib/generators/swoff-files-generator.js +1387 -0
- package/dist/lib/generators/swoff-files-generator.js.map +1 -0
- package/package.json +9 -7
- package/src/lib/templates/sw-template.js +23 -6
- package/src/index.ts +0 -475
- package/src/lib/generators/sw-generator.ts +0 -367
- package/src/lib/generators/swoff-files-generator.ts +0 -461
- package/src/lib/schema/swoff-config.schema.json +0 -171
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swoff Files Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates framework-agnostic pattern files based on swoff.config.json features.
|
|
5
|
+
* All files go into swoff/ directory (or public/ for manifest).
|
|
6
|
+
*
|
|
7
|
+
* CLI Usage:
|
|
8
|
+
* node swoff-files-generator.js --project-root <path> --package-dir <path> --language <ts|js> --config-path <path>
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { join, dirname } from "path";
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
function getArg(name) {
|
|
15
|
+
const idx = args.findIndex((arg) => arg === `--${name}`);
|
|
16
|
+
return idx !== -1 ? args[idx + 1] : null;
|
|
17
|
+
}
|
|
18
|
+
const projectRoot = getArg("project-root") || process.cwd();
|
|
19
|
+
const packageDir = getArg("package-dir") || join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
20
|
+
const language = getArg("language") || "ts";
|
|
21
|
+
const configPath = getArg("config-path") || join(projectRoot, "swoff.config.json");
|
|
22
|
+
const defaultConfig = {
|
|
23
|
+
enabled: true,
|
|
24
|
+
version: "from-package",
|
|
25
|
+
minSupportedVersion: "0.0.0",
|
|
26
|
+
serviceWorker: {
|
|
27
|
+
autoRegister: true,
|
|
28
|
+
autoUpdate: false,
|
|
29
|
+
defaultStrategy: "cache-first",
|
|
30
|
+
strategies: {},
|
|
31
|
+
},
|
|
32
|
+
features: {
|
|
33
|
+
versionedSw: true,
|
|
34
|
+
offlineReads: true,
|
|
35
|
+
mutationQueue: false,
|
|
36
|
+
backgroundSync: false,
|
|
37
|
+
pwa: true,
|
|
38
|
+
auth: false,
|
|
39
|
+
crossTabSync: true,
|
|
40
|
+
tagInvalidation: true,
|
|
41
|
+
clientRegistration: true,
|
|
42
|
+
indexeddb: false,
|
|
43
|
+
},
|
|
44
|
+
pwa: {
|
|
45
|
+
preventDefaultInstall: false,
|
|
46
|
+
},
|
|
47
|
+
build: {
|
|
48
|
+
outputDir: "dist",
|
|
49
|
+
swFilename: "sw",
|
|
50
|
+
},
|
|
51
|
+
database: {
|
|
52
|
+
name: "app-db",
|
|
53
|
+
stores: [],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
let config = defaultConfig;
|
|
57
|
+
if (existsSync(configPath)) {
|
|
58
|
+
try {
|
|
59
|
+
config = { ...defaultConfig, ...JSON.parse(readFileSync(configPath, "utf8")) };
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
console.warn("Could not parse config, using defaults");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
console.log("No config found, using defaults");
|
|
67
|
+
}
|
|
68
|
+
const ext = language === "ts" ? "ts" : "js";
|
|
69
|
+
const swoffDir = join(projectRoot, "swoff");
|
|
70
|
+
const generatedFiles = [];
|
|
71
|
+
function ensureSwoffDir() {
|
|
72
|
+
if (!existsSync(swoffDir))
|
|
73
|
+
mkdirSync(swoffDir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
function generateSwInjector() {
|
|
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
|
+
*/
|
|
115
|
+
|
|
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();
|
|
125
|
+
}
|
|
126
|
+
|
|
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
|
+
}
|
|
137
|
+
|
|
138
|
+
export function shouldRegisterSW() {
|
|
139
|
+
if (!AUTO_REGISTER) return false;
|
|
140
|
+
|
|
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
|
+
}
|
|
158
|
+
|
|
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" });
|
|
181
|
+
} else {
|
|
182
|
+
await doRegisterServiceWorker(manifest.version);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
window.dispatchEvent(
|
|
186
|
+
new CustomEvent("sw-update-available", {
|
|
187
|
+
detail: { version: manifest.version },
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
}
|
|
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"));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
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
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function skipWaiting() {
|
|
219
|
+
return navigator.serviceWorker.ready.then((registration) => {
|
|
220
|
+
if (registration.waiting) {
|
|
221
|
+
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
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
|
+
});
|
|
251
|
+
}
|
|
252
|
+
`;
|
|
253
|
+
writeFileSync(join(swoffDir, `sw-injector.${ext}`), code);
|
|
254
|
+
generatedFiles.push(`swoff/sw-injector.${ext}`);
|
|
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
|
+
*/
|
|
280
|
+
|
|
281
|
+
const inFlightRequests = new Map();
|
|
282
|
+
|
|
283
|
+
export async function fetchWithCache(input, options = {}) {
|
|
284
|
+
const headers = new Headers(options.headers);
|
|
285
|
+
const method = options.method || "GET";
|
|
286
|
+
|
|
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
|
+
}
|
|
293
|
+
|
|
294
|
+
if (options.staleWhileRevalidate) {
|
|
295
|
+
headers.set("X-SW-Stale", "true");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (options.tags && options.tags.length > 0) {
|
|
299
|
+
headers.set("X-SW-Cache-Tags", options.tags.join(","));
|
|
300
|
+
}
|
|
301
|
+
|
|
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
|
+
}
|
|
313
|
+
|
|
314
|
+
return fetch(input, { ...options, headers });
|
|
315
|
+
}
|
|
316
|
+
|
|
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
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return fetchWithCache(input, options);
|
|
328
|
+
}
|
|
329
|
+
`;
|
|
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
|
+
*/
|
|
348
|
+
|
|
349
|
+
export async function invalidateByTag(tag) {
|
|
350
|
+
if (!navigator.serviceWorker?.controller) return;
|
|
351
|
+
|
|
352
|
+
navigator.serviceWorker.controller.postMessage({
|
|
353
|
+
type: "INVALIDATE_TAG",
|
|
354
|
+
tag,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
window.dispatchEvent(
|
|
358
|
+
new CustomEvent("cache-invalidated", { detail: { tags: [tag] } })
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function invalidateByTags(tags) {
|
|
363
|
+
for (const tag of tags) {
|
|
364
|
+
await invalidateByTag(tag);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function initCrossTabSync() {
|
|
369
|
+
if (!navigator.serviceWorker) return;
|
|
370
|
+
|
|
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
|
+
);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
`;
|
|
382
|
+
writeFileSync(join(swoffDir, "cache.js"), code);
|
|
383
|
+
generatedFiles.push("swoff/cache.js");
|
|
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
|
+
*/
|
|
406
|
+
|
|
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
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
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
|
+
});
|
|
446
|
+
|
|
447
|
+
await new Promise((resolve, reject) => {
|
|
448
|
+
tx.oncomplete = () => resolve();
|
|
449
|
+
tx.onerror = () => reject(tx.error);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
window.dispatchEvent(new CustomEvent("mutation-queue-changed"));
|
|
453
|
+
}
|
|
454
|
+
|
|
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
|
+
}
|
|
480
|
+
|
|
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
|
+
});
|
|
490
|
+
|
|
491
|
+
if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
|
|
492
|
+
|
|
493
|
+
const serverData = await response.json();
|
|
494
|
+
|
|
495
|
+
if (item.tempId && item.storeName) {
|
|
496
|
+
await reconcileRecord(item.storeName, item.tempId, serverData);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (item.tags && item.tags.length > 0) {
|
|
500
|
+
const { invalidateByTags } = await import("./cache.js");
|
|
501
|
+
await invalidateByTags(item.tags);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
await removeFromQueue(item.id);
|
|
505
|
+
succeeded++;
|
|
506
|
+
} catch {
|
|
507
|
+
item.retryCount++;
|
|
508
|
+
await updateInQueue(item);
|
|
509
|
+
failed++;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
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
|
+
}
|
|
523
|
+
|
|
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
|
+
}
|
|
533
|
+
|
|
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
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
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
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
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
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
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
|
+
}
|
|
602
|
+
|
|
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
|
+
*/
|
|
627
|
+
|
|
628
|
+
import { queueMutation, processMutationQueue, getPendingCount } from "./mutation-queue.js";
|
|
629
|
+
|
|
630
|
+
const SYNC_TAG = "sync-mutations";
|
|
631
|
+
|
|
632
|
+
async function registerSync() {
|
|
633
|
+
if (!("serviceWorker" in navigator) || !("SyncManager" in window)) {
|
|
634
|
+
window.addEventListener("online", processMutationQueue, { once: true });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
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
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function syncWhenPossible(mutation) {
|
|
647
|
+
await queueMutation(mutation);
|
|
648
|
+
await registerSync();
|
|
649
|
+
}
|
|
650
|
+
|
|
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
|
+
}
|
|
658
|
+
|
|
659
|
+
window.addEventListener("mutation-sync-complete", retrySync);
|
|
660
|
+
`;
|
|
661
|
+
writeFileSync(join(swoffDir, "background-sync.js"), code);
|
|
662
|
+
generatedFiles.push("swoff/background-sync.js");
|
|
663
|
+
}
|
|
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
|
+
*/
|
|
674
|
+
|
|
675
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
676
|
+
import { join, dirname } from 'path';
|
|
677
|
+
import { fileURLToPath } from 'url';
|
|
678
|
+
|
|
679
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
680
|
+
const projectRoot = join(__dirname, '..');
|
|
681
|
+
|
|
682
|
+
const pkgPath = join(projectRoot, 'package.json');
|
|
683
|
+
const templatePath = join(__dirname, 'sw-template.js');
|
|
684
|
+
const configPath = join(projectRoot, 'swoff.config.json');
|
|
685
|
+
|
|
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
|
+
}
|
|
691
|
+
|
|
692
|
+
if (!existsSync(templatePath)) {
|
|
693
|
+
console.error('Error: swoff/sw-template.js not found');
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
|
|
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');
|
|
700
|
+
|
|
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';
|
|
704
|
+
|
|
705
|
+
let sw = template;
|
|
706
|
+
sw = sw.replace('// [[CACHE_NAME]]', \`CACHE_NAME = 'sw-v\${version}'\`);
|
|
707
|
+
|
|
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};\`);
|
|
713
|
+
|
|
714
|
+
const outDir = join(projectRoot, outputDir);
|
|
715
|
+
if (!existsSync(outDir)) {
|
|
716
|
+
import('fs').then(fs => fs.mkdirSync(outDir, { recursive: true }));
|
|
717
|
+
}
|
|
718
|
+
|
|
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));
|
|
725
|
+
|
|
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");
|
|
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
|
+
*/
|
|
746
|
+
|
|
747
|
+
let CACHE_NAME = "";
|
|
748
|
+
let ASSETS_TO_CACHE = [];
|
|
749
|
+
|
|
750
|
+
// [[CACHE_NAME]]
|
|
751
|
+
// [[ASSETS_LIST]]
|
|
752
|
+
// [[AUTO_SKIP_WAITING]]
|
|
753
|
+
|
|
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);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (AUTO_SKIP_WAITING) self.skipWaiting();
|
|
782
|
+
})(),
|
|
783
|
+
);
|
|
784
|
+
});
|
|
785
|
+
|
|
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
|
+
});
|
|
796
|
+
|
|
797
|
+
// Message - skip waiting and cache invalidation
|
|
798
|
+
self.addEventListener("message", (event) => {
|
|
799
|
+
if (event.data.type === "SKIP_WAITING") {
|
|
800
|
+
self.skipWaiting();
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// Fetch - cache strategies
|
|
805
|
+
self.addEventListener("fetch", (event) => {
|
|
806
|
+
if (event.request.method !== "GET" && event.request.method !== "HEAD") return;
|
|
807
|
+
|
|
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);
|
|
813
|
+
|
|
814
|
+
const byPath = await cache.match(url.pathname);
|
|
815
|
+
if (byPath) return byPath;
|
|
816
|
+
|
|
817
|
+
const byRequest = await runtimeCache.match(event.request);
|
|
818
|
+
if (byRequest) return byRequest;
|
|
819
|
+
|
|
820
|
+
if (event.request.mode === "navigate") {
|
|
821
|
+
const spa = await cache.match("/index.html");
|
|
822
|
+
if (spa) return spa;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
const response = await fetch(event.request);
|
|
827
|
+
if (response.ok) {
|
|
828
|
+
await runtimeCache.put(event.request, response.clone());
|
|
829
|
+
}
|
|
830
|
+
return response;
|
|
831
|
+
} catch {
|
|
832
|
+
return new Response("Offline: content not available", { status: 503 });
|
|
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);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
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
|
+
*/
|
|
876
|
+
|
|
877
|
+
const DB_NAME = "${dbName}";
|
|
878
|
+
|
|
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
|
+
}
|
|
886
|
+
|
|
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
|
+
}
|
|
897
|
+
|
|
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
|
+
}
|
|
908
|
+
|
|
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
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
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
|
+
*/
|
|
945
|
+
|
|
946
|
+
import { getRecord, putRecord, deleteRecord } from './store.js';
|
|
947
|
+
|
|
948
|
+
export async function reconcileRecord(storeName, tempId, serverData) {
|
|
949
|
+
const existing = await getRecord(storeName, tempId);
|
|
950
|
+
if (!existing) return;
|
|
951
|
+
|
|
952
|
+
const reconciled = {
|
|
953
|
+
...existing,
|
|
954
|
+
...serverData,
|
|
955
|
+
id: serverData.id,
|
|
956
|
+
$synced: true,
|
|
957
|
+
$syncedAt: Date.now(),
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
await putRecord(storeName, reconciled);
|
|
961
|
+
|
|
962
|
+
if (String(tempId) !== String(serverData.id)) {
|
|
963
|
+
await deleteRecord(storeName, tempId);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
await reconcileReferences(storeName, tempId, serverData.id);
|
|
967
|
+
}
|
|
968
|
+
|
|
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
|
+
*/
|
|
998
|
+
|
|
999
|
+
const DB_NAME = "${dbName}";
|
|
1000
|
+
const DB_VERSION = 1;
|
|
1001
|
+
|
|
1002
|
+
export function openDB() {
|
|
1003
|
+
return new Promise((resolve, reject) => {
|
|
1004
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
1005
|
+
|
|
1006
|
+
request.onupgradeneeded = (e) => {
|
|
1007
|
+
const db = e.target.result;
|
|
1008
|
+
const oldVersion = e.oldVersion;
|
|
1009
|
+
|
|
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
|
+
};
|
|
1018
|
+
|
|
1019
|
+
request.onsuccess = (e) => resolve(e.target.result);
|
|
1020
|
+
request.onerror = (e) => reject(e.target.error);
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export async function requestPersistentStorage() {
|
|
1025
|
+
if (!navigator.storage?.persist) return false;
|
|
1026
|
+
|
|
1027
|
+
const isPersisted = await navigator.storage.persisted();
|
|
1028
|
+
if (isPersisted) return true;
|
|
1029
|
+
|
|
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;
|
|
1040
|
+
|
|
1041
|
+
return {
|
|
1042
|
+
usage: estimate.usage,
|
|
1043
|
+
quota: estimate.quota,
|
|
1044
|
+
ratio,
|
|
1045
|
+
status: ratio >= 0.95 ? "critical" : ratio >= 0.8 ? "warning" : "ok",
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
`;
|
|
1049
|
+
writeFileSync(join(swoffDir, "indexeddb.js"), code);
|
|
1050
|
+
generatedFiles.push("swoff/indexeddb.js");
|
|
1051
|
+
}
|
|
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" }>;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
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
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
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
|
+
}
|
|
1080
|
+
|
|
1081
|
+
export interface SWOFF {
|
|
1082
|
+
cache: SWOFFCache;
|
|
1083
|
+
network: {
|
|
1084
|
+
fetch(request: Request | string, options?: RequestInit): Promise<Response>;
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
export interface FetchWithCacheOptions extends RequestInit {
|
|
1089
|
+
strategy?: "read" | "mutation";
|
|
1090
|
+
tags?: string[];
|
|
1091
|
+
staleWhileRevalidate?: boolean;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
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;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export interface MutationQueueResult {
|
|
1109
|
+
succeeded: number;
|
|
1110
|
+
failed: number;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
export {};
|
|
1114
|
+
`;
|
|
1115
|
+
writeFileSync(join(swoffDir, "swoff.d.ts"), code);
|
|
1116
|
+
generatedFiles.push("swoff/swoff.d.ts");
|
|
1117
|
+
}
|
|
1118
|
+
function generateManifest() {
|
|
1119
|
+
const outputDir = join(projectRoot, "public");
|
|
1120
|
+
if (!existsSync(outputDir))
|
|
1121
|
+
mkdirSync(outputDir, { recursive: true });
|
|
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
|
+
],
|
|
1142
|
+
};
|
|
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
|
+
*/
|
|
1177
|
+
|
|
1178
|
+
const PREVENT_DEFAULT_INSTALL = ${preventDefault};
|
|
1179
|
+
|
|
1180
|
+
window.addEventListener("beforeinstallprompt", (e) => {
|
|
1181
|
+
// Always capture the event so we never lose it
|
|
1182
|
+
window.deferredInstallPrompt = e;
|
|
1183
|
+
window.pwaInstallable = true;
|
|
1184
|
+
|
|
1185
|
+
if (PREVENT_DEFAULT_INSTALL) {
|
|
1186
|
+
// Suppress browser's native prompt
|
|
1187
|
+
e.preventDefault();
|
|
1188
|
+
}
|
|
1189
|
+
// When false, browser shows native prompt naturally
|
|
1190
|
+
// but we still capture the event for manual triggering
|
|
1191
|
+
|
|
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;
|
|
1202
|
+
|
|
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");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
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
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
window.deferredInstallPrompt = null;
|
|
1239
|
+
return choice;
|
|
1240
|
+
}
|
|
1241
|
+
`;
|
|
1242
|
+
writeFileSync(join(swoffDir, "pwa-install.js"), code);
|
|
1243
|
+
generatedFiles.push("swoff/pwa-install.js");
|
|
1244
|
+
}
|
|
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
|
+
*/
|
|
1267
|
+
|
|
1268
|
+
import { invalidateByTag, invalidateByTags } from "./cache.js";
|
|
1269
|
+
|
|
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);
|
|
1273
|
+
|
|
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++;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
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}\`);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
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]);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
return tags;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
export function generateTagsFromMethod(method, url) {
|
|
1310
|
+
const tags = generateTags(url);
|
|
1311
|
+
|
|
1312
|
+
if (method === "GET" || method === "HEAD") {
|
|
1313
|
+
return tags;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// For mutations, add method prefix
|
|
1317
|
+
return tags.map((tag) => \`\${method.toLowerCase()}-\${tag}\`);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
export async function invalidateUrl(url) {
|
|
1321
|
+
const tags = generateTags(url);
|
|
1322
|
+
await invalidateByTags(tags);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
export async function invalidateByMethod(method, url) {
|
|
1326
|
+
const tags = generateTagsFromMethod(method, url);
|
|
1327
|
+
await invalidateByTags(tags);
|
|
1328
|
+
}
|
|
1329
|
+
`;
|
|
1330
|
+
writeFileSync(join(swoffDir, "invalidation-tags.js"), code);
|
|
1331
|
+
generatedFiles.push("swoff/invalidation-tags.js");
|
|
1332
|
+
}
|
|
1333
|
+
console.log("Generating Swoff files...");
|
|
1334
|
+
console.log(`Language: ${language}`);
|
|
1335
|
+
console.log(`Project: ${projectRoot}`);
|
|
1336
|
+
console.log("");
|
|
1337
|
+
if (!config.enabled) {
|
|
1338
|
+
console.log("Config generation disabled");
|
|
1339
|
+
process.exit(0);
|
|
1340
|
+
}
|
|
1341
|
+
console.log("Generating pattern files...");
|
|
1342
|
+
generateSwTemplate();
|
|
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");
|
|
1353
|
+
}
|
|
1354
|
+
if (config.features.mutationQueue) {
|
|
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");
|
|
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");
|
|
1374
|
+
if (config.features.pwa) {
|
|
1375
|
+
generatePwaInstall();
|
|
1376
|
+
console.log(" pwa-install");
|
|
1377
|
+
generateManifest();
|
|
1378
|
+
console.log(" manifest.json");
|
|
1379
|
+
}
|
|
1380
|
+
if (config.features.tagInvalidation) {
|
|
1381
|
+
generateInvalidationTags();
|
|
1382
|
+
console.log(" invalidation-tags");
|
|
1383
|
+
}
|
|
1384
|
+
console.log("\nGenerated files:");
|
|
1385
|
+
generatedFiles.forEach((file) => console.log(` ${file}`));
|
|
1386
|
+
console.log(`\nTotal: ${generatedFiles.length} files`);
|
|
1387
|
+
//# sourceMappingURL=swoff-files-generator.js.map
|