@wells-nelson/build-tools 1.0.1 → 1.0.3

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
@@ -36,18 +36,38 @@ export default defineConfig(({ mode, command }) => ({
36
36
  }));
37
37
  ```
38
38
 
39
+ ### Content Security Policy
40
+
41
+ `contentSecurityPolicy({ supabaseUrl })` returns SvelteKit `csp` options: hashes for SvelteKit's inline
42
+ scripts and no other scripts, network access only to the app and its Supabase project (REST and
43
+ Realtime), images from the app, `data:`/`blob:` and Google profile pictures. Pass `connect` / `img` for
44
+ more sources.
45
+
46
+ ```js
47
+ sveltekit({ csp: contentSecurityPolicy({ supabaseUrl: env.VITE_SUPABASE_URL }), ... })
48
+ ```
49
+
39
50
  ## Service worker (`@wells-nelson/build-tools/service-worker`)
40
51
 
41
52
  ```ts
42
53
  /// <reference types="@sveltejs/kit" />
43
- import { version } from '$app/env';
54
+ import { dev, version } from '$app/env';
44
55
  import { assets, immutable } from '$app/manifest';
45
56
  import { self } from '$app/service-worker';
46
57
  import { registerAppShellCache } from '@wells-nelson/build-tools/service-worker';
47
58
 
48
- registerAppShellCache({ sw: self, version, files: [...immutable, ...assets], cachePrefix: 'my-app' });
59
+ registerAppShellCache({ sw: self, version, dev, files: [...immutable, ...assets], cachePrefix: 'my-app' });
49
60
  ```
50
61
 
62
+ Navigations go to the network first, so starting the app online always loads the latest deploy;
63
+ offline they fall back to the cached shell. A new worker skips waiting and takes over open
64
+ pages. `networkOnly` paths are never cached (default: SvelteKit's `_app/version.json`, so
65
+ `updated` from `$app/state` notices new deploys). `dev` caches nothing.
66
+
67
+ On activate a worker deletes only its own older caches (`<cachePrefix>-cache-*`). Give each app
68
+ on an origin its own `cachePrefix`: GitHub Pages project sites of one user all share
69
+ `https://<user>.github.io`.
70
+
51
71
  ## Templates
52
72
 
53
73
  `templates/deploy-pages.yml` (replace `__REPO__`) and `templates/manifest.webmanifest`.
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Offline-first app shell for a SvelteKit service worker. Everything the app needs is precached
3
- * on install; navigations fall back to the cached shell (the app root) when the network is
4
- * unavailable; cross-origin requests (Supabase, Google avatars) are left to the network.
3
+ * on install; navigations go to the network first (so starting the app online always
4
+ * loads the latest deploy) and fall back to the cached shell (the app root) offline. A new
5
+ * deploy's worker installs, skips waiting and takes over open pages straight away. Cross-origin
6
+ * requests (Supabase, Google avatars) are left to the network.
5
7
  *
