@farm.js/pwa 0.1.0-beta.0 → 0.1.0-beta.10

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/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # @farm.js/pwa
2
2
 
3
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.
4
+ service worker or copies a custom one during the production build, then registers it through Farm's
5
+ browser plugin lifecycle.
5
6
 
6
- Farm.js is currently in beta.
7
+ This package is in beta. See [Stability and Support](https://farmjs.dev/docs/stability).
7
8
 
8
9
  ## Install
9
10
 
@@ -21,7 +22,7 @@ export default defineConfig({
21
22
  plugins: [
22
23
  pwa({
23
24
  offline: "/offline",
24
- cache: "recommended",
25
+ cache: "auto",
25
26
  }),
26
27
  ],
27
28
  });
@@ -33,15 +34,18 @@ does not duplicate it.
33
34
 
34
35
  ## Short cache configuration
35
36
 
36
- `recommended` precaches emitted static pages and uses SWR for public same-origin images:
37
+ `auto` precaches emitted static pages and uses SWR for public same-origin images. It is the default,
38
+ so you can omit `cache` when you want this behavior:
37
39
 
38
40
  ```ts
39
41
  pwa({
40
42
  offline: "/offline",
41
- cache: "recommended",
43
+ cache: "auto",
42
44
  });
43
45
  ```
44
46
 
47
+ The previous `recommended` value remains available as a compatibility alias for `auto`.
48
+
45
49
  The explicit short form is:
46
50
 
47
51
  ```ts
@@ -71,6 +75,25 @@ pwa({
71
75
  });
72
76
  ```
73
77
 
78
+ ## Bring your own service worker
79
+
80
+ Advanced applications can replace the generated worker with a prebuilt JavaScript file:
81
+
82
+ ```ts
83
+ pwa({
84
+ serviceWorker: {
85
+ source: "src/service-worker.js",
86
+ type: "module",
87
+ },
88
+ });
89
+ ```
90
+
91
+ The source path is relative to the Farm project root. Farm copies the file verbatim to the final
92
+ `sw.js` location and keeps its production registration and update lifecycle. A custom worker owns
93
+ all fetch, offline, and cache behavior, so `serviceWorker` cannot be combined with `offline` or
94
+ `cache`. It must handle the `FARM_PWA_SKIP_WAITING` message for Farm's update action to activate a
95
+ waiting worker.
96
+
74
97
  ## Updates
75
98
 
76
99
  `update: "prompt"` is the default. The plugin dispatches `farm:pwa:update-available` when a new
package/dist/build.d.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import type { ResolvedPwaOptions } from "./config.js";
2
2
  export interface PwaBuildInput {
3
+ root?: string;
3
4
  outputDir: string;
4
5
  publicDir?: string;
5
6
  preset: string;
6
7
  basePath: string;
8
+ /** Prefix already present in emitted HTML paths (localized Farm SSG output). */
9
+ htmlBasePath?: string;
7
10
  options: ResolvedPwaOptions;
8
11
  }
9
12
  export interface PwaBuildResult {
13
+ mode: "generated" | "custom";
10
14
  workerPath: string;
11
15
  workerUrl: string;
12
16
  precacheUrls: string[];
@@ -15,6 +19,8 @@ export interface PwaBuildResult {
15
19
  }
16
20
  export declare function writePwaBuildArtifacts(input: PwaBuildInput): Promise<PwaBuildResult>;
17
21
  export interface GenerateServiceWorkerOptions {
22
+ basePath: string;
23
+ htmlBasePath?: string;
18
24
  cacheId: string;
19
25
  precacheUrls: string[];
20
26
  staticRoutes: Record<string, string>;
@@ -1 +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"}
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,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,kBAAkB,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,WAAW,GAAG,QAAQ,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,CA8G1F;AAED,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,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,CA0NnF;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK7E;AAiDD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAGnE;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKpE"}
package/dist/build.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  const PRECACHE_EXTENSIONS = new Set([
5
5
  ".css",
@@ -14,26 +14,51 @@ const PRECACHE_EXTENSIONS = new Set([
14
14
  ]);
15
15
  export async function writePwaBuildArtifacts(input) {
16
16
  const publicDir = input.publicDir ?? resolvePwaPublicDir(input.outputDir, input.preset);
17
- const files = await walkFiles(publicDir);
18
- const staticRouteFiles = resolveStaticRouteFiles(files);
19
17
  const basePath = normalizeBasePath(input.basePath);
18
+ const htmlBasePath = normalizeBasePath(input.htmlBasePath);
20
19
  const workerRelativePath = path.posix.join(basePath.replace(/^\//, ""), "sw.js");
20
+ const workerPath = path.join(publicDir, ...workerRelativePath.split("/"));
21
+ await assertWorkerPathInsidePublicDir(publicDir, workerPath);
22
+ if (input.options.serviceWorker) {
23
+ const sourcePath = path.resolve(input.root ?? process.cwd(), input.options.serviceWorker.source);
24
+ let source;
25
+ try {
26
+ source = await readFile(sourcePath);
27
+ }
28
+ catch (cause) {
29
+ throw new Error(`[farm:pwa] Custom service worker ${JSON.stringify(input.options.serviceWorker.source)} could not be read.`, { cause });
30
+ }
31
+ await mkdir(path.dirname(workerPath), { recursive: true });
32
+ await writeFile(workerPath, source);
33
+ return {
34
+ mode: "custom",
35
+ workerPath,
36
+ workerUrl: withBasePath("/sw.js", basePath),
37
+ precacheUrls: [],
38
+ staticRoutes: {},
39
+ cacheId: createHash("sha256").update(source).digest("hex").slice(0, 16),
40
+ };
41
+ }
42
+ const files = await walkFiles(publicDir);
43
+ const staticRouteFiles = resolveStaticRouteFiles(files, htmlBasePath);
21
44
  const selectedRoutes = selectStaticRoutes(staticRouteFiles, input.options.cache.staticRoutes, basePath);
22
45
  const configuredOfflineRoute = input.options.offline;
23
46
  const offlineRoute = configuredOfflineRoute
24
47
  ? withBasePath(configuredOfflineRoute, basePath)
25
48
  : false;
26
49
  if (offlineRoute && configuredOfflineRoute) {
27
- const emittedOfflineFile = staticRouteFiles[configuredOfflineRoute] ?? staticRouteFiles[offlineRoute];
50
+ const emittedOfflineFile = staticRouteFiles[configuredOfflineRoute];
28
51
  if (!emittedOfflineFile) {
29
52
  throw new Error(`[farm:pwa] Offline route ${JSON.stringify(input.options.offline)} was not emitted as a static page. ` +
30
53
  "Create that page as a static Farm route so it can be precached.");
31
54
  }
32
55
  selectedRoutes[offlineRoute] = emittedOfflineFile;
33
56
  }
34
- const assetFiles = files.filter((file) => file !== workerRelativePath && PRECACHE_EXTENSIONS.has(path.extname(file).toLowerCase()));
57
+ const assetFiles = files.filter((file) => toPublicUrl(file, basePath) !== withBasePath("/sw.js", basePath) &&
58
+ PRECACHE_EXTENSIONS.has(path.extname(file).toLowerCase()));
35
59
  const precacheFiles = [...new Set([...assetFiles, ...Object.values(selectedRoutes)])].sort();
36
- const precacheUrls = precacheFiles.map(toPublicUrl);
60
+ const htmlFiles = new Set(Object.values(selectedRoutes));
61
+ const precacheUrls = precacheFiles.map((file) => toPublicUrl(file, basePath, htmlFiles.has(file) ? htmlBasePath : basePath));
37
62
  const fileHashes = await Promise.all(precacheFiles.map(async (file) => {
38
63
  const content = await readFile(path.join(publicDir, file));
39
64
  return `${file}:${createHash("sha256").update(content).digest("hex")}`;
@@ -41,6 +66,7 @@ export async function writePwaBuildArtifacts(input) {
41
66
  const cacheId = createHash("sha256")
42
67
  .update(JSON.stringify({
43
68
  files: fileHashes,
69
+ urls: precacheUrls,
44
70
  routes: selectedRoutes,
45
71
  offlineRoute,
46
72
  update: input.options.update,
@@ -48,9 +74,10 @@ export async function writePwaBuildArtifacts(input) {
48
74
  }))
49
75
  .digest("hex")
50
76
  .slice(0, 16);
51
- const workerPath = path.join(publicDir, ...workerRelativePath.split("/"));
52
77
  await mkdir(path.dirname(workerPath), { recursive: true });
53
78
  await writeFile(workerPath, generateServiceWorker({
79
+ basePath,
80
+ htmlBasePath,
54
81
  cacheId,
55
82
  precacheUrls,
56
83
  staticRoutes: selectedRoutes,
@@ -59,6 +86,7 @@ export async function writePwaBuildArtifacts(input) {
59
86
  images: input.options.cache.images,
60
87
  }));
61
88
  return {
89
+ mode: "generated",
62
90
  workerPath,
63
91
  workerUrl: withBasePath("/sw.js", basePath),
64
92
  precacheUrls,
@@ -67,11 +95,22 @@ export async function writePwaBuildArtifacts(input) {
67
95
  };
68
96
  }
69
97
  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;
98
+ const routeFiles = Object.fromEntries(Object.entries(options.staticRoutes).map(([route, file]) => [
99
+ encodeRoutePathname(route),
100
+ toPublicUrl(file, options.basePath, options.htmlBasePath ?? "/"),
101
+ ]));
102
+ const offlineFile = options.offlineRoute
103
+ ? (routeFiles[encodeRoutePathname(options.offlineRoute)] ?? null)
104
+ : null;
105
+ const cacheScope = createHash("sha256")
106
+ .update(normalizeBasePath(options.basePath))
107
+ .digest("hex")
108
+ .slice(0, 16);
109
+ const precachePrefix = `farm-pwa-precache-${cacheScope}-`;
72
110
  return `/* Generated by @farm.js/pwa. */
73
- const PRECACHE = ${JSON.stringify(`farm-pwa-precache-${options.cacheId}`)};
74
- const IMAGE_CACHE = "farm-pwa-images-v1";
111
+ const PRECACHE_PREFIX = ${JSON.stringify(precachePrefix)};
112
+ const PRECACHE = ${JSON.stringify(`${precachePrefix}${options.cacheId}`)};
113
+ const IMAGE_CACHE = ${JSON.stringify(`farm-pwa-images-${cacheScope}-v1`)};
75
114
  const PRECACHE_URLS = ${JSON.stringify(options.precacheUrls)};
76
115
  const PRECACHE_PATHS = new Set(PRECACHE_URLS.map((value) => new URL(value, self.location.origin).pathname));
77
116
  const STATIC_ROUTES = ${JSON.stringify(routeFiles)};
@@ -90,7 +129,7 @@ self.addEventListener("activate", (event) => {
90
129
  event.waitUntil((async () => {
91
130
  const names = await caches.keys();
92
131
  await Promise.all(names
93
- .filter((name) => name.startsWith("farm-pwa-precache-") && name !== PRECACHE)
132
+ .filter((name) => name.startsWith(PRECACHE_PREFIX) && name !== PRECACHE)
94
133
  .map((name) => caches.delete(name)));
95
134
  await self.clients.claim();
96
135
  })());
@@ -118,7 +157,9 @@ self.addEventListener("fetch", (event) => {
118
157
  }
119
158
 
120
159
  if (IMAGE_OPTIONS && request.destination === "image") {
121
- event.respondWith(staleWhileRevalidateImage(request));
160
+ const image = staleWhileRevalidateImage(request);
161
+ event.respondWith(image.response);
162
+ event.waitUntil(image.lifetime);
122
163
  }
123
164
  });
124
165
 
@@ -145,48 +186,107 @@ async function handleNavigation(request, url) {
145
186
  }
146
187
  }
147
188
 
148
- async function staleWhileRevalidateImage(request) {
149
- if (request.headers.has("authorization")) return fetch(request);
189
+ function staleWhileRevalidateImage(request) {
190
+ let update = Promise.resolve();
191
+ const response = (async () => {
192
+ if (request.headers.has("authorization")) return fetch(request);
150
193
 
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);
194
+ let cache;
195
+ let cached;
196
+ try {
197
+ cache = await caches.open(IMAGE_CACHE);
198
+ cached = await cache.match(request);
199
+ } catch {
200
+ // Runtime caching is optional when browser storage is unavailable.
201
+ return fetch(request);
202
+ }
203
+ const cachedAt = Number(cached?.headers.get("x-farm-pwa-cached-at") || 0);
204
+ const fresh = cached && Date.now() - cachedAt <= IMAGE_OPTIONS.ttlMs;
205
+ update = fetchAndCacheImage(request, cache);
156
206
 
157
- if (fresh) {
158
- void update.catch(() => undefined);
159
- return cached;
160
- }
207
+ if (fresh) return cached;
161
208
 
162
- try {
163
- return await update;
164
- } catch (error) {
165
- if (cached) return cached;
166
- throw error;
167
- }
209
+ try {
210
+ return await update;
211
+ } catch (error) {
212
+ if (cached) return cached;
213
+ throw error;
214
+ }
215
+ })();
216
+ const lifetime = response
217
+ .then(
218
+ () => update,
219
+ () => update,
220
+ )
221
+ .then(
222
+ () => undefined,
223
+ () => undefined,
224
+ );
225
+ return { response, lifetime };
168
226
  }
169
227
 
170
228
  async function fetchAndCacheImage(request, cache) {
171
229
  const response = await fetch(request);
172
230
  if (!isPublicCacheableImage(response)) return response;
173
231
 
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);
232
+ let stored;
233
+ try {
234
+ const headers = new Headers(response.headers);
235
+ headers.set("x-farm-pwa-cached-at", String(Date.now()));
236
+ stored = new Response(response.clone().body, {
237
+ status: response.status,
238
+ statusText: response.statusText,
239
+ headers,
240
+ });
241
+ await cache.put(request, stored);
242
+ await trimImageCache(cache, IMAGE_OPTIONS.limit);
243
+ } catch {
244
+ // Quota and storage failures must not discard a successful network response.
245
+ // Do not wait for tee cancellation, which can depend on the response consumer.
246
+ if (stored?.body) void stored.body.cancel().catch(() => undefined);
247
+ }
183
248
  return response;
184
249
  }
185
250
 
251
+ // Cache Storage is shared by every account that signs in on this browser, and a
252
+ // service worker cannot see the request's cookies to tell those accounts apart.
253
+ // So an image is kept only when the server marked it shareable or it looks like a
254
+ // static file, the same responses the browser's own HTTP cache would reuse.
186
255
  function isPublicCacheableImage(response) {
187
256
  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");
257
+ const directives = parseCacheControl(response.headers.get("cache-control"));
258
+ if (directives.has("private") || directives.has("no-store") || directives.has("no-cache")) {
259
+ return false;
260
+ }
261
+ const vary = (response.headers.get("vary") || "")
262
+ .toLowerCase()
263
+ .split(",")
264
+ .map((value) => value.trim());
265
+ // A response that varies on the caller's identity cannot be keyed here.
266
+ if (vary.includes("*") || vary.includes("cookie") || vary.includes("authorization")) {
267
+ return false;
268
+ }
269
+ if (directives.has("max-age")) return Number(directives.get("max-age")) > 0;
270
+ if (directives.has("public")) return true;
271
+ const expires = Date.parse(response.headers.get("expires") || "");
272
+ if (Number.isFinite(expires)) return expires > Date.now();
273
+ // Heuristic freshness (RFC 9111 section 4.2.2) needs a Last-Modified validator.
274
+ // Static files carry one. A dynamic response with no caching headers at all, such
275
+ // as an API route returning the signed-in account's avatar, does not, and keeping
276
+ // it would show that image to whoever uses this browser next.
277
+ return response.headers.has("last-modified");
278
+ }
279
+
280
+ function parseCacheControl(value) {
281
+ const directives = new Map();
282
+ for (const part of (value || "").toLowerCase().split(",")) {
283
+ const separator = part.indexOf("=");
284
+ const name = (separator === -1 ? part : part.slice(0, separator)).trim();
285
+ if (!name) continue;
286
+ const raw = separator === -1 ? "" : part.slice(separator + 1).trim();
287
+ directives.set(name, raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw);
288
+ }
289
+ return directives;
190
290
  }
191
291
 
192
292
  async function trimImageCache(cache, limit) {
@@ -213,6 +313,46 @@ function normalizePathname(pathname) {
213
313
  export function resolvePwaPublicDir(outputDir, preset) {
214
314
  return path.join(outputDir, preset === "vercel" || preset === "vercel-edge" ? "static" : "public");
215
315
  }
316
+ async function assertWorkerPathInsidePublicDir(publicDir, workerPath) {
317
+ const prospectivePublicDir = await resolveProspectiveRealPath(publicDir);
318
+ const prospectiveWorkerPath = await resolveProspectiveRealPath(workerPath);
319
+ const relativePath = path.relative(prospectivePublicDir, prospectiveWorkerPath);
320
+ if (relativePath === ".." ||
321
+ relativePath.startsWith(`..${path.sep}`) ||
322
+ path.isAbsolute(relativePath)) {
323
+ throw new Error("[farm:pwa] Service worker output must stay inside the public output directory, including through symlinks.");
324
+ }
325
+ }
326
+ async function resolveProspectiveRealPath(candidate) {
327
+ const missingSegments = [];
328
+ let existingAncestor = path.resolve(candidate);
329
+ while (true) {
330
+ try {
331
+ return path.join(await realpath(existingAncestor), ...missingSegments);
332
+ }
333
+ catch (error) {
334
+ if (error.code !== "ENOENT")
335
+ throw error;
336
+ let existingEntry = false;
337
+ try {
338
+ await lstat(existingAncestor);
339
+ existingEntry = true;
340
+ }
341
+ catch (statError) {
342
+ if (statError.code !== "ENOENT")
343
+ throw statError;
344
+ }
345
+ if (existingEntry) {
346
+ throw new Error("[farm:pwa] Service worker output must stay inside the public output directory, including through symlinks.");
347
+ }
348
+ const parent = path.dirname(existingAncestor);
349
+ if (parent === existingAncestor)
350
+ throw error;
351
+ missingSegments.unshift(path.basename(existingAncestor));
352
+ existingAncestor = parent;
353
+ }
354
+ }
355
+ }
216
356
  export function normalizeBasePath(value) {
217
357
  if (!value || value === "/")
218
358
  return "/";
@@ -223,9 +363,6 @@ export function withBasePath(route, basePath) {
223
363
  const normalizedRoute = route === "/" ? "/" : `/${route.replace(/^\/+|\/+$/g, "")}`;
224
364
  if (normalizedBase === "/")
225
365
  return normalizedRoute;
226
- if (normalizedRoute === normalizedBase || normalizedRoute.startsWith(`${normalizedBase}/`)) {
227
- return normalizedRoute;
228
- }
229
366
  return normalizedRoute === "/" ? normalizedBase : `${normalizedBase}${normalizedRoute}`;
230
367
  }
231
368
  async function walkFiles(root) {
@@ -239,7 +376,7 @@ async function walkFiles(root) {
239
376
  }));
240
377
  return files.flat().sort();
241
378
  }
242
- function resolveStaticRouteFiles(files) {
379
+ function resolveStaticRouteFiles(files, htmlBasePath) {
243
380
  const routes = {};
244
381
  for (const file of files) {
245
382
  if (path.extname(file).toLowerCase() !== ".html")
@@ -249,7 +386,7 @@ function resolveStaticRouteFiles(files) {
249
386
  const route = withoutIndex
250
387
  ? `/${withoutIndex.replace(/\.html$/, "").replace(/^\/+|\/+$/g, "")}`
251
388
  : "/";
252
- routes[route] = normalized;
389
+ routes[stripBasePath(route, htmlBasePath)] = normalized;
253
390
  }
254
391
  return routes;
255
392
  }
@@ -262,7 +399,7 @@ function selectStaticRoutes(emitted, configured, basePath) {
262
399
  const selected = {};
263
400
  for (const requested of configured) {
264
401
  const route = withBasePath(requested, basePath);
265
- const emittedFile = emitted[requested] ?? emitted[route];
402
+ const emittedFile = emitted[requested];
266
403
  if (!emittedFile) {
267
404
  throw new Error(`[farm:pwa] Static route ${JSON.stringify(requested)} was not found in the production output.`);
268
405
  }
@@ -270,10 +407,39 @@ function selectStaticRoutes(emitted, configured, basePath) {
270
407
  }
271
408
  return selected;
272
409
  }
273
- function toPublicUrl(file) {
274
- return `/${file
275
- .replace(/\\/g, "/")
410
+ function stripBasePath(route, basePath) {
411
+ const base = normalizeBasePath(basePath);
412
+ if (base === "/")
413
+ return route;
414
+ if (route === base)
415
+ return "/";
416
+ return route.startsWith(`${base}/`) ? route.slice(base.length) : route;
417
+ }
418
+ function toPublicUrl(file, basePath, fileBasePath = basePath) {
419
+ // Generated assets can already live under basePath. HTML uses the explicit
420
+ // SSG output prefix instead: a folder named like basePath can be a real route.
421
+ const route = stripBasePath(`/${file.replace(/\\/g, "/")}`, fileBasePath);
422
+ return withBasePath(encodePathname(route), basePath);
423
+ }
424
+ // Encode a route path the way browsers encode `location.pathname` so the
425
+ // STATIC_ROUTES keys match the pathname a live navigation produces at runtime.
426
+ // encodeURIComponent (used by toPublicUrl for asset URLs) is wrong for the keys:
427
+ // it percent-encodes ordinary path characters such as @ , = + ; $ that browsers
428
+ // leave untouched, so a route like /@alice would be keyed as /%40alice and never
429
+ // match a navigation to /@alice, silently falling through to the offline page.
430
+ function encodeRoutePathname(pathname) {
431
+ return new URL(pathname, "http://farm.localhost").pathname;
432
+ }
433
+ function encodePathname(pathname) {
434
+ // Match how a browser encodes location.pathname. encodeURIComponent is safe
435
+ // for every character but over-encodes the path sub-delimiters and ":"/"@"
436
+ // that browsers leave literal, so a precached asset like /logo@2x.css would
437
+ // be stored (and its PRECACHE_PATHS key derived) as /logo%402x.css and never
438
+ // match the live request for /logo@2x.css. Restore that path-safe set while
439
+ // leaving "?", "#", spaces and non-ASCII percent-encoded, the same divergence
440
+ // encodeRoutePathname documents for static-route keys.
441
+ return pathname
276
442
  .split("/")
277
- .map((segment) => encodeURIComponent(segment))
278
- .join("/")}`;
443
+ .map((segment) => encodeURIComponent(segment).replace(/%(24|26|2B|2C|3A|3B|3D|40)/gi, (match) => decodeURIComponent(match)))
444
+ .join("/");
279
445
  }
package/dist/config.d.ts CHANGED
@@ -14,15 +14,21 @@ export interface PwaCacheOptions {
14
14
  /** Opt in to same-origin public image caching. */
15
15
  images?: PwaImageCache;
16
16
  }
17
+ export interface PwaServiceWorkerOptions {
18
+ /** JavaScript file copied verbatim into the production output as sw.js. */
19
+ source: string;
20
+ /** Registration type for the custom worker. */
21
+ type?: "classic" | "module";
22
+ }
17
23
  export interface PwaPluginOptions {
18
- /** Set false to keep the plugin registered without emitting or registering a worker. */
19
- enabled?: boolean;
20
24
  /** Static route to serve after an offline navigation misses the cache. */
21
25
  offline?: string | false;
22
26
  /** How a waiting service worker becomes active. */
23
27
  update?: "prompt" | "auto";
24
- /** Recommended caching, custom caching, or build assets only. */
25
- cache?: "recommended" | PwaCacheOptions | false;
28
+ /** Generated-worker caching, a custom cache policy, or build assets only. */
29
+ cache?: "auto" | "recommended" | PwaCacheOptions | false;
30
+ /** Copy and register a prebuilt service worker instead of generating one. */
31
+ serviceWorker?: PwaServiceWorkerOptions;
26
32
  }
27
33
  export interface ResolvedPwaImageCacheOptions {
28
34
  strategy: "swr";
@@ -30,9 +36,9 @@ export interface ResolvedPwaImageCacheOptions {
30
36
  ttlMs: number;
31
37
  }
32
38
  export interface ResolvedPwaOptions {
33
- enabled: boolean;
34
39
  offline: string | false;
35
40
  update: "prompt" | "auto";
41
+ serviceWorker: Required<PwaServiceWorkerOptions> | false;
36
42
  cache: {
37
43
  staticRoutes: boolean | string[];
38
44
  images: ResolvedPwaImageCacheOptions | false;
@@ -1 +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"}
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,uBAAuB;IACtC,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,IAAI,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACzB,mDAAmD;IACnD,MAAM,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC3B,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,eAAe,GAAG,KAAK,CAAC;IACzD,6EAA6E;IAC7E,aAAa,CAAC,EAAE,uBAAuB,CAAC;CACzC;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,MAAM,GAAG,KAAK,CAAC;IACxB,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC1B,aAAa,EAAE,QAAQ,CAAC,uBAAuB,CAAC,GAAG,KAAK,CAAC;IACzD,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,CA8DpF;AAoBD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CA0B3D"}
package/dist/config.js CHANGED
@@ -1,26 +1,74 @@
1
1
  const DEFAULT_IMAGE_LIMIT = 100;
2
2
  const DEFAULT_IMAGE_TTL = "30d";
3
3
  export function resolvePwaOptions(options = {}) {
4
- const cache = options.cache ?? "recommended";
4
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
5
+ throw new TypeError("PWA options must be an object");
6
+ }
7
+ if ("enabled" in options) {
8
+ throw new TypeError("PWA no longer accepts enabled; remove pwa() from plugins to disable it");
9
+ }
10
+ if (options.offline !== undefined &&
11
+ options.offline !== false &&
12
+ typeof options.offline !== "string") {
13
+ throw new TypeError('PWA offline must be a route starting with "/" or false');
14
+ }
15
+ if (options.update !== undefined && options.update !== "prompt" && options.update !== "auto") {
16
+ throw new TypeError('PWA update must be "prompt" or "auto"');
17
+ }
18
+ const serviceWorker = resolveServiceWorker(options.serviceWorker);
19
+ if (serviceWorker && (options.offline !== undefined || options.cache !== undefined)) {
20
+ throw new TypeError("PWA serviceWorker cannot be combined with offline or cache because the custom worker owns those behaviors");
21
+ }
22
+ const configuredCache = options.cache;
23
+ if (configuredCache !== undefined &&
24
+ configuredCache !== false &&
25
+ configuredCache !== "auto" &&
26
+ configuredCache !== "recommended" &&
27
+ (!configuredCache || typeof configuredCache !== "object" || Array.isArray(configuredCache))) {
28
+ throw new TypeError('PWA cache must be "auto", "recommended", an options object, or false');
29
+ }
30
+ const cache = configuredCache ?? "auto";
31
+ const usesAutomaticCache = cache === "auto" || cache === "recommended";
5
32
  const customCache = typeof cache === "object" ? cache : undefined;
6
33
  return {
7
- enabled: options.enabled !== false,
8
- offline: normalizeRoute(options.offline ?? false, "offline"),
34
+ offline: serviceWorker ? false : normalizeRoute(options.offline ?? false, "offline"),
9
35
  update: options.update ?? "prompt",
36
+ serviceWorker,
10
37
  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),
38
+ staticRoutes: serviceWorker
39
+ ? false
40
+ : usesAutomaticCache
41
+ ? true
42
+ : cache === false
43
+ ? false
44
+ : normalizeStaticRoutes(customCache?.staticRoutes === undefined ? false : customCache.staticRoutes),
45
+ images: serviceWorker
46
+ ? false
47
+ : usesAutomaticCache
48
+ ? resolveImageCache("swr")
49
+ : cache === false
50
+ ? false
51
+ : resolveImageCache(customCache?.images === undefined ? false : customCache.images),
21
52
  },
22
53
  };
23
54
  }
55
+ function resolveServiceWorker(value) {
56
+ if (value === undefined)
57
+ return false;
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
59
+ throw new TypeError("PWA serviceWorker must be an options object");
60
+ }
61
+ if (typeof value.source !== "string") {
62
+ throw new TypeError("PWA serviceWorker source must be a non-empty path");
63
+ }
64
+ const source = value.source.trim();
65
+ if (!source)
66
+ throw new TypeError("PWA serviceWorker source must be a non-empty path");
67
+ if (value.type !== undefined && value.type !== "classic" && value.type !== "module") {
68
+ throw new TypeError('PWA serviceWorker type must be "classic" or "module"');
69
+ }
70
+ return { source, type: value.type ?? "classic" };
71
+ }
24
72
  export function parsePwaDuration(value) {
25
73
  if (typeof value === "number") {
26
74
  if (!Number.isFinite(value) || value <= 0) {
@@ -48,7 +96,9 @@ export function parsePwaDuration(value) {
48
96
  function resolveImageCache(value) {
49
97
  if (value === false)
50
98
  return false;
51
- if (value !== true && value !== "swr" && value.strategy !== "swr") {
99
+ if (value !== true &&
100
+ value !== "swr" &&
101
+ (!value || typeof value !== "object" || Array.isArray(value) || value.strategy !== "swr")) {
52
102
  throw new TypeError('PWA image cache strategy must be "swr"');
53
103
  }
54
104
  const configured = typeof value === "object" ? value : undefined;
@@ -63,8 +113,11 @@ function resolveImageCache(value) {
63
113
  };
64
114
  }
65
115
  function normalizeStaticRoutes(value) {
66
- if (!Array.isArray(value))
116
+ if (typeof value === "boolean")
67
117
  return value;
118
+ if (!Array.isArray(value)) {
119
+ throw new TypeError("PWA cache staticRoutes must be boolean or an array of routes");
120
+ }
68
121
  return [...new Set(value.map((route) => normalizeRoute(route, "static route")))];
69
122
  }
70
123
  function normalizeRoute(value, label) {
@@ -74,8 +127,36 @@ function normalizeRoute(value, label) {
74
127
  if (!route.startsWith("/")) {
75
128
  throw new TypeError(`PWA ${label} must start with "/"`);
76
129
  }
130
+ if (route.startsWith("//")) {
131
+ throw new TypeError(`PWA ${label} must be an application pathname, not a URL`);
132
+ }
77
133
  if (route.includes("?") || route.includes("#")) {
78
134
  throw new TypeError(`PWA ${label} cannot contain a query string or fragment`);
79
135
  }
136
+ if (hasUnstablePathCharacters(route)) {
137
+ throw new TypeError(`PWA ${label} cannot contain backslashes or control characters`);
138
+ }
139
+ for (const segment of route.split("/")) {
140
+ let decoded = segment;
141
+ try {
142
+ decoded = decodeURIComponent(segment);
143
+ }
144
+ catch {
145
+ // Malformed escapes remain literal URL pathname segments.
146
+ }
147
+ if (hasUnstablePathCharacters(decoded) || decoded.includes("/")) {
148
+ throw new TypeError(`PWA ${label} cannot contain encoded path separators`);
149
+ }
150
+ if (decoded === "." || decoded === "..") {
151
+ throw new TypeError(`PWA ${label} cannot contain "." or ".." path segments`);
152
+ }
153
+ }
80
154
  return route === "/" ? route : route.replace(/\/+$/, "");
81
155
  }
156
+ function hasUnstablePathCharacters(value) {
157
+ return (value.includes("\\") ||
158
+ Array.from(value).some((character) => {
159
+ const code = character.charCodeAt(0);
160
+ return code <= 31 || (code >= 127 && code <= 159);
161
+ }));
162
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type PwaCacheOptions, type PwaDuration, type PwaImageCache, type PwaImageCacheOptions, type PwaPluginOptions } from "./config.js";
2
- export type { PwaCacheOptions, PwaDuration, PwaImageCache, PwaImageCacheOptions, PwaPluginOptions };
1
+ import { type PwaCacheOptions, type PwaDuration, type PwaImageCache, type PwaImageCacheOptions, type PwaPluginOptions, type PwaServiceWorkerOptions } from "./config.js";
2
+ export type { PwaCacheOptions, PwaDuration, PwaImageCache, PwaImageCacheOptions, PwaPluginOptions, PwaServiceWorkerOptions, };
3
3
  export interface FarmPwaBrowserRuntime {
4
4
  registration: ServiceWorkerRegistration;
5
5
  applyUpdate(): void;
@@ -15,16 +15,16 @@ declare global {
15
15
  }
16
16
  }
17
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.
18
+ * Generate or copy and then register a service worker for a Farm application.
19
+ * In generated mode, build assets are always precached while optional page and
20
+ * image caching stays constrained to safe GET navigation and same-origin images.
21
21
  */
22
22
  export declare function pwa(options?: PwaPluginOptions): import("@farm.js/core/plugin").FarmPlugin<undefined, Record<string, never>, {
23
23
  cleanup(): void;
24
24
  } | undefined, {
25
- enabled: boolean;
26
25
  workerUrl: string;
27
26
  scope: string;
28
27
  update: "prompt" | "auto";
29
- }, unknown, false>;
28
+ workerType: "classic" | "module";
29
+ }, unknown, false, import("@farm.js/core/plugin").PluginRoutes>;
30
30
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
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,EACrB,KAAK,uBAAuB,EAC7B,MAAM,aAAa,CAAC;AAErB,YAAY,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACb,oBAAoB,EACpB,gBAAgB,EAChB,uBAAuB,GACxB,CAAC;AAEF,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;;;;;;;gEAgJjD"}
package/dist/index.js CHANGED
@@ -2,33 +2,38 @@ import { definePlugin } from "@farm.js/core/plugin";
2
2
  import { normalizeBasePath, withBasePath, writePwaBuildArtifacts } from "./build.js";
3
3
  import { resolvePwaOptions, } from "./config.js";
4
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.
5
+ * Generate or copy and then register a service worker for a Farm application.
6
+ * In generated mode, build assets are always precached while optional page and
7
+ * image caching stays constrained to safe GET navigation and same-origin images.
8
8
  */
9
9
  export function pwa(options = {}) {
10
10
  const resolved = resolvePwaOptions(options);
11
+ let configuredRoot = ".";
11
12
  let configuredBasePath = "/";
13
+ let htmlBasePath = "/";
12
14
  let configuredOutputDir = ".farm/.output";
13
15
  const publicConfig = {
14
- enabled: resolved.enabled,
15
16
  workerUrl: "/sw.js",
16
17
  scope: "/",
17
18
  update: resolved.update,
19
+ workerType: resolved.serviceWorker ? resolved.serviceWorker.type : "classic",
18
20
  };
19
21
  return definePlugin({
20
22
  name: "farm:pwa",
21
23
  configure(config) {
24
+ configuredRoot = config.root ?? ".";
22
25
  const basePath = normalizeBasePath(config.basePath);
23
26
  configuredBasePath = basePath;
27
+ // Only localized SSG output already includes the application's basePath.
28
+ const i18n = config.i18n;
29
+ htmlBasePath =
30
+ i18n && typeof i18n === "object" && "enabled" in i18n && i18n.enabled ? basePath : "/";
24
31
  configuredOutputDir = `${config.root ?? "."}/.farm/.output`;
25
32
  publicConfig.workerUrl = withBasePath("/sw.js", basePath);
26
33
  publicConfig.scope = basePath === "/" ? "/" : `${basePath}/`;
27
34
  },
28
35
  build: {
29
36
  configure(nitroConfig) {
30
- if (!resolved.enabled)
31
- return nitroConfig;
32
37
  let generated = false;
33
38
  const hooks = nitroConfig.hooks ?? {};
34
39
  const previousPrerenderDone = hooks["prerender:done"];
@@ -37,14 +42,18 @@ export function pwa(options = {}) {
37
42
  if (generated)
38
43
  return;
39
44
  const built = await writePwaBuildArtifacts({
45
+ root: configuredRoot,
40
46
  outputDir: nitroConfig.output?.dir ?? configuredOutputDir,
41
47
  publicDir: nitroConfig.output?.publicDir,
42
48
  preset: nitroConfig.preset ?? "node-server",
43
49
  basePath: configuredBasePath,
50
+ htmlBasePath,
44
51
  options: resolved,
45
52
  });
46
53
  generated = true;
47
- console.info(`[farm:pwa] Generated ${built.workerUrl} with ${built.precacheUrls.length} precached files`);
54
+ console.info(built.mode === "custom"
55
+ ? `[farm:pwa] Copied custom service worker to ${built.workerUrl}`
56
+ : `[farm:pwa] Generated ${built.workerUrl} with ${built.precacheUrls.length} precached files`);
48
57
  };
49
58
  return {
50
59
  ...nitroConfig,
@@ -67,11 +76,11 @@ export function pwa(options = {}) {
67
76
  client: {
68
77
  public: publicConfig,
69
78
  async setup({ public: config, isProd }) {
70
- if (!config.enabled || !isProd || !("serviceWorker" in navigator))
79
+ if (!isProd || !("serviceWorker" in navigator))
71
80
  return undefined;
72
- const registration = await navigator.serviceWorker.register(config.workerUrl, {
73
- scope: config.scope,
74
- });
81
+ const registration = await navigator.serviceWorker.register(config.workerUrl, config.workerType === "module"
82
+ ? { scope: config.scope, type: "module" }
83
+ : { scope: config.scope });
75
84
  const hadControllerAtSetup = Boolean(navigator.serviceWorker.controller);
76
85
  let applyingUpdate = false;
77
86
  let reloading = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farm.js/pwa",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.10",
4
4
  "description": "Installable, offline-aware Progressive Web Apps for Farm.js",
5
5
  "keywords": [
6
6
  "farm.js",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@farm.js/core": "0.1.0-beta.68"
35
+ "@farm.js/core": "0.1.6"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^20.10.5",
@@ -40,7 +40,7 @@
40
40
  "vitest": "^3.2.7"
41
41
  },
42
42
  "engines": {
43
- "node": ">=20.0.0"
43
+ "node": ">=22.13.0"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",