@farm.js/pwa 0.1.0-beta.1 → 0.1.0-beta.2

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/dist/build.d.ts CHANGED
@@ -17,6 +17,7 @@ export interface PwaBuildResult {
17
17
  }
18
18
  export declare function writePwaBuildArtifacts(input: PwaBuildInput): Promise<PwaBuildResult>;
19
19
  export interface GenerateServiceWorkerOptions {
20
+ basePath: string;
20
21
  cacheId: string;
21
22
  precacheUrls: string[];
22
23
  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,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,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,CAsG1F;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,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,CAyG1F;AAED,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,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,CAyKnF;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,CAQpE"}
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",
@@ -17,6 +17,7 @@ export async function writePwaBuildArtifacts(input) {
17
17
  const basePath = normalizeBasePath(input.basePath);
18
18
  const workerRelativePath = path.posix.join(basePath.replace(/^\//, ""), "sw.js");
19
19
  const workerPath = path.join(publicDir, ...workerRelativePath.split("/"));
20
+ await assertWorkerPathInsidePublicDir(publicDir, workerPath);
20
21
  if (input.options.serviceWorker) {
21
22
  const sourcePath = path.resolve(input.root ?? process.cwd(), input.options.serviceWorker.source);
22
23
  let source;
@@ -52,9 +53,10 @@ export async function writePwaBuildArtifacts(input) {
52
53
  }
53
54
  selectedRoutes[offlineRoute] = emittedOfflineFile;
54
55
  }
55
- const assetFiles = files.filter((file) => file !== workerRelativePath && PRECACHE_EXTENSIONS.has(path.extname(file).toLowerCase()));
56
+ const assetFiles = files.filter((file) => toPublicUrl(file, basePath) !== withBasePath("/sw.js", basePath) &&
57
+ PRECACHE_EXTENSIONS.has(path.extname(file).toLowerCase()));
56
58
  const precacheFiles = [...new Set([...assetFiles, ...Object.values(selectedRoutes)])].sort();
57
- const precacheUrls = precacheFiles.map(toPublicUrl);
59
+ const precacheUrls = precacheFiles.map((file) => toPublicUrl(file, basePath));
58
60
  const fileHashes = await Promise.all(precacheFiles.map(async (file) => {
59
61
  const content = await readFile(path.join(publicDir, file));
60
62
  return `${file}:${createHash("sha256").update(content).digest("hex")}`;
@@ -71,6 +73,7 @@ export async function writePwaBuildArtifacts(input) {
71
73
  .slice(0, 16);
72
74
  await mkdir(path.dirname(workerPath), { recursive: true });
73
75
  await writeFile(workerPath, generateServiceWorker({
76
+ basePath,
74
77
  cacheId,
75
78
  precacheUrls,
76
79
  staticRoutes: selectedRoutes,
@@ -88,11 +91,22 @@ export async function writePwaBuildArtifacts(input) {
88
91
  };
89
92
  }
90
93
  export function generateServiceWorker(options) {
91
- const routeFiles = Object.fromEntries(Object.entries(options.staticRoutes).map(([route, file]) => [route, toPublicUrl(file)]));
92
- const offlineFile = options.offlineRoute ? (routeFiles[options.offlineRoute] ?? null) : null;
94
+ const routeFiles = Object.fromEntries(Object.entries(options.staticRoutes).map(([route, file]) => [
95
+ encodePathname(route),
96
+ toPublicUrl(file, options.basePath),
97
+ ]));
98
+ const offlineFile = options.offlineRoute
99
+ ? (routeFiles[encodePathname(options.offlineRoute)] ?? null)
100
+ : null;
101
+ const cacheScope = createHash("sha256")
102
+ .update(normalizeBasePath(options.basePath))
103
+ .digest("hex")
104
+ .slice(0, 16);
105
+ const precachePrefix = `farm-pwa-precache-${cacheScope}-`;
93
106
  return `/* Generated by @farm.js/pwa. */
94
- const PRECACHE = ${JSON.stringify(`farm-pwa-precache-${options.cacheId}`)};
95
- const IMAGE_CACHE = "farm-pwa-images-v1";
107
+ const PRECACHE_PREFIX = ${JSON.stringify(precachePrefix)};
108
+ const PRECACHE = ${JSON.stringify(`${precachePrefix}${options.cacheId}`)};
109
+ const IMAGE_CACHE = ${JSON.stringify(`farm-pwa-images-${cacheScope}-v1`)};
96
110
  const PRECACHE_URLS = ${JSON.stringify(options.precacheUrls)};
97
111
  const PRECACHE_PATHS = new Set(PRECACHE_URLS.map((value) => new URL(value, self.location.origin).pathname));
98
112
  const STATIC_ROUTES = ${JSON.stringify(routeFiles)};
@@ -111,7 +125,7 @@ self.addEventListener("activate", (event) => {
111
125
  event.waitUntil((async () => {
112
126
  const names = await caches.keys();
113
127
  await Promise.all(names
114
- .filter((name) => name.startsWith("farm-pwa-precache-") && name !== PRECACHE)
128
+ .filter((name) => name.startsWith(PRECACHE_PREFIX) && name !== PRECACHE)
115
129
  .map((name) => caches.delete(name)));
116
130
  await self.clients.claim();
117
131
  })());
@@ -139,7 +153,9 @@ self.addEventListener("fetch", (event) => {
139
153
  }
140
154
 
141
155
  if (IMAGE_OPTIONS && request.destination === "image") {
142
- event.respondWith(staleWhileRevalidateImage(request));
156
+ const image = staleWhileRevalidateImage(request);
157
+ event.respondWith(image.response);
158
+ event.waitUntil(image.lifetime);
143
159
  }
144
160
  });
145
161
 
@@ -166,26 +182,36 @@ async function handleNavigation(request, url) {
166
182
  }
167
183
  }
168
184
 
169
- async function staleWhileRevalidateImage(request) {
170
- if (request.headers.has("authorization")) return fetch(request);
185
+ function staleWhileRevalidateImage(request) {
186
+ let update = Promise.resolve();
187
+ const response = (async () => {
188
+ if (request.headers.has("authorization")) return fetch(request);
171
189
 
172
- const cache = await caches.open(IMAGE_CACHE);
173
- const cached = await cache.match(request);
174
- const cachedAt = Number(cached?.headers.get("x-farm-pwa-cached-at") || 0);
175
- const fresh = cached && Date.now() - cachedAt <= IMAGE_OPTIONS.ttlMs;
176
- const update = fetchAndCacheImage(request, cache);
190
+ const cache = await caches.open(IMAGE_CACHE);
191
+ const cached = await cache.match(request);
192
+ const cachedAt = Number(cached?.headers.get("x-farm-pwa-cached-at") || 0);
193
+ const fresh = cached && Date.now() - cachedAt <= IMAGE_OPTIONS.ttlMs;
194
+ update = fetchAndCacheImage(request, cache);
177
195
 
178
- if (fresh) {
179
- void update.catch(() => undefined);
180
- return cached;
181
- }
196
+ if (fresh) return cached;
182
197
 
183
- try {
184
- return await update;
185
- } catch (error) {
186
- if (cached) return cached;
187
- throw error;
188
- }
198
+ try {
199
+ return await update;
200
+ } catch (error) {
201
+ if (cached) return cached;
202
+ throw error;
203
+ }
204
+ })();
205
+ const lifetime = response
206
+ .then(
207
+ () => update,
208
+ () => update,
209
+ )
210
+ .then(
211
+ () => undefined,
212
+ () => undefined,
213
+ );
214
+ return { response, lifetime };
189
215
  }
190
216
 
191
217
  async function fetchAndCacheImage(request, cache) {
@@ -234,6 +260,46 @@ function normalizePathname(pathname) {
234
260
  export function resolvePwaPublicDir(outputDir, preset) {
235
261
  return path.join(outputDir, preset === "vercel" || preset === "vercel-edge" ? "static" : "public");
236
262
  }
263
+ async function assertWorkerPathInsidePublicDir(publicDir, workerPath) {
264
+ const prospectivePublicDir = await resolveProspectiveRealPath(publicDir);
265
+ const prospectiveWorkerPath = await resolveProspectiveRealPath(workerPath);
266
+ const relativePath = path.relative(prospectivePublicDir, prospectiveWorkerPath);
267
+ if (relativePath === ".." ||
268
+ relativePath.startsWith(`..${path.sep}`) ||
269
+ path.isAbsolute(relativePath)) {
270
+ throw new Error("[farm:pwa] Service worker output must stay inside the public output directory, including through symlinks.");
271
+ }
272
+ }
273
+ async function resolveProspectiveRealPath(candidate) {
274
+ const missingSegments = [];
275
+ let existingAncestor = path.resolve(candidate);
276
+ while (true) {
277
+ try {
278
+ return path.join(await realpath(existingAncestor), ...missingSegments);
279
+ }
280
+ catch (error) {
281
+ if (error.code !== "ENOENT")
282
+ throw error;
283
+ let existingEntry = false;
284
+ try {
285
+ await lstat(existingAncestor);
286
+ existingEntry = true;
287
+ }
288
+ catch (statError) {
289
+ if (statError.code !== "ENOENT")
290
+ throw statError;
291
+ }
292
+ if (existingEntry) {
293
+ throw new Error("[farm:pwa] Service worker output must stay inside the public output directory, including through symlinks.");
294
+ }
295
+ const parent = path.dirname(existingAncestor);
296
+ if (parent === existingAncestor)
297
+ throw error;
298
+ missingSegments.unshift(path.basename(existingAncestor));
299
+ existingAncestor = parent;
300
+ }
301
+ }
302
+ }
237
303
  export function normalizeBasePath(value) {
238
304
  if (!value || value === "/")
239
305
  return "/";
@@ -291,10 +357,17 @@ function selectStaticRoutes(emitted, configured, basePath) {
291
357
  }
292
358
  return selected;
293
359
  }
294
- function toPublicUrl(file) {
295
- return `/${file
360
+ function toPublicUrl(file, basePath) {
361
+ const url = `/${file
296
362
  .replace(/\\/g, "/")
297
363
  .split("/")
298
364
  .map((segment) => encodeURIComponent(segment))
299
365
  .join("/")}`;
366
+ return withBasePath(url, basePath);
367
+ }
368
+ function encodePathname(pathname) {
369
+ return pathname
370
+ .split("/")
371
+ .map((segment) => encodeURIComponent(segment))
372
+ .join("/");
300
373
  }
package/dist/config.d.ts CHANGED
@@ -21,8 +21,6 @@ export interface PwaServiceWorkerOptions {
21
21
  type?: "classic" | "module";
22
22
  }
23
23
  export interface PwaPluginOptions {
24
- /** Set false to keep the plugin registered without emitting or registering a worker. */
25
- enabled?: boolean;
26
24
  /** Static route to serve after an offline navigation misses the cache. */
27
25
  offline?: string | false;
28
26
  /** How a waiting service worker becomes active. */
@@ -38,7 +36,6 @@ export interface ResolvedPwaImageCacheOptions {
38
36
  ttlMs: number;
39
37
  }
40
38
  export interface ResolvedPwaOptions {
41
- enabled: boolean;
42
39
  offline: string | false;
43
40
  update: "prompt" | "auto";
44
41
  serviceWorker: Required<PwaServiceWorkerOptions> | 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,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,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,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,OAAO,CAAC;IACjB,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,CAkCpF;AAcD,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,15 +1,36 @@
1
1
  const DEFAULT_IMAGE_LIMIT = 100;
2
2
  const DEFAULT_IMAGE_TTL = "30d";
3
3
  export function resolvePwaOptions(options = {}) {
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
+ }
4
18
  const serviceWorker = resolveServiceWorker(options.serviceWorker);
5
19
  if (serviceWorker && (options.offline !== undefined || options.cache !== undefined)) {
6
20
  throw new TypeError("PWA serviceWorker cannot be combined with offline or cache because the custom worker owns those behaviors");
7
21
  }
8
- const cache = options.cache ?? "auto";
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";
9
31
  const usesAutomaticCache = cache === "auto" || cache === "recommended";
10
32
  const customCache = typeof cache === "object" ? cache : undefined;
11
33
  return {
12
- enabled: options.enabled !== false,
13
34
  offline: serviceWorker ? false : normalizeRoute(options.offline ?? false, "offline"),
14
35
  update: options.update ?? "prompt",
15
36
  serviceWorker,
@@ -20,20 +41,26 @@ export function resolvePwaOptions(options = {}) {
20
41
  ? true
21
42
  : cache === false
22
43
  ? false
23
- : normalizeStaticRoutes(customCache?.staticRoutes ?? false),
44
+ : normalizeStaticRoutes(customCache?.staticRoutes === undefined ? false : customCache.staticRoutes),
24
45
  images: serviceWorker
25
46
  ? false
26
47
  : usesAutomaticCache
27
48
  ? resolveImageCache("swr")
28
49
  : cache === false
29
50
  ? false
30
- : resolveImageCache(customCache?.images ?? false),
51
+ : resolveImageCache(customCache?.images === undefined ? false : customCache.images),
31
52
  },
32
53
  };
33
54
  }
34
55
  function resolveServiceWorker(value) {
35
- if (!value)
56
+ if (value === undefined)
36
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
+ }
37
64
  const source = value.source.trim();
38
65
  if (!source)
39
66
  throw new TypeError("PWA serviceWorker source must be a non-empty path");
@@ -69,7 +96,9 @@ export function parsePwaDuration(value) {
69
96
  function resolveImageCache(value) {
70
97
  if (value === false)
71
98
  return false;
72
- 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")) {
73
102
  throw new TypeError('PWA image cache strategy must be "swr"');
74
103
  }
75
104
  const configured = typeof value === "object" ? value : undefined;
@@ -84,8 +113,11 @@ function resolveImageCache(value) {
84
113
  };
85
114
  }
86
115
  function normalizeStaticRoutes(value) {
87
- if (!Array.isArray(value))
116
+ if (typeof value === "boolean")
88
117
  return value;
118
+ if (!Array.isArray(value)) {
119
+ throw new TypeError("PWA cache staticRoutes must be boolean or an array of routes");
120
+ }
89
121
  return [...new Set(value.map((route) => normalizeRoute(route, "static route")))];
90
122
  }
91
123
  function normalizeRoute(value, label) {
@@ -95,8 +127,36 @@ function normalizeRoute(value, label) {
95
127
  if (!route.startsWith("/")) {
96
128
  throw new TypeError(`PWA ${label} must start with "/"`);
97
129
  }
130
+ if (route.startsWith("//")) {
131
+ throw new TypeError(`PWA ${label} must be an application pathname, not a URL`);
132
+ }
98
133
  if (route.includes("?") || route.includes("#")) {
99
134
  throw new TypeError(`PWA ${label} cannot contain a query string or fragment`);
100
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
+ }
101
154
  return route === "/" ? route : route.replace(/\/+$/, "");
102
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
@@ -22,7 +22,6 @@ declare global {
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";
@@ -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,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;;;;;;;;mBA6IjD"}
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;;;;;;;mBA0IjD"}
package/dist/index.js CHANGED
@@ -12,7 +12,6 @@ export function pwa(options = {}) {
12
12
  let configuredBasePath = "/";
13
13
  let configuredOutputDir = ".farm/.output";
14
14
  const publicConfig = {
15
- enabled: resolved.enabled,
16
15
  workerUrl: "/sw.js",
17
16
  scope: "/",
18
17
  update: resolved.update,
@@ -30,8 +29,6 @@ export function pwa(options = {}) {
30
29
  },
31
30
  build: {
32
31
  configure(nitroConfig) {
33
- if (!resolved.enabled)
34
- return nitroConfig;
35
32
  let generated = false;
36
33
  const hooks = nitroConfig.hooks ?? {};
37
34
  const previousPrerenderDone = hooks["prerender:done"];
@@ -73,7 +70,7 @@ export function pwa(options = {}) {
73
70
  client: {
74
71
  public: publicConfig,
75
72
  async setup({ public: config, isProd }) {
76
- if (!config.enabled || !isProd || !("serviceWorker" in navigator))
73
+ if (!isProd || !("serviceWorker" in navigator))
77
74
  return undefined;
78
75
  const registration = await navigator.serviceWorker.register(config.workerUrl, config.workerType === "module"
79
76
  ? { scope: config.scope, type: "module" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farm.js/pwa",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.2",
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.69"
35
+ "@farm.js/core": "0.1.0-beta.93"
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",