spark-html-offline 0.1.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/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # ⚡ spark-html-offline
2
+
3
+ Offline-capable URL imports for [spark-html](https://www.npmjs.com/package/spark-html)
4
+ — a tiny service worker that caches `<div import="https://…">` components on
5
+ first fetch and serves them when the CDN is unreachable or the user is
6
+ offline. Zero dependencies.
7
+
8
+ It kills the #1 critique of CDN imports: **"CDN down = component gone."**
9
+ With the worker installed, a component imported by URL is served from cache
10
+ instantly on every visit after the first, and refreshed in the background —
11
+ users are never more than one visit behind, and never broken.
12
+
13
+ ```js
14
+ // src/main.js — zero config
15
+ import { offline } from 'spark-html-offline';
16
+ offline();
17
+ ```
18
+
19
+ ```js
20
+ // vite.config.js — emits /spark-sw.js in build, serves it in dev
21
+ import spark from 'spark-html/vite';
22
+ import offlineSw from 'spark-html-offline/vite';
23
+
24
+ export default { plugins: [spark(), offlineSw()] };
25
+ ```
26
+
27
+ ```html
28
+ <!-- this now works on a plane -->
29
+ <div import="https://esm.sh/some-pkg/card.html"></div>
30
+ ```
31
+
32
+ Works with any CDN — esm.sh, unpkg, jsdelivr, raw.githubusercontent, your own.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ npm install spark-html-offline
38
+ ```
39
+
40
+ ## How it works
41
+
42
+ The worker intercepts **cross-origin GET requests only** (the CDN-import
43
+ case) with a cache-first, background-revalidate strategy:
44
+
45
+ 1. **First visit** — fetched from the network, stored in the cache.
46
+ 2. **Every visit after** — served from cache instantly; a background fetch
47
+ refreshes the entry for next time.
48
+ 3. **Network gone** — the cached copy is served; a URL never seen before
49
+ answers `504`.
50
+
51
+ Same-origin requests are untouched by default, so dev servers, HMR, and your
52
+ own assets behave exactly as before.
53
+
54
+ ## Options
55
+
56
+ ```js
57
+ // vite.config.js
58
+ offlineSw({
59
+ file: 'spark-sw.js', // emitted worker file name
60
+ include: ['/components/'], // ALSO cache these same-origin paths
61
+ exclude: ['/api/'], // never touch these (substring match)
62
+ cacheName: 'spark-offline-v1',
63
+ });
64
+ ```
65
+
66
+ ```js
67
+ // main.js
68
+ offline({
69
+ sw: 'spark-sw.js', // worker URL, relative to the page base
70
+ scope: '/', // registration scope
71
+ });
72
+ ```
73
+
74
+ `offline()` no-ops safely wherever service workers don't exist — prerender
75
+ builds, old browsers, non-secure origins — your app runs exactly as before,
76
+ just without the safety net.
77
+
78
+ ## No build step?
79
+
80
+ You don't need Vite. Generate the worker once and host it next to
81
+ `index.html`:
82
+
83
+ ```js
84
+ // node make-sw.mjs > spark-sw.js
85
+ import { swSource } from 'spark-html-offline';
86
+ console.log(swSource({ include: ['/components/'] }));
87
+ ```
88
+
89
+ Then call `offline()` from any `<script type="module">`.
90
+
91
+ ## API
92
+
93
+ | Export | Meaning |
94
+ |--------|---------|
95
+ | `offline(options?)` | Register the worker. Returns the registration, or `null` where unsupported. |
96
+ | `swSource(options?)` | The full worker source as a string. |
97
+ | `shouldHandle(url, origin, config?)` | The matching rule the worker uses (exported for tests/tooling). |
98
+ | `CACHE_NAME` | Default cache bucket name (`'spark-offline-v1'`). |
99
+ | `spark-html-offline/vite` | Vite plugin — emits the worker in build, serves it in dev. |
100
+
101
+ ## License
102
+
103
+ MIT
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "spark-html-offline",
3
+ "version": "0.1.0",
4
+ "description": "Offline-capable URL imports for spark-html — a tiny service worker that caches CDN-imported components on first fetch and serves them when the network is gone. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "homepage": "https://wilkinnovo.github.io/spark",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./vite": {
15
+ "types": "./src/vite.d.ts",
16
+ "default": "./src/vite.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "test": "node test/offline.js"
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/wilkinnovo/spark.git",
28
+ "directory": "packages/spark-html-offline"
29
+ },
30
+ "keywords": [
31
+ "spark-html",
32
+ "service-worker",
33
+ "offline",
34
+ "pwa",
35
+ "cdn",
36
+ "cache-first",
37
+ "stale-while-revalidate"
38
+ ],
39
+ "license": "MIT"
40
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /** Options shared by the worker generator and the vite plugin. */
2
+ export interface OfflineSwOptions {
3
+ /** Same-origin URL substrings to cache too (e.g. ['/components/']). */
4
+ include?: string[];
5
+ /** URL substrings the worker must never touch. */
6
+ exclude?: string[];
7
+ /** Override the cache bucket name (default 'spark-offline-v1'). */
8
+ cacheName?: string;
9
+ }
10
+
11
+ export interface OfflineOptions {
12
+ /** Worker URL, relative to the page base (default 'spark-sw.js'). */
13
+ sw?: string;
14
+ /** Registration scope (default: the worker's directory). */
15
+ scope?: string;
16
+ }
17
+
18
+ /** Default cache bucket name. */
19
+ export const CACHE_NAME: string;
20
+
21
+ /** True when the worker should intercept this URL. */
22
+ export function shouldHandle(
23
+ url: string,
24
+ origin: string,
25
+ config?: { include?: string[]; exclude?: string[] },
26
+ ): boolean;
27
+
28
+ /** The full service-worker source as a string. */
29
+ export function swSource(options?: OfflineSwOptions): string;
30
+
31
+ /** Register the worker (no-op where service workers don't exist). */
32
+ export function offline(options?: OfflineOptions): Promise<ServiceWorkerRegistration | null>;
33
+
34
+ declare const _default: {
35
+ offline: typeof offline;
36
+ swSource: typeof swSource;
37
+ shouldHandle: typeof shouldHandle;
38
+ CACHE_NAME: string;
39
+ };
40
+ export default _default;
41
+
42
+ /** Vite plugin: emits the worker in build, serves it in dev. */
43
+ export interface OfflineVitePluginOptions extends OfflineSwOptions {
44
+ /** Emitted file name (default 'spark-sw.js'). */
45
+ file?: string;
46
+ }
package/src/index.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * spark-html-offline — URL-imported components that survive a dead CDN.
3
+ *
4
+ * The #1 critique of `<div import="https://esm.sh/…/card.html">` is
5
+ * "CDN down = component gone". This package is a tiny service worker
6
+ * that caches those cross-origin fetches on first load and serves them
7
+ * cache-first afterwards (refreshing in the background), so a component
8
+ * imported by URL keeps working when the CDN is slow, flaky, or the
9
+ * user is offline.
10
+ *
11
+ * // main.js — zero config
12
+ * import { offline } from 'spark-html-offline';
13
+ * offline();
14
+ *
15
+ * // vite.config.js — emits + serves the worker file for you
16
+ * import offlineSw from 'spark-html-offline/vite';
17
+ * plugins: [spark(), offlineSw()]
18
+ *
19
+ * Works with any CDN (esm.sh, unpkg, jsdelivr, your own). By default it
20
+ * ONLY handles cross-origin GETs — local dev files are never intercepted,
21
+ * so dev servers and HMR behave exactly as before. Same-origin paths can
22
+ * opt in via `include` (e.g. `include: ['/components/']` to make your own
23
+ * fragments offline-capable too).
24
+ *
25
+ * No build step? Write the worker once with `swSource()` (or copy it from
26
+ * the README), host it next to index.html, and call `offline()`.
27
+ */
28
+
29
+ /** Cache name — bump the suffix to invalidate everything on upgrade. */
30
+ export const CACHE_NAME = 'spark-offline-v1';
31
+
32
+ /**
33
+ * Decide whether the worker should handle a URL.
34
+ * Cross-origin http(s) → yes (the CDN-import case), unless excluded.
35
+ * Same-origin → only when it matches an `include` substring.
36
+ * This exact function is embedded into the generated worker.
37
+ */
38
+ export function shouldHandle(url, origin, config) {
39
+ var u;
40
+ try { u = new URL(url); } catch (e) { return false; }
41
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
42
+ var cfg = config || {};
43
+ var i;
44
+ for (i = 0; i < (cfg.exclude || []).length; i++) {
45
+ if (url.indexOf(cfg.exclude[i]) !== -1) return false;
46
+ }
47
+ if (u.origin !== origin) return true;
48
+ for (i = 0; i < (cfg.include || []).length; i++) {
49
+ if (url.indexOf(cfg.include[i]) !== -1) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * The service-worker source, as a string — write it to a file served from
56
+ * your origin (the vite plugin does this for you as /spark-sw.js).
57
+ *
58
+ * Strategy: cache-first with background revalidation (stale-while-
59
+ * revalidate). First visit fetches + caches; every visit after serves
60
+ * from cache instantly and refreshes the entry in the background, so
61
+ * users are never more than one visit behind — and never broken.
62
+ *
63
+ * @param {object} [options]
64
+ * @param {string[]} [options.include] Same-origin URL substrings to cache too.
65
+ * @param {string[]} [options.exclude] URL substrings to never touch.
66
+ * @param {string} [options.cacheName] Override the cache bucket name.
67
+ */
68
+ export function swSource(options = {}) {
69
+ const config = JSON.stringify({
70
+ include: options.include || [],
71
+ exclude: options.exclude || [],
72
+ });
73
+ const cache = JSON.stringify(options.cacheName || CACHE_NAME);
74
+ return `/* generated by spark-html-offline — cache-first for URL imports */
75
+ 'use strict';
76
+ var CACHE = ${cache};
77
+ var CONFIG = ${config};
78
+ var shouldHandle = ${shouldHandle.toString()};
79
+
80
+ self.addEventListener('install', function () { self.skipWaiting(); });
81
+ self.addEventListener('activate', function (event) {
82
+ event.waitUntil(self.clients.claim());
83
+ });
84
+ self.addEventListener('fetch', function (event) {
85
+ var req = event.request;
86
+ if (req.method !== 'GET') return;
87
+ if (!shouldHandle(req.url, self.location.origin, CONFIG)) return;
88
+ event.respondWith(caches.open(CACHE).then(function (cache) {
89
+ return cache.match(req).then(function (cached) {
90
+ var refresh = fetch(req).then(function (res) {
91
+ if (res && res.ok) cache.put(req, res.clone());
92
+ return res;
93
+ }).catch(function () { return null; });
94
+ if (cached) {
95
+ // Serve instantly; keep the worker alive while the refresh lands.
96
+ event.waitUntil(refresh);
97
+ return cached;
98
+ }
99
+ return refresh.then(function (fresh) {
100
+ return fresh || new Response('', { status: 504, statusText: 'offline and never cached' });
101
+ });
102
+ });
103
+ }));
104
+ });
105
+ `;
106
+ }
107
+
108
+ /**
109
+ * Register the worker. Call once from main.js. No-ops safely where
110
+ * service workers don't exist (prerender, old browsers, non-secure
111
+ * origins) — your app runs exactly as before, just without the net.
112
+ *
113
+ * @param {object} [options]
114
+ * @param {string} [options.sw='spark-sw.js'] Worker URL (relative to the page base).
115
+ * @param {string} [options.scope] Registration scope (default: the worker's directory).
116
+ * @returns {Promise<ServiceWorkerRegistration|null>}
117
+ */
118
+ export async function offline(options = {}) {
119
+ if (typeof navigator === 'undefined' || !navigator.serviceWorker) return null;
120
+ if (typeof globalThis.__SPARK_PRERENDER__ !== 'undefined' && globalThis.__SPARK_PRERENDER__) return null;
121
+ const sw = options.sw || 'spark-sw.js';
122
+ try {
123
+ return await navigator.serviceWorker.register(sw, options.scope ? { scope: options.scope } : undefined);
124
+ } catch (e) {
125
+ console.warn(`[spark-offline] service worker registration failed: ${e.message}`);
126
+ return null;
127
+ }
128
+ }
129
+
130
+ export default { offline, swSource, shouldHandle, CACHE_NAME };
package/src/vite.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { OfflineVitePluginOptions } from './index.js';
2
+
3
+ /** Vite plugin: emits the service worker in build, serves it in dev. */
4
+ export default function sparkOffline(options?: OfflineVitePluginOptions): {
5
+ name: string;
6
+ configureServer(server: unknown): void;
7
+ generateBundle(): void;
8
+ };
package/src/vite.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * spark-html-offline/vite — emit + serve the worker file.
3
+ *
4
+ * Build: writes the generated service worker into the output dir (default
5
+ * name /spark-sw.js) so `offline()` finds it in production.
6
+ * Dev: serves the same source from the dev server, so the worker can be
7
+ * exercised locally too (it only touches cross-origin URLs by default, so
8
+ * HMR and local files are unaffected).
9
+ *
10
+ * import offlineSw from 'spark-html-offline/vite';
11
+ * plugins: [spark(), offlineSw({ include: ['/components/'] })]
12
+ */
13
+ import { swSource } from './index.js';
14
+
15
+ export default function sparkOffline(options = {}) {
16
+ const file = (options.file || 'spark-sw.js').replace(/^\//, '');
17
+ const source = swSource(options);
18
+ return {
19
+ name: 'spark-html-offline',
20
+ configureServer(server) {
21
+ server.middlewares.use((req, res, next) => {
22
+ if ((req.url || '').split('?')[0] === `/${file}`) {
23
+ res.setHeader('Content-Type', 'text/javascript');
24
+ res.setHeader('Cache-Control', 'no-cache');
25
+ res.end(source);
26
+ return;
27
+ }
28
+ next();
29
+ });
30
+ },
31
+ generateBundle() {
32
+ this.emitFile({ type: 'asset', fileName: file, source });
33
+ },
34
+ };
35
+ }