6
8
  * ```ts
7
9
  * // src/service-worker/index.ts
@@ -27,21 +29,30 @@ export interface ServiceWorkerScopeLike {
27
29
  }
28
30
  export interface AppShellCacheOptions {
29
31
  sw: ServiceWorkerScopeLike;
30
- /** Build version; each deploy gets a fresh cache and old ones are deleted. */
32
+ /** Build version; each deploy gets a fresh cache and this app's old ones are deleted. */
31
33
  version: string;
32
34
  /** Files to precache, relative to the scope (SvelteKit's `immutable` and `assets`). */
33
35
  files: Array<string | {
34
36
  path: string;
35
37
  }>;
36
- /** Cache name prefix (default 'app'). */
38
+ /**
39
+ * Cache name prefix (default 'app'). Give every app on the same origin its own prefix: a worker
40
+ * only deletes caches named `<cachePrefix>-cache-*`, so apps sharing an origin keep each other's.
41
+ */
37
42
  cachePrefix?: string;
38
43
  /**
39
44
  * Development: cache nothing and delete what an earlier worker cached, so every code change
40
45
  * loads (a cache-first worker would otherwise keep serving stale dev modules).
41
46
  */
42
47
  dev?: boolean;
48
+ /**
49
+ * Paths (relative to the scope) that always go to the network and are never cached. Defaults
50
+ * to SvelteKit's `_app/version.json`, so its update check (`updated` in `$app/state`) sees
51
+ * new deploys instead of the first answer it ever cached.
52
+ */
53
+ networkOnly?: string[];
43
54
  }
44
- export declare function registerAppShellCache({ sw, version, files, cachePrefix, dev }: AppShellCacheOptions): {
55
+ export declare function registerAppShellCache({ sw, version, files, cachePrefix, dev, networkOnly }: AppShellCacheOptions): {
45
56
  cacheName: null;
46
57
  precache: never[];
47
58
  } | {
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Offline-first app shell for a SvelteKit service worker. Everything the app needs is precached
3
- * on install; navigations fall back to the cached shell (the app root) when the network is
4
- * unavailable; cross-origin requests (Supabase, Google avatars) are left to the network.
3
+ * on install; navigations go to the network first (so starting the app online always
4
+ * loads the latest deploy) and fall back to the cached shell (the app root) offline. A new
5
+ * deploy's worker installs, skips waiting and takes over open pages straight away. Cross-origin
6
+ * requests (Supabase, Google avatars) are left to the network.
5
7
  *
6
8
  * ```ts
7
9
  * // src/service-worker/index.ts
@@ -14,7 +16,7 @@
14
16
  * registerAppShellCache({ sw: self, version, dev, files: [...immutable, ...assets], cachePrefix: 'my-app' });
