@farm.js/pwa 0.1.0-beta.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Farm.js Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @farm.js/pwa
2
+
3
+ Installable, offline-aware Progressive Web Apps for Farm.js. The plugin generates a route-aware
4
+ service worker during the production build and registers it through Farm's browser plugin lifecycle.
5
+
6
+ Farm.js is currently in beta.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pnpm add @farm.js/pwa
12
+ ```
13
+
14
+ ## Configure
15
+
16
+ ```ts
17
+ import { defineConfig } from "@farm.js/core";
18
+ import { pwa } from "@farm.js/pwa";
19
+
20
+ export default defineConfig({
21
+ plugins: [
22
+ pwa({
23
+ offline: "/offline",
24
+ cache: "recommended",
25
+ }),
26
+ ],
27
+ });
28
+ ```
29
+
30
+ Create `src/app/offline/page.tsx` as a static route and define application metadata in
31
+ `src/app/manifest.ts`. Farm already serves that manifest at `/manifest.webmanifest`, so the plugin
32
+ does not duplicate it.
33
+
34
+ ## Short cache configuration
35
+
36
+ `recommended` precaches emitted static pages and uses SWR for public same-origin images:
37
+
38
+ ```ts
39
+ pwa({
40
+ offline: "/offline",
41
+ cache: "recommended",
42
+ });
43
+ ```
44
+
45
+ The explicit short form is:
46
+
47
+ ```ts
48
+ pwa({
49
+ cache: {
50
+ staticRoutes: true,
51
+ images: "swr",
52
+ },
53
+ });
54
+ ```
55
+
56
+ `swr` means stale while revalidate. A cached image is returned immediately while the worker fetches
57
+ and stores a newer response in the background. The default keeps 100 entries fresh for 30 days.
58
+
59
+ Override those limits only when needed:
60
+
61
+ ```ts
62
+ pwa({
63
+ cache: {
64
+ staticRoutes: ["/", "/pricing"],
65
+ images: {
66
+ strategy: "swr",
67
+ limit: 200,
68
+ ttl: "7d",
69
+ },
70
+ },
71
+ });
72
+ ```
73
+
74
+ ## Updates
75
+
76
+ `update: "prompt"` is the default. The plugin dispatches `farm:pwa:update-available` when a new
77
+ worker is waiting:
78
+
79
+ ```ts
80
+ window.addEventListener("farm:pwa:update-available", (event) => {
81
+ const { applyUpdate } = (event as CustomEvent).detail;
82
+ showUpdateButton(applyUpdate);
83
+ });
84
+ ```
85
+
86
+ The same action is available as `window.__FARM_PWA__?.applyUpdate()`. Use `update: "auto"` only when
87
+ an automatic reload cannot discard unsaved user work.
88
+
89
+ ## Safety boundaries
90
+
91
+ The generated worker only intercepts `GET` requests. It never caches cross-origin traffic,
92
+ mutations, API requests as pages, server actions, integrations, or workflows. Image requests with
93
+ an `Authorization` header and responses marked `private`, `no-store`, or `no-cache` are not stored.
94
+ Treat `images: "swr"` as an opt-in for public images, not private account media.
95
+
96
+ Service workers register only in production. Test the complete behavior with `farm build` and a
97
+ production server over HTTPS or localhost.
@@ -0,0 +1,29 @@
1
+ import type { ResolvedPwaOptions } from "./config.js";
2
+ export interface PwaBuildInput {
3
+ outputDir: string;
4
+ publicDir?: string;
5
+ preset: string;
6
+ basePath: string;
7
+ options: ResolvedPwaOptions;
8
+ }
9
+ export interface PwaBuildResult {
10
+ workerPath: string;
11
+ workerUrl: string;
12
+ precacheUrls: string[];
13
+ staticRoutes: Record<string, string>;
14
+ cacheId: string;
15
+ }
16
+ export declare function writePwaBuildArtifacts(input: PwaBuildInput): Promise<PwaBuildResult>;
17
+ export interface GenerateServiceWorkerOptions {
18
+ cacheId: string;
19
+ precacheUrls: string[];
20
+ staticRoutes: Record<string, string>;
21
+ offlineRoute: string | false;
22
+ update: "prompt" | "auto";
23
+ images: ResolvedPwaOptions["cache"]["images"];
24
+ }
25
+ export declare function generateServiceWorker(options: GenerateServiceWorkerOptions): string;
26
+ export declare function resolvePwaPublicDir(outputDir: string, preset: string): string;
27
+ export declare function normalizeBasePath(value: string | undefined): string;
28
+ export declare function withBasePath(route: string, basePath: string): string;
29
+ //# sourceMappingURL=build.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,kBAAkB,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;CACjB;AAcD,wBAAsB,sBAAsB,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CA0E1F;AAED,MAAM,WAAW,4BAA4B;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,YAAY,EAAE,MAAM,GAAG,KAAK,CAAC;IAC7B,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC1B,MAAM,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,CAAC;CAC/C;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,MAAM,CAkJnF;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7E;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAGnE;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQpE"}
package/dist/build.js ADDED
@@ -0,0 +1,279 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ const PRECACHE_EXTENSIONS = new Set([
5
+ ".css",
6
+ ".js",
7
+ ".mjs",
8
+ ".otf",
9
+ ".ttf",
10
+ ".wasm",
11
+ ".webmanifest",
12
+ ".woff",
13
+ ".woff2",
14
+ ]);
15
+ export async function writePwaBuildArtifacts(input) {
16
+ const publicDir = input.publicDir ?? resolvePwaPublicDir(input.outputDir, input.preset);
17
+ const files = await walkFiles(publicDir);
18
+ const staticRouteFiles = resolveStaticRouteFiles(files);
19
+ const basePath = normalizeBasePath(input.basePath);
20
+ const workerRelativePath = path.posix.join(basePath.replace(/^\//, ""), "sw.js");
21
+ const selectedRoutes = selectStaticRoutes(staticRouteFiles, input.options.cache.staticRoutes, basePath);
22
+ const configuredOfflineRoute = input.options.offline;
23
+ const offlineRoute = configuredOfflineRoute
24
+ ? withBasePath(configuredOfflineRoute, basePath)
25
+ : false;
26
+ if (offlineRoute && configuredOfflineRoute) {
27
+ const emittedOfflineFile = staticRouteFiles[configuredOfflineRoute] ?? staticRouteFiles[offlineRoute];
28
+ if (!emittedOfflineFile) {
29
+ throw new Error(`[farm:pwa] Offline route ${JSON.stringify(input.options.offline)} was not emitted as a static page. ` +
30
+ "Create that page as a static Farm route so it can be precached.");
31
+ }
32
+ selectedRoutes[offlineRoute] = emittedOfflineFile;
33
+ }
34
+ const assetFiles = files.filter((file) => file !== workerRelativePath && PRECACHE_EXTENSIONS.has(path.extname(file).toLowerCase()));
35
+ const precacheFiles = [...new Set([...assetFiles, ...Object.values(selectedRoutes)])].sort();
36
+ const precacheUrls = precacheFiles.map(toPublicUrl);
37
+ const fileHashes = await Promise.all(precacheFiles.map(async (file) => {
38
+ const content = await readFile(path.join(publicDir, file));
39
+ return `${file}:${createHash("sha256").update(content).digest("hex")}`;
40
+ }));
41
+ const cacheId = createHash("sha256")
42
+ .update(JSON.stringify({
43
+ files: fileHashes,
44
+ routes: selectedRoutes,
45
+ offlineRoute,
46
+ update: input.options.update,
47
+ images: input.options.cache.images,
48
+ }))
49
+ .digest("hex")
50
+ .slice(0, 16);
51
+ const workerPath = path.join(publicDir, ...workerRelativePath.split("/"));
52
+ await mkdir(path.dirname(workerPath), { recursive: true });
53
+ await writeFile(workerPath, generateServiceWorker({
54
+ cacheId,
55
+ precacheUrls,
56
+ staticRoutes: selectedRoutes,
57
+ offlineRoute,
58
+ update: input.options.update,
59
+ images: input.options.cache.images,
60
+ }));
61
+ return {
62
+ workerPath,
63
+ workerUrl: withBasePath("/sw.js", basePath),
64
+ precacheUrls,
65
+ staticRoutes: selectedRoutes,
66
+ cacheId,
67
+ };
68
+ }
69
+ export function generateServiceWorker(options) {
70
+ const routeFiles = Object.fromEntries(Object.entries(options.staticRoutes).map(([route, file]) => [route, toPublicUrl(file)]));
71
+ const offlineFile = options.offlineRoute ? (routeFiles[options.offlineRoute] ?? null) : null;
72
+ return `/* Generated by @farm.js/pwa. */
73
+ const PRECACHE = ${JSON.stringify(`farm-pwa-precache-${options.cacheId}`)};
74
+ const IMAGE_CACHE = "farm-pwa-images-v1";
75
+ const PRECACHE_URLS = ${JSON.stringify(options.precacheUrls)};
76
+ const PRECACHE_PATHS = new Set(PRECACHE_URLS.map((value) => new URL(value, self.location.origin).pathname));
77
+ const STATIC_ROUTES = ${JSON.stringify(routeFiles)};
78
+ const OFFLINE_FILE = ${JSON.stringify(offlineFile)};
79
+ const IMAGE_OPTIONS = ${JSON.stringify(options.images)};
80
+
81
+ self.addEventListener("install", (event) => {
82
+ event.waitUntil((async () => {
83
+ const cache = await caches.open(PRECACHE);
84
+ await cache.addAll(PRECACHE_URLS);
85
+ ${options.update === "auto" ? "await self.skipWaiting();" : ""}
86
+ })());
87
+ });
88
+
89
+ self.addEventListener("activate", (event) => {
90
+ event.waitUntil((async () => {
91
+ const names = await caches.keys();
92
+ await Promise.all(names
93
+ .filter((name) => name.startsWith("farm-pwa-precache-") && name !== PRECACHE)
94
+ .map((name) => caches.delete(name)));
95
+ await self.clients.claim();
96
+ })());
97
+ });
98
+
99
+ self.addEventListener("message", (event) => {
100
+ if (event.data?.type === "FARM_PWA_SKIP_WAITING") void self.skipWaiting();
101
+ });
102
+
103
+ self.addEventListener("fetch", (event) => {
104
+ const request = event.request;
105
+ if (request.method !== "GET") return;
106
+
107
+ const url = new URL(request.url);
108
+ if (url.origin !== self.location.origin) return;
109
+
110
+ if (request.mode === "navigate") {
111
+ event.respondWith(handleNavigation(request, url));
112
+ return;
113
+ }
114
+
115
+ if (PRECACHE_PATHS.has(url.pathname)) {
116
+ event.respondWith(cacheFirst(request));
117
+ return;
118
+ }
119
+
120
+ if (IMAGE_OPTIONS && request.destination === "image") {
121
+ event.respondWith(staleWhileRevalidateImage(request));
122
+ }
123
+ });
124
+
125
+ async function cacheFirst(request) {
126
+ const cache = await caches.open(PRECACHE);
127
+ return (await cache.match(request, { ignoreSearch: true })) ?? fetch(request);
128
+ }
129
+
130
+ async function handleNavigation(request, url) {
131
+ const staticFile = STATIC_ROUTES[normalizePathname(url.pathname)];
132
+ if (staticFile) {
133
+ const cached = await caches.match(staticFile, { cacheName: PRECACHE });
134
+ if (cached) return cached;
135
+ }
136
+
137
+ try {
138
+ return await fetch(request);
139
+ } catch (error) {
140
+ if (OFFLINE_FILE) {
141
+ const fallback = await caches.match(OFFLINE_FILE, { cacheName: PRECACHE });
142
+ if (fallback) return fallback;
143
+ }
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ async function staleWhileRevalidateImage(request) {
149
+ if (request.headers.has("authorization")) return fetch(request);
150
+
151
+ const cache = await caches.open(IMAGE_CACHE);
152
+ const cached = await cache.match(request);
153
+ const cachedAt = Number(cached?.headers.get("x-farm-pwa-cached-at") || 0);
154
+ const fresh = cached && Date.now() - cachedAt <= IMAGE_OPTIONS.ttlMs;
155
+ const update = fetchAndCacheImage(request, cache);
156
+
157
+ if (fresh) {
158
+ void update.catch(() => undefined);
159
+ return cached;
160
+ }
161
+
162
+ try {
163
+ return await update;
164
+ } catch (error) {
165
+ if (cached) return cached;
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ async function fetchAndCacheImage(request, cache) {
171
+ const response = await fetch(request);
172
+ if (!isPublicCacheableImage(response)) return response;
173
+
174
+ const headers = new Headers(response.headers);
175
+ headers.set("x-farm-pwa-cached-at", String(Date.now()));
176
+ const stored = new Response(response.clone().body, {
177
+ status: response.status,
178
+ statusText: response.statusText,
179
+ headers,
180
+ });
181
+ await cache.put(request, stored);
182
+ await trimImageCache(cache, IMAGE_OPTIONS.limit);
183
+ return response;
184
+ }
185
+
186
+ function isPublicCacheableImage(response) {
187
+ if (!response.ok || response.type === "opaque") return false;
188
+ const policy = (response.headers.get("cache-control") || "").toLowerCase();
189
+ return !policy.includes("private") && !policy.includes("no-store") && !policy.includes("no-cache");
190
+ }
191
+
192
+ async function trimImageCache(cache, limit) {
193
+ const keys = await cache.keys();
194
+ if (keys.length <= limit) return;
195
+
196
+ const entries = await Promise.all(keys.map(async (request) => {
197
+ const response = await cache.match(request);
198
+ return {
199
+ request,
200
+ cachedAt: Number(response?.headers.get("x-farm-pwa-cached-at") || 0),
201
+ };
202
+ }));
203
+ entries.sort((left, right) => left.cachedAt - right.cachedAt);
204
+ await Promise.all(entries.slice(0, entries.length - limit).map(({ request }) => cache.delete(request)));
205
+ }
206
+
207
+ function normalizePathname(pathname) {
208
+ if (pathname === "/") return pathname;
209
+ return pathname.replace(/\\/+$/, "");
210
+ }
211
+ `;
212
+ }
213
+ export function resolvePwaPublicDir(outputDir, preset) {
214
+ return path.join(outputDir, preset === "vercel" || preset === "vercel-edge" ? "static" : "public");
215
+ }
216
+ export function normalizeBasePath(value) {
217
+ if (!value || value === "/")
218
+ return "/";
219
+ return `/${value.replace(/^\/+|\/+$/g, "")}`;
220
+ }
221
+ export function withBasePath(route, basePath) {
222
+ const normalizedBase = normalizeBasePath(basePath);
223
+ const normalizedRoute = route === "/" ? "/" : `/${route.replace(/^\/+|\/+$/g, "")}`;
224
+ if (normalizedBase === "/")
225
+ return normalizedRoute;
226
+ if (normalizedRoute === normalizedBase || normalizedRoute.startsWith(`${normalizedBase}/`)) {
227
+ return normalizedRoute;
228
+ }
229
+ return normalizedRoute === "/" ? normalizedBase : `${normalizedBase}${normalizedRoute}`;
230
+ }
231
+ async function walkFiles(root) {
232
+ const entries = await readdir(root, { withFileTypes: true });
233
+ const files = await Promise.all(entries.map(async (entry) => {
234
+ const relative = entry.name;
235
+ if (entry.isDirectory()) {
236
+ return (await walkFiles(path.join(root, entry.name))).map((file) => path.posix.join(relative, file));
237
+ }
238
+ return entry.isFile() ? [relative] : [];
239
+ }));
240
+ return files.flat().sort();
241
+ }
242
+ function resolveStaticRouteFiles(files) {
243
+ const routes = {};
244
+ for (const file of files) {
245
+ if (path.extname(file).toLowerCase() !== ".html")
246
+ continue;
247
+ const normalized = file.replace(/\\/g, "/");
248
+ const withoutIndex = normalized.replace(/(?:^|\/)index\.html$/, "");
249
+ const route = withoutIndex
250
+ ? `/${withoutIndex.replace(/\.html$/, "").replace(/^\/+|\/+$/g, "")}`
251
+ : "/";
252
+ routes[route] = normalized;
253
+ }
254
+ return routes;
255
+ }
256
+ function selectStaticRoutes(emitted, configured, basePath) {
257
+ if (configured === false)
258
+ return {};
259
+ if (configured === true) {
260
+ return Object.fromEntries(Object.entries(emitted).map(([route, file]) => [withBasePath(route, basePath), file]));
261
+ }
262
+ const selected = {};
263
+ for (const requested of configured) {
264
+ const route = withBasePath(requested, basePath);
265
+ const emittedFile = emitted[requested] ?? emitted[route];
266
+ if (!emittedFile) {
267
+ throw new Error(`[farm:pwa] Static route ${JSON.stringify(requested)} was not found in the production output.`);
268
+ }
269
+ selected[route] = emittedFile;
270
+ }
271
+ return selected;
272
+ }
273
+ function toPublicUrl(file) {
274
+ return `/${file
275
+ .replace(/\\/g, "/")
276
+ .split("/")
277
+ .map((segment) => encodeURIComponent(segment))
278
+ .join("/")}`;
279
+ }
@@ -0,0 +1,43 @@
1
+ export type PwaDuration = number | `${number}${"s" | "m" | "h" | "d" | "w"}`;
2
+ export interface PwaImageCacheOptions {
3
+ /** Show the cached image immediately and refresh it in the background. */
4
+ strategy: "swr";
5
+ /** Maximum number of public image responses retained in Cache Storage. */
6
+ limit?: number;
7
+ /** How long a cached image remains fresh. Numbers are milliseconds. */
8
+ ttl?: PwaDuration;
9
+ }
10
+ export type PwaImageCache = boolean | "swr" | PwaImageCacheOptions;
11
+ export interface PwaCacheOptions {
12
+ /** Precache every emitted static page, selected routes, or no static pages. */
13
+ staticRoutes?: boolean | string[];
14
+ /** Opt in to same-origin public image caching. */
15
+ images?: PwaImageCache;
16
+ }
17
+ export interface PwaPluginOptions {
18
+ /** Set false to keep the plugin registered without emitting or registering a worker. */
19
+ enabled?: boolean;
20
+ /** Static route to serve after an offline navigation misses the cache. */
21
+ offline?: string | false;
22
+ /** How a waiting service worker becomes active. */
23
+ update?: "prompt" | "auto";
24
+ /** Recommended caching, custom caching, or build assets only. */
25
+ cache?: "recommended" | PwaCacheOptions | false;
26
+ }
27
+ export interface ResolvedPwaImageCacheOptions {
28
+ strategy: "swr";
29
+ limit: number;
30
+ ttlMs: number;
31
+ }
32
+ export interface ResolvedPwaOptions {
33
+ enabled: boolean;
34
+ offline: string | false;
35
+ update: "prompt" | "auto";
36
+ cache: {
37
+ staticRoutes: boolean | string[];
38
+ images: ResolvedPwaImageCacheOptions | false;
39
+ };
40
+ }
41
+ export declare function resolvePwaOptions(options?: PwaPluginOptions): ResolvedPwaOptions;
42
+ export declare function parsePwaDuration(value: PwaDuration): number;
43
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,GAAG,MAAM,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE,CAAC;AAE7E,MAAM,WAAW,oBAAoB;IACnC,0EAA0E;IAC1E,QAAQ,EAAE,KAAK,CAAC;IAChB,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,GAAG,CAAC,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,KAAK,GAAG,oBAAoB,CAAC;AAEnE,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,OAAO,GAAG,MAAM,EAAE,CAAC;IAClC,kDAAkD;IAClD,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,wFAAwF;IACxF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACzB,mDAAmD;IACnD,MAAM,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC3B,iEAAiE;IACjE,KAAK,CAAC,EAAE,aAAa,GAAG,eAAe,GAAG,KAAK,CAAC;CACjD;AAED,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,EAAE,KAAK,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,KAAK,CAAC;IACxB,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC1B,KAAK,EAAE;QACL,YAAY,EAAE,OAAO,GAAG,MAAM,EAAE,CAAC;QACjC,MAAM,EAAE,4BAA4B,GAAG,KAAK,CAAC;KAC9C,CAAC;CACH;AAKD,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,gBAAqB,GAAG,kBAAkB,CAuBpF;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CA0B3D"}
package/dist/config.js ADDED
@@ -0,0 +1,81 @@
1
+ const DEFAULT_IMAGE_LIMIT = 100;
2
+ const DEFAULT_IMAGE_TTL = "30d";
3
+ export function resolvePwaOptions(options = {}) {
4
+ const cache = options.cache ?? "recommended";
5
+ const customCache = typeof cache === "object" ? cache : undefined;
6
+ return {
7
+ enabled: options.enabled !== false,
8
+ offline: normalizeRoute(options.offline ?? false, "offline"),
9
+ update: options.update ?? "prompt",
10
+ cache: {
11
+ staticRoutes: cache === "recommended"
12
+ ? true
13
+ : cache === false
14
+ ? false
15
+ : normalizeStaticRoutes(customCache?.staticRoutes ?? false),
16
+ images: cache === "recommended"
17
+ ? resolveImageCache("swr")
18
+ : cache === false
19
+ ? false
20
+ : resolveImageCache(customCache?.images ?? false),
21
+ },
22
+ };
23
+ }
24
+ export function parsePwaDuration(value) {
25
+ if (typeof value === "number") {
26
+ if (!Number.isFinite(value) || value <= 0) {
27
+ throw new TypeError("PWA cache ttl must be a positive finite number");
28
+ }
29
+ return value;
30
+ }
31
+ const match = /^(\d+(?:\.\d+)?)(s|m|h|d|w)$/.exec(value);
32
+ if (!match) {
33
+ throw new TypeError('PWA cache ttl must use a duration such as "30s", "5m", "2h", or "30d"');
34
+ }
35
+ const amount = Number(match[1]);
36
+ if (amount <= 0) {
37
+ throw new TypeError("PWA cache ttl must be a positive duration");
38
+ }
39
+ const multiplier = {
40
+ s: 1_000,
41
+ m: 60_000,
42
+ h: 3_600_000,
43
+ d: 86_400_000,
44
+ w: 604_800_000,
45
+ }[match[2]];
46
+ return amount * multiplier;
47
+ }
48
+ function resolveImageCache(value) {
49
+ if (value === false)
50
+ return false;
51
+ if (value !== true && value !== "swr" && value.strategy !== "swr") {
52
+ throw new TypeError('PWA image cache strategy must be "swr"');
53
+ }
54
+ const configured = typeof value === "object" ? value : undefined;
55
+ const limit = configured?.limit ?? DEFAULT_IMAGE_LIMIT;
56
+ if (!Number.isInteger(limit) || limit <= 0) {
57
+ throw new TypeError("PWA image cache limit must be a positive integer");
58
+ }
59
+ return {
60
+ strategy: "swr",
61
+ limit,
62
+ ttlMs: parsePwaDuration(configured?.ttl ?? DEFAULT_IMAGE_TTL),
63
+ };
64
+ }
65
+ function normalizeStaticRoutes(value) {
66
+ if (!Array.isArray(value))
67
+ return value;
68
+ return [...new Set(value.map((route) => normalizeRoute(route, "static route")))];
69
+ }
70
+ function normalizeRoute(value, label) {
71
+ if (value === false)
72
+ return false;
73
+ const route = value.trim();
74
+ if (!route.startsWith("/")) {
75
+ throw new TypeError(`PWA ${label} must start with "/"`);
76
+ }
77
+ if (route.includes("?") || route.includes("#")) {
78
+ throw new TypeError(`PWA ${label} cannot contain a query string or fragment`);
79
+ }
80
+ return route === "/" ? route : route.replace(/\/+$/, "");
81
+ }
@@ -0,0 +1,30 @@
1
+ import { type PwaCacheOptions, type PwaDuration, type PwaImageCache, type PwaImageCacheOptions, type PwaPluginOptions } from "./config.js";
2
+ export type { PwaCacheOptions, PwaDuration, PwaImageCache, PwaImageCacheOptions, PwaPluginOptions };
3
+ export interface FarmPwaBrowserRuntime {
4
+ registration: ServiceWorkerRegistration;
5
+ applyUpdate(): void;
6
+ }
7
+ export interface FarmPwaUpdateEventDetail {
8
+ registration: ServiceWorkerRegistration;
9
+ applyUpdate(): void;
10
+ }
11
+ declare global {
12
+ interface Window {
13
+ /** Browser runtime installed by `@farm.js/pwa`. */
14
+ __FARM_PWA__?: FarmPwaBrowserRuntime;
15
+ }
16
+ }
17
+ /**
18
+ * Generate and register a route-aware service worker for a Farm application.
19
+ * Build assets are always precached. Optional page and image caching stays
20
+ * constrained to safe GET navigation and same-origin image requests.
21
+ */
22
+ export declare function pwa(options?: PwaPluginOptions): import("@farm.js/core/plugin").FarmPlugin<undefined, Record<string, never>, {
23
+ cleanup(): void;
24
+ } | undefined, {
25
+ enabled: boolean;
26
+ workerUrl: string;
27
+ scope: string;
28
+ update: "prompt" | "auto";
29
+ }, unknown, false>;
30
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACtB,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,eAAe,EAAE,WAAW,EAAE,aAAa,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,CAAC;AAEpG,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,yBAAyB,CAAC;IACxC,WAAW,IAAI,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,yBAAyB,CAAC;IACxC,WAAW,IAAI,IAAI,CAAC;CACrB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,mDAAmD;QACnD,YAAY,CAAC,EAAE,qBAAqB,CAAC;KACtC;CACF;AAED;;;;GAIG;AACH,wBAAgB,GAAG,CAAC,OAAO,GAAE,gBAAqB;;;;;;;mBAoIjD"}
package/dist/index.js ADDED
@@ -0,0 +1,142 @@
1
+ import { definePlugin } from "@farm.js/core/plugin";
2
+ import { normalizeBasePath, withBasePath, writePwaBuildArtifacts } from "./build.js";
3
+ import { resolvePwaOptions, } from "./config.js";
4
+ /**
5
+ * Generate and register a route-aware service worker for a Farm application.
6
+ * Build assets are always precached. Optional page and image caching stays
7
+ * constrained to safe GET navigation and same-origin image requests.
8
+ */
9
+ export function pwa(options = {}) {
10
+ const resolved = resolvePwaOptions(options);
11
+ let configuredBasePath = "/";
12
+ let configuredOutputDir = ".farm/.output";
13
+ const publicConfig = {
14
+ enabled: resolved.enabled,
15
+ workerUrl: "/sw.js",
16
+ scope: "/",
17
+ update: resolved.update,
18
+ };
19
+ return definePlugin({
20
+ name: "farm:pwa",
21
+ configure(config) {
22
+ const basePath = normalizeBasePath(config.basePath);
23
+ configuredBasePath = basePath;
24
+ configuredOutputDir = `${config.root ?? "."}/.farm/.output`;
25
+ publicConfig.workerUrl = withBasePath("/sw.js", basePath);
26
+ publicConfig.scope = basePath === "/" ? "/" : `${basePath}/`;
27
+ },
28
+ build: {
29
+ configure(nitroConfig) {
30
+ if (!resolved.enabled)
31
+ return nitroConfig;
32
+ let generated = false;
33
+ const hooks = nitroConfig.hooks ?? {};
34
+ const previousPrerenderDone = hooks["prerender:done"];
35
+ const previousRollupBefore = hooks["rollup:before"];
36
+ const generate = async () => {
37
+ if (generated)
38
+ return;
39
+ const built = await writePwaBuildArtifacts({
40
+ outputDir: nitroConfig.output?.dir ?? configuredOutputDir,
41
+ publicDir: nitroConfig.output?.publicDir,
42
+ preset: nitroConfig.preset ?? "node-server",
43
+ basePath: configuredBasePath,
44
+ options: resolved,
45
+ });
46
+ generated = true;
47
+ console.info(`[farm:pwa] Generated ${built.workerUrl} with ${built.precacheUrls.length} precached files`);
48
+ };
49
+ return {
50
+ ...nitroConfig,
51
+ hooks: {
52
+ ...hooks,
53
+ "prerender:done": async (...args) => {
54
+ await callConfiguredHooks(previousPrerenderDone, args);
55
+ await generate();
56
+ },
57
+ "rollup:before": async (...args) => {
58
+ await callConfiguredHooks(previousRollupBefore, args);
59
+ if (isNitroPrerenderBuild(args[0]))
60
+ return;
61
+ await generate();
62
+ },
63
+ },
64
+ };
65
+ },
66
+ },
67
+ client: {
68
+ public: publicConfig,
69
+ async setup({ public: config, isProd }) {
70
+ if (!config.enabled || !isProd || !("serviceWorker" in navigator))
71
+ return undefined;
72
+ const registration = await navigator.serviceWorker.register(config.workerUrl, {
73
+ scope: config.scope,
74
+ });
75
+ const hadControllerAtSetup = Boolean(navigator.serviceWorker.controller);
76
+ let applyingUpdate = false;
77
+ let reloading = false;
78
+ const applyUpdate = () => {
79
+ applyingUpdate = true;
80
+ registration.waiting?.postMessage({ type: "FARM_PWA_SKIP_WAITING" });
81
+ };
82
+ const announceUpdate = () => {
83
+ if (config.update === "auto") {
84
+ applyUpdate();
85
+ return;
86
+ }
87
+ window.dispatchEvent(new CustomEvent("farm:pwa:update-available", {
88
+ detail: { registration, applyUpdate },
89
+ }));
90
+ };
91
+ const handleUpdateFound = () => {
92
+ const installing = registration.installing;
93
+ if (!installing)
94
+ return;
95
+ const handleStateChange = () => {
96
+ if (installing.state === "installed" && navigator.serviceWorker.controller) {
97
+ announceUpdate();
98
+ }
99
+ };
100
+ installing.addEventListener("statechange", handleStateChange);
101
+ };
102
+ const handleControllerChange = () => {
103
+ window.dispatchEvent(new CustomEvent("farm:pwa:updated"));
104
+ if (((config.update === "auto" && hadControllerAtSetup) || applyingUpdate) &&
105
+ !reloading) {
106
+ reloading = true;
107
+ window.location.reload();
108
+ }
109
+ };
110
+ registration.addEventListener("updatefound", handleUpdateFound);
111
+ navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange);
112
+ window.__FARM_PWA__ = { registration, applyUpdate };
113
+ if (registration.waiting && navigator.serviceWorker.controller)
114
+ announceUpdate();
115
+ return {
116
+ cleanup() {
117
+ registration.removeEventListener("updatefound", handleUpdateFound);
118
+ navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange);
119
+ if (window.__FARM_PWA__?.registration === registration)
120
+ delete window.__FARM_PWA__;
121
+ },
122
+ };
123
+ },
124
+ close({ state }) {
125
+ state?.cleanup?.();
126
+ },
127
+ },
128
+ });
129
+ }
130
+ function isNitroPrerenderBuild(value) {
131
+ if (!value || typeof value !== "object")
132
+ return false;
133
+ const options = value.options;
134
+ return options?.preset === "nitro-prerender";
135
+ }
136
+ async function callConfiguredHooks(value, args) {
137
+ const hooks = Array.isArray(value) ? value : [value];
138
+ for (const hook of hooks) {
139
+ if (typeof hook === "function")
140
+ await hook(...args);
141
+ }
142
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@farm.js/pwa",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Installable, offline-aware Progressive Web Apps for Farm.js",
5
+ "keywords": [
6
+ "farm.js",
7
+ "offline",
8
+ "progressive-web-app",
9
+ "pwa",
10
+ "service-worker"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/farming-labs/farm.js",
16
+ "directory": "packages/farm-pwa"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@farm.js/core": "0.1.0-beta.68"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^20.10.5",
39
+ "typescript": "^5.3.3",
40
+ "vitest": "^3.2.7"
41
+ },
42
+ "engines": {
43
+ "node": ">=20.0.0"
44
+ },
45
+ "scripts": {
46
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
47
+ "dev": "tsc --watch",
48
+ "type-check": "tsc --noEmit",
49
+ "test": "vitest run"
50
+ }
51
+ }