@swoff/cli 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +49 -0
  2. package/dist/__tests__/assemble-sw.test.js +145 -0
  3. package/dist/__tests__/assemble-sw.test.js.map +1 -0
  4. package/dist/__tests__/cli-integration.test.js +128 -0
  5. package/dist/__tests__/cli-integration.test.js.map +1 -0
  6. package/dist/__tests__/config-types.test.js +82 -0
  7. package/dist/__tests__/config-types.test.js.map +1 -0
  8. package/dist/__tests__/detect-language.test.js +51 -0
  9. package/dist/__tests__/detect-language.test.js.map +1 -0
  10. package/dist/__tests__/file-generators.test.js +272 -0
  11. package/dist/__tests__/file-generators.test.js.map +1 -0
  12. package/dist/__tests__/invalidation-tags.test.js +99 -0
  13. package/dist/__tests__/invalidation-tags.test.js.map +1 -0
  14. package/dist/__tests__/loader.test.js +77 -0
  15. package/dist/__tests__/loader.test.js.map +1 -0
  16. package/dist/__tests__/validator.test.js +231 -0
  17. package/dist/__tests__/validator.test.js.map +1 -0
  18. package/dist/index.js +22 -585
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/cli/colors.js +14 -0
  21. package/dist/lib/cli/colors.js.map +1 -0
  22. package/dist/lib/cli/help.js +63 -0
  23. package/dist/lib/cli/help.js.map +1 -0
  24. package/dist/lib/cli/logger.js +13 -0
  25. package/dist/lib/cli/logger.js.map +1 -0
  26. package/dist/lib/commands/add.js +58 -0
  27. package/dist/lib/commands/add.js.map +1 -0
  28. package/dist/lib/commands/clean.js +39 -0
  29. package/dist/lib/commands/clean.js.map +1 -0
  30. package/dist/lib/commands/generate.js +84 -0
  31. package/dist/lib/commands/generate.js.map +1 -0
  32. package/dist/lib/commands/info.js +44 -0
  33. package/dist/lib/commands/info.js.map +1 -0
  34. package/dist/lib/commands/init.js +46 -0
  35. package/dist/lib/commands/init.js.map +1 -0
  36. package/dist/lib/commands/validate.js +30 -0
  37. package/dist/lib/commands/validate.js.map +1 -0
  38. package/dist/lib/config/loader.js +80 -0
  39. package/dist/lib/config/loader.js.map +1 -0
  40. package/dist/lib/config/validator.js +102 -0
  41. package/dist/lib/config/validator.js.map +1 -0
  42. package/dist/lib/generators/file-generators/background-sync.js +59 -0
  43. package/dist/lib/generators/file-generators/background-sync.js.map +1 -0
  44. package/dist/lib/generators/file-generators/cache.js +55 -0
  45. package/dist/lib/generators/file-generators/cache.js.map +1 -0
  46. package/dist/lib/generators/file-generators/context.js +16 -0
  47. package/dist/lib/generators/file-generators/context.js.map +1 -0
  48. package/dist/lib/generators/file-generators/fetch-wrapper.js +80 -0
  49. package/dist/lib/generators/file-generators/fetch-wrapper.js.map +1 -0
  50. package/dist/lib/generators/file-generators/indexeddb.js +73 -0
  51. package/dist/lib/generators/file-generators/indexeddb.js.map +1 -0
  52. package/dist/lib/generators/file-generators/invalidation-tags.js +91 -0
  53. package/dist/lib/generators/file-generators/invalidation-tags.js.map +1 -0
  54. package/dist/lib/generators/file-generators/manifest.js +26 -0
  55. package/dist/lib/generators/file-generators/manifest.js.map +1 -0
  56. package/dist/lib/generators/file-generators/mutation-queue.js +226 -0
  57. package/dist/lib/generators/file-generators/mutation-queue.js.map +1 -0
  58. package/dist/lib/generators/file-generators/pwa-install.js +102 -0
  59. package/dist/lib/generators/file-generators/pwa-install.js.map +1 -0
  60. package/dist/lib/generators/file-generators/reconcile.js +54 -0
  61. package/dist/lib/generators/file-generators/reconcile.js.map +1 -0
  62. package/dist/lib/generators/file-generators/store.js +75 -0
  63. package/dist/lib/generators/file-generators/store.js.map +1 -0
  64. package/dist/lib/generators/file-generators/sw-generator-build.js +70 -0
  65. package/dist/lib/generators/file-generators/sw-generator-build.js.map +1 -0
  66. package/dist/lib/generators/file-generators/sw-injector.js +201 -0
  67. package/dist/lib/generators/file-generators/sw-injector.js.map +1 -0
  68. package/dist/lib/generators/file-generators/sw-template.js +143 -0
  69. package/dist/lib/generators/file-generators/sw-template.js.map +1 -0
  70. package/dist/lib/generators/file-generators/type-definitions.js +69 -0
  71. package/dist/lib/generators/file-generators/type-definitions.js.map +1 -0
  72. package/dist/lib/generators/sw-generator.js +33 -569
  73. package/dist/lib/generators/sw-generator.js.map +1 -1
  74. package/dist/lib/generators/sw-sections/activate-handler.js +16 -0
  75. package/dist/lib/generators/sw-sections/activate-handler.js.map +1 -0
  76. package/dist/lib/generators/sw-sections/assemble-sw.js +38 -0
  77. package/dist/lib/generators/sw-sections/assemble-sw.js.map +1 -0
  78. package/dist/lib/generators/sw-sections/background-sync-handler.js +106 -0
  79. package/dist/lib/generators/sw-sections/background-sync-handler.js.map +1 -0
  80. package/dist/lib/generators/sw-sections/config-header.js +15 -0
  81. package/dist/lib/generators/sw-sections/config-header.js.map +1 -0
  82. package/dist/lib/generators/sw-sections/default-template.js +51 -0
  83. package/dist/lib/generators/sw-sections/default-template.js.map +1 -0
  84. package/dist/lib/generators/sw-sections/fetch-handler.js +203 -0
  85. package/dist/lib/generators/sw-sections/fetch-handler.js.map +1 -0
  86. package/dist/lib/generators/sw-sections/install-handler.js +35 -0
  87. package/dist/lib/generators/sw-sections/install-handler.js.map +1 -0
  88. package/dist/lib/generators/sw-sections/message-handler.js +20 -0
  89. package/dist/lib/generators/sw-sections/message-handler.js.map +1 -0
  90. package/dist/lib/generators/sw-sections/tag-management.js +69 -0
  91. package/dist/lib/generators/sw-sections/tag-management.js.map +1 -0
  92. package/dist/lib/generators/swoff-files-generator.js +78 -1368
  93. package/dist/lib/generators/swoff-files-generator.js.map +1 -1
  94. package/dist/lib/shared/config-types.js +87 -0
  95. package/dist/lib/shared/config-types.js.map +1 -0
  96. package/dist/lib/utils/detect-language.js +33 -0
  97. package/dist/lib/utils/detect-language.js.map +1 -0
  98. package/package.json +5 -2