15
17
  * ```
16
18
  */
17
- export function registerAppShellCache({ sw, version, files, cachePrefix = 'app', dev = false }) {
19
+ export function registerAppShellCache({ sw, version, files, cachePrefix = 'app', dev = false, networkOnly = ['_app/version.json'] }) {
18
20
  const CACHE = `${cachePrefix}-cache-${version}`;
19
21
  if (dev) {
20
22
  sw.addEventListener('install', (event) => event.waitUntil(sw.skipWaiting()));
@@ -31,6 +33,7 @@ export function registerAppShellCache({ sw, version, files, cachePrefix = 'app',
31
33
  const scope = new URL(sw.registration.scope);
32
34
  // The app root serves the SPA shell (index.html on GitHub Pages, the fallback page elsewhere).
33
35
  const SHELL = scope.href;
36
+ const NETWORK_ONLY = new Set(networkOnly.map((p) => new URL(p, scope).pathname));
34
37
  const PRECACHE = files.map((f) => new URL(typeof f === 'string' ? f : f.path, scope).href).concat(SHELL);
35
38
  sw.addEventListener('install', (event) => {
36
39
  event.waitUntil((async () => {
@@ -41,9 +44,12 @@ export function registerAppShellCache({ sw, version, files, cachePrefix = 'app',
41
44
  });
42
45
  sw.addEventListener('activate', (event) => {
43
46
  event.waitUntil((async () => {
44
- for (const key of await caches.keys())
45
- if (key !== CACHE)
47
+ // Only this app's older caches: other apps on the same origin (e.g. two GitHub Pages
48
+ // project sites under one user) keep theirs.
49
+ for (const key of await caches.keys()) {
50
+ if (key !== CACHE && key.startsWith(`${cachePrefix}-cache-`))
46
51
  await caches.delete(key);
52
+ }
47
53
  await sw.clients.claim();
48
54
  })());
49
55
  });
@@ -52,7 +58,7 @@ export function registerAppShellCache({ sw, version, files, cachePrefix = 'app',
52
58
  if (request.method !== 'GET')
53
59
  return;
54
60
  const url = new URL(request.url);
55
- if (url.origin !== scope.origin)
61
+ if (url.origin !== scope.origin || NETWORK_ONLY.has(url.pathname))
56
62
  return;
57
63
  if (request.mode === 'navigate') {
58
64
  event.respondWith((async () => {
package/dist/vite.d.ts CHANGED
@@ -44,3 +44,25 @@ export declare function devJsonModule({ id, file, enabled }: {
44
44
  file: string;
45
45
  enabled: boolean;
46
46
  }): Plugin;
47
+ /**
48
+ * SvelteKit `csp` options for an offline-first app on static hosting. SvelteKit adds a
49
+ * `<meta http-equiv="content-security-policy">` with hashes of its own inline scripts, so only
50
+ * the app's bundled scripts run: injected markup can't load or run other scripts. Network access
51
+ * is limited to the app's origin and its Supabase project (REST and Realtime); images also allow
52
+ * data/blob URLs and Google profile pictures. Styles allow inline (Svelte transitions use them).
53
+ *
54
+ * ```js
55
+ * sveltekit({ csp: contentSecurityPolicy({ supabaseUrl: env.VITE_SUPABASE_URL }), ... })
56
+ * ```
57
+ */
58
+ export declare function contentSecurityPolicy({ supabaseUrl, connect, img }?: {
59
+ /** The project URL (`VITE_SUPABASE_URL`); without it any `*.supabase.co` project is allowed. */
60
+ supabaseUrl?: string;
61
+ /** Extra `connect-src` sources. */
62
+ connect?: string[];
63
+ /** Extra `img-src` sources. */
64
+ img?: string[];
65
+ }): {
66
+ mode: "hash";
67
+ directives: Record<string, any[]>;
68
+ };
package/dist/vite.js CHANGED
@@ -80,3 +80,41 @@ export function devJsonModule({ id, file, enabled }) {
80
80
  }
81
81
  };
82
82
  }
83
+ /**
84
+ * SvelteKit `csp` options for an offline-first app on static hosting. SvelteKit adds a
85
+ * `<meta http-equiv="content-security-policy">` with hashes of its own inline scripts, so only
86
+ * the app's bundled scripts run: injected markup can't load or run other scripts. Network access
87
+ * is limited to the app's origin and its Supabase project (REST and Realtime); images also allow
88
+ * data/blob URLs and Google profile pictures. Styles allow inline (Svelte transitions use them).
89
+ *
90
+ * ```js
91
+ * sveltekit({ csp: contentSecurityPolicy({ supabaseUrl: env.VITE_SUPABASE_URL }), ... })
92
+ * ```
93
+ */
94
+ export function contentSecurityPolicy({ supabaseUrl, connect = [], img = [] } = {}) {
95
+ let supabase = ['https://*.supabase.co', 'wss://*.supabase.co'];
96
+ if (supabaseUrl) {
97
+ try {
98
+ const { host, protocol } = new URL(supabaseUrl);
99
+ supabase = [`${protocol}//${host}`, `${protocol === 'http:' ? 'ws' : 'wss'}://${host}`];
100
+ }
101
+ catch {
102
+ /* keep the wildcard */
103
+ }
104
+ }
105
+ // Typed loosely (`any[]`) so it fits SvelteKit's `CspDirectives` without depending on SvelteKit.
106
+ const directives = {
107
+ 'default-src': ['self'],
108
+ 'script-src': ['self'],
109
+ 'style-src': ['self', 'unsafe-inline'],
110
+ 'img-src': ['self', 'data:', 'blob:', 'https://*.googleusercontent.com', ...img],
111
+ 'font-src': ['self'],
112
+ 'connect-src': ['self', ...supabase, ...connect],
113
+ 'worker-src': ['self'],
114
+ 'manifest-src': ['self'],
115
+ 'object-src': ['none'],
116
+ 'base-uri': ['self'],
117
+ 'form-action': ['self']
118
+ };
119
+ return { mode: 'hash', directives };
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wells-nelson/build-tools",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Build and deployment tooling for Wells Nelson SvelteKit PWAs: postbuild, PWA icon generator, Vite config pieces, service worker, and templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,9 @@ jobs:
26
26
  node-version: 24
27
27
  cache: npm
28
28
  - run: npm ci
29
+ # Nothing ships unless it type-checks and its tests pass.
30
+ - run: npm run check --if-present
31
+ - run: npm test --if-present
29
32
  - run: npm run build
30
33
  env:
31
34
  # Repository variables (Settings > Secrets and variables > Actions > Variables).