@@ -2,1386 +2,96 @@
2
2
  * Swoff Files Generator
3
3
  *
4
4
  * Generates framework-agnostic pattern files based on swoff.config.json features.
5
- * All files go into swoff/ directory (or public/ for manifest).
5
+ * Thin orchestrator - delegates to file-generators for each template.
6
6
  *
7
7
  * CLI Usage:
8
- * node swoff-files-generator.js --project-root <path> --package-dir <path> --language <ts|js> --config-path <path>
8
+ * node swoff-files-generator.js --project-root <path> --language <ts|js> --config-path <path>
9
+ *
10
+ * Module Usage:
11
+ * import { generateFiles } from './swoff-files-generator.js';
12
+ * const files = generateFiles(ctx, (name) => { ... });
9
13
  */
10
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
14
+ import { join } from "path";
11
15
  import { fileURLToPath } from "url";
12
- import { join, dirname } from "path";
16
+ import { loadConfig } from "../config/loader.js";
17
+ import { generateSwTemplate } from "./file-generators/sw-template.js";
18
+ import { generateSwInjector } from "./file-generators/sw-injector.js";
19
+ import { generateFetchWrapper } from "./file-generators/fetch-wrapper.js";
20
+ import { generateCache } from "./file-generators/cache.js";
21
+ import { generateMutationQueue } from "./file-generators/mutation-queue.js";
22
+ import { generateStore } from "./file-generators/store.js";
23
+ import { generateReconcile } from "./file-generators/reconcile.js";
24
+ import { generateBackgroundSync } from "./file-generators/background-sync.js";
25
+ import { generateIndexedDB } from "./file-generators/indexeddb.js";
26
+ import { generatePwaInstall } from "./file-generators/pwa-install.js";
27
+ import { generateManifest } from "./file-generators/manifest.js";
28
+ import { generateInvalidationTags } from "./file-generators/invalidation-tags.js";
29
+ import { generateSwGeneratorBuild } from "./file-generators/sw-generator-build.js";
30
+ import { generateTypeDefinitions } from "./file-generators/type-definitions.js";
31
+ export function generateFiles(ctx, onFile) {
32
+ const steps = [
33
+ { name: "sw-template", gen: () => generateSwTemplate(ctx), enabled: true },
34
+ { name: "sw-injector", gen: () => generateSwInjector(ctx), enabled: ctx.config.features.clientRegistration },
35
+ { name: "fetch-wrapper", gen: () => generateFetchWrapper(ctx), enabled: true },
36
+ { name: "cache", gen: () => generateCache(ctx), enabled: ctx.config.features.tagInvalidation || ctx.config.features.crossTabSync },
37
+ { name: "store", gen: () => generateStore(ctx), enabled: ctx.config.features.mutationQueue },
38
+ { name: "reconcile", gen: () => generateReconcile(ctx), enabled: ctx.config.features.mutationQueue },
39
+ { name: "mutation-queue", gen: () => generateMutationQueue(ctx), enabled: ctx.config.features.mutationQueue },
40
+ { name: "background-sync", gen: () => generateBackgroundSync(ctx), enabled: ctx.config.features.backgroundSync },
41
+ { name: "indexeddb", gen: () => generateIndexedDB(ctx), enabled: ctx.config.features.indexeddb },
42
+ { name: "sw-generator", gen: () => generateSwGeneratorBuild(ctx), enabled: true },
43
+ { name: "swoff.d.ts", gen: () => generateTypeDefinitions(ctx), enabled: ctx.ext === "ts" },
44
+ { name: "pwa-install", gen: () => generatePwaInstall(ctx), enabled: ctx.config.features.pwa },
45
+ { name: "manifest.json", gen: () => generateManifest(ctx), enabled: ctx.config.features.pwa },
46
+ { name: "invalidation-tags", gen: () => generateInvalidationTags(ctx), enabled: ctx.config.features.tagInvalidation },
47
+ ];
48
+ for (const step of steps) {
49
+ if (!step.enabled)
50
+ continue;
51
+ onFile?.(step.name);
52
+ step.gen();
53
+ }
54
+ return ctx.generatedFiles;
55
+ }
56
+ // --- CLI entry point ---
13
57
  const args = process.argv.slice(2);
14
58
  function getArg(name) {
15
59
  const idx = args.findIndex((arg) => arg === `--${name}`);
16
60
  return idx !== -1 ? args[idx + 1] : null;
17
61
  }
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
- );
62
+ if (fileURLToPath(import.meta.url) === fileURLToPath(new URL(process.argv[1], "file:"))) {
63
+ const projectRoot = getArg("project-root") || process.cwd();
64
+ const language = getArg("language") || "ts";
65
+ const configPath = getArg("config-path") || join(projectRoot, "swoff.config.json");
66
+ const { config } = loadConfig(projectRoot, configPath);
67
+ if (!config.enabled) {
68
+ console.log("Config generation disabled");
69
+ process.exit(0);
378
70
  }
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
- }
71
+ const ext = language === "ts" ? "ts" : "js";
72
+ const swoffDir = join(projectRoot, "swoff");
73
+ const generatedFiles = [];
74
+ const ctx = {
75
+ config,
76
+ projectRoot,
77
+ swoffDir,
78
+ ext,
79
+ generatedFiles,
420
80
  };
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);
81
+ const ttyStatus = process.stdout.isTTY
82
+ ? (msg) => {
83
+ const cols = process.stdout.columns || 80;
84
+ process.stdout.write(`\r${" ".repeat(cols - 1)}\r ${msg}`);
497
85
  }
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
- }
86
+ : (msg) => console.log(` ${msg}`);
87
+ console.log(`Generating Swoff files (${language})...`);
88
+ generateFiles(ctx, (name) => ttyStatus(`→ ${name}...`));
89
+ if (process.stdout.isTTY) {
90
+ const cols = process.stdout.columns || 80;
91
+ process.stdout.write(`\r${" ".repeat(cols - 1)}\r`);
511
92
  }
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");
93
+ console.log("Generated files:");
94
+ generatedFiles.forEach((file) => console.log(` ${file}`));
95
+ console.log(`\nTotal: ${generatedFiles.length} files`);
1383
96
  }
1384
- console.log("\nGenerated files:");
1385
- generatedFiles.forEach((file) => console.log(` ${file}`));
1386
- console.log(`\nTotal: ${generatedFiles.length} files`);
1387
97
  //# sourceMappingURL=swoff-files-generator.js.map