webhanger-front 1.0.0 → 1.0.1

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.
Files changed (4) hide show
  1. package/README.md +167 -0
  2. package/browser.js +132 -13
  3. package/index.js +71 -12
  4. package/package.json +8 -2
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # webhanger-front
2
+
3
+ Browser SDK for WebHanger. Loads encrypted UI components from CDN and injects them into the DOM. No eval, no readable source — decrypted in memory and applied directly.
4
+
5
+ ---
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install webhanger-front
11
+ ```
12
+
13
+ Or via script tag:
14
+
15
+ ```html
16
+ <script src="https://unpkg.com/webhanger-front/browser.js"></script>
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Usage
22
+
23
+ ### Script tag (plain HTML)
24
+
25
+ ```html
26
+ <div data-wh></div>
27
+
28
+ <script src="https://unpkg.com/webhanger-front/browser.js"></script>
29
+ <script>
30
+ WebHangerFront.load(
31
+ "https://xxx.cloudfront.net/components/navbar@1.0.0.js",
32
+ "wh_1234567890", // projectId from webhanger.config.json
33
+ "your-token",
34
+ 0 // expires: 0 = never, or unix timestamp
35
+ );
36
+ </script>
37
+ ```
38
+
39
+ ### ESM (Vite, Webpack, Rollup)
40
+
41
+ ```js
42
+ import { load } from "webhanger-front";
43
+
44
+ await load(
45
+ "https://xxx.cloudfront.net/components/navbar@1.0.0.js",
46
+ "wh_1234567890",
47
+ "your-token",
48
+ 0
49
+ );
50
+ ```
51
+
52
+ ### React
53
+
54
+ ```jsx
55
+ import { useEffect } from "react";
56
+ import { load } from "webhanger-front";
57
+
58
+ export default function Navbar() {
59
+ useEffect(() => {
60
+ load(
61
+ "https://xxx.cloudfront.net/components/navbar@1.0.0.js",
62
+ "wh_1234567890",
63
+ "your-token",
64
+ 0,
65
+ "#navbar-mount"
66
+ );
67
+ }, []);
68
+
69
+ return <div id="navbar-mount" />;
70
+ }
71
+ ```
72
+
73
+ ### Vue
74
+
75
+ ```vue
76
+ <template>
77
+ <div ref="mount" />
78
+ </template>
79
+
80
+ <script setup>
81
+ import { onMounted, ref } from "vue";
82
+ import { load } from "webhanger-front";
83
+
84
+ const mount = ref(null);
85
+
86
+ onMounted(() => {
87
+ load(
88
+ "https://xxx.cloudfront.net/components/navbar@1.0.0.js",
89
+ "wh_1234567890",
90
+ "your-token",
91
+ 0,
92
+ "#navbar-mount"
93
+ );
94
+ });
95
+ </script>
96
+ ```
97
+
98
+ ---
99
+
100
+ ## API
101
+
102
+ ### `load(cdnUrl, projectId, token, expires, selector?)`
103
+
104
+ | Param | Type | Description |
105
+ |---|---|---|
106
+ | `cdnUrl` | `string` | Full CDN URL of the component |
107
+ | `projectId` | `string` | Your WebHanger project ID — used as decrypt key |
108
+ | `token` | `string` | HMAC signed token from `wh deploy` |
109
+ | `expires` | `number` | Unix timestamp expiry. `0` = never expires |
110
+ | `selector` | `string` | CSS selector for mount target. Default: `[data-wh]` |
111
+
112
+ ```js
113
+ await load(cdnUrl, projectId, token, expires, "[data-wh]");
114
+ ```
115
+
116
+ ---
117
+
118
+ ### `clearCache()`
119
+
120
+ Clears all cached components from localStorage and IndexedDB.
121
+
122
+ ```js
123
+ import { clearCache } from "webhanger-front";
124
+ await clearCache();
125
+ ```
126
+
127
+ ---
128
+
129
+ ### `version`
130
+
131
+ ```js
132
+ import { version } from "webhanger-front";
133
+ console.log(version); // "1.0.0"
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Caching
139
+
140
+ Components are automatically cached after first load:
141
+
142
+ | Size | Storage |
143
+ |---|---|
144
+ | < 50KB | `localStorage` |
145
+ | >= 50KB | `IndexedDB` |
146
+
147
+ Cache key is `cdnUrl@expires` — changing the version or expiry busts the cache automatically.
148
+
149
+ ---
150
+
151
+ ## Offline support
152
+
153
+ Register the service worker from the `webhanger` package for offline component delivery:
154
+
155
+ ```js
156
+ navigator.serviceWorker.register("/webhanger.sw.js");
157
+ ```
158
+
159
+ ---
160
+
161
+ ## How it works
162
+
163
+ 1. Fetches encrypted JSON payload from CDN
164
+ 2. Decrypts each chunk in memory using `projectId` as cipher key
165
+ 3. Injects CSS into `<head>`, HTML into mount target, JS via `<script>`
166
+ 4. No `eval` — browser parses JS natively
167
+ 5. Payload on CDN is unreadable without the projectId
package/browser.js CHANGED
@@ -8,17 +8,17 @@
8
8
  const LS_PREFIX = "wh_";
9
9
  const SIZE_THRESHOLD = 50 * 1024;
10
10
 
11
- function xorDecode(str, key) {
12
- let out = "";
13
- for (let i = 0; i < str.length; i++) {
14
- out += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length));
15
- }
16
- return out;
17
- }
18
-
11
+ // UTF-8 safe XOR decrypt — mirrors bundler byte-level encoding
19
12
  function decrypt(b64, projectId, salt) {
20
13
  if (!b64) return "";
21
- return xorDecode(atob(b64), projectId + salt);
14
+ const key = projectId + salt;
15
+ const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
16
+ const keyBytes = new TextEncoder().encode(key);
17
+ const out = new Uint8Array(bytes.length);
18
+ for (let i = 0; i < bytes.length; i++) {
19
+ out[i] = bytes[i] ^ keyBytes[i % keyBytes.length];
20
+ }
21
+ return new TextDecoder().decode(out);
22
22
  }
23
23
 
24
24
  function openIDB() {
@@ -77,6 +77,38 @@
77
77
  return await res.text();
78
78
  }
79
79
 
80
+ // ─── Load external CDN assets ─────────────────────────────────────────────
81
+
82
+ function loadAsset(asset) {
83
+ return new Promise((resolve) => {
84
+ const existing = asset.type === "style"
85
+ ? document.querySelector(`link[href="${asset.url}"]`)
86
+ : document.querySelector(`script[src="${asset.url}"]`);
87
+ if (existing) return resolve();
88
+
89
+ if (asset.type === "style") {
90
+ const link = document.createElement("link");
91
+ link.rel = "stylesheet";
92
+ link.href = asset.url;
93
+ link.onload = resolve;
94
+ link.onerror = resolve;
95
+ document.head.appendChild(link);
96
+ } else {
97
+ const script = document.createElement("script");
98
+ script.src = asset.url;
99
+ if (asset.defer) script.defer = true;
100
+ if (asset.async) script.async = true;
101
+ script.onload = resolve;
102
+ script.onerror = resolve;
103
+ document.head.appendChild(script);
104
+ }
105
+ });
106
+ }
107
+
108
+ async function loadAssets(assets = []) {
109
+ for (const asset of assets) await loadAsset(asset);
110
+ }
111
+
80
112
  function injectComponent(encryptedPayload, projectId, targetSelector) {
81
113
  const target = document.querySelector(targetSelector || "[data-wh]");
82
114
  if (!target) { console.warn("[WebHanger] No mount target found."); return; }
@@ -104,7 +136,50 @@
104
136
  }
105
137
  }
106
138
 
107
- async function load(cdnUrl, projectId, token, expires, selector = "[data-wh]") {
139
+ // ─── Loading Signaler ─────────────────────────────────────────────────────
140
+ // Fires callbacks at each stage of the load lifecycle.
141
+ // Stages: "start" | "fetching" | "assets" | "injecting" | "done" | "error"
142
+
143
+ function createSignaler(onSignal) {
144
+ return function signal(stage, detail = {}) {
145
+ if (typeof onSignal === "function") {
146
+ onSignal({ stage, ...detail });
147
+ }
148
+ };
149
+ }
150
+
151
+ // ─── Built-in preloader ───────────────────────────────────────────────────
152
+
153
+ function createPreloader(selector) {
154
+ const target = document.querySelector(selector || "[data-wh]");
155
+ if (!target) return { show: () => {}, hide: () => {} };
156
+
157
+ const loader = document.createElement("div");
158
+ loader.setAttribute("data-wh-loader", "");
159
+ loader.style.cssText = `
160
+ display:flex; align-items:center; justify-content:center;
161
+ padding: 24px; width:100%; box-sizing:border-box;
162
+ `;
163
+ loader.innerHTML = `
164
+ <div style="
165
+ width:28px; height:28px;
166
+ border:3px solid #e5e7eb;
167
+ border-top-color:#6366f1;
168
+ border-radius:50%;
169
+ animation:wh-spin 0.7s linear infinite;
170
+ "></div>
171
+ <style>
172
+ @keyframes wh-spin { to { transform: rotate(360deg); } }
173
+ </style>
174
+ `;
175
+
176
+ return {
177
+ show() { target.appendChild(loader); },
178
+ hide() { if (loader.parentNode) loader.parentNode.removeChild(loader); }
179
+ };
180
+ }
181
+
182
+ async function load(cdnUrl, projectId, token, expires, selector = "[data-wh]", onSignal = null) {
108
183
  if (!cdnUrl || !projectId || !token || expires === undefined || expires === null) {
109
184
  console.error("[WebHanger] Missing required params: cdnUrl, projectId, token, expires");
110
185
  return;
@@ -113,26 +188,70 @@
113
188
  console.warn("[WebHanger] Token expired.");
114
189
  return;
115
190
  }
191
+
192
+ const signal = createSignaler(onSignal);
193
+ const preloader = createPreloader(selector);
194
+
195
+ signal("start", { cdnUrl, selector });
196
+ preloader.show();
197
+
116
198
  const cacheKey = `${cdnUrl}@${expires}`;
117
199
  try {
200
+ signal("fetching");
118
201
  let payload = await cacheGet(cacheKey);
119
202
  if (!payload) {
120
203
  payload = await fetchComponent(cdnUrl, token, expires);
121
204
  await cacheSet(cacheKey, payload);
122
205
  }
206
+
207
+ let parsed;
208
+ try { parsed = JSON.parse(payload); } catch (_) { parsed = {}; }
209
+
210
+ if (parsed.assets && parsed.assets.length) {
211
+ signal("assets", { count: parsed.assets.length, assets: parsed.assets });
212
+ await loadAssets(parsed.assets);
213
+ }
214
+
215
+ signal("injecting");
216
+ preloader.hide();
123
217
  injectComponent(payload, projectId, selector);
218
+ signal("done");
124
219
  } catch (err) {
220
+ preloader.hide();
221
+ signal("error", { message: err.message });
125
222
  console.error("[WebHanger] Load failed:", err.message);
126
223
  }
127
224
  }
128
225
 
129
226
  async function clearCache() {
227
+ // 1. Clear all wh_ keys from localStorage
130
228
  Object.keys(localStorage)
131
229
  .filter(k => k.startsWith(LS_PREFIX))
132
230
  .forEach(k => localStorage.removeItem(k));
133
- const db = await openIDB();
134
- const tx = db.transaction(IDB_STORE, "readwrite");
135
- tx.objectStore(IDB_STORE).clear();
231
+
232
+ // 2. Clear IndexedDB store
233
+ try {
234
+ const db = await openIDB();
235
+ const tx = db.transaction(IDB_STORE, "readwrite");
236
+ tx.objectStore(IDB_STORE).clear();
237
+ } catch (_) {}
238
+
239
+ // 3. Clear service worker caches
240
+ if ("caches" in window) {
241
+ const keys = await caches.keys();
242
+ await Promise.all(keys.map(k => caches.delete(k)));
243
+ }
244
+
245
+ // 4. Unregister service workers
246
+ if ("serviceWorker" in navigator) {
247
+ const regs = await navigator.serviceWorker.getRegistrations();
248
+ await Promise.all(regs.map(r => r.unregister()));
249
+ }
250
+
251
+ // 5. Clear sessionStorage wh_ keys
252
+ Object.keys(sessionStorage)
253
+ .filter(k => k.startsWith(LS_PREFIX))
254
+ .forEach(k => sessionStorage.removeItem(k));
136
255
  }
137
256
 
138
257
  global.WebHangerFront = { load, clearCache, version: VERSION };
package/index.js CHANGED
@@ -9,17 +9,17 @@ const SIZE_THRESHOLD = 50 * 1024;
9
9
 
10
10
  // ─── Decrypt ─────────────────────────────────────────────────────────────────
11
11
 
12
- function xorDecode(str, key) {
13
- let out = "";
14
- for (let i = 0; i < str.length; i++) {
15
- out += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length));
16
- }
17
- return out;
18
- }
19
-
12
+ // UTF-8 safe XOR decrypt
20
13
  function decrypt(b64, projectId, salt) {
21
14
  if (!b64) return "";
22
- return xorDecode(atob(b64), projectId + salt);
15
+ const key = projectId + salt;
16
+ const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
17
+ const keyBytes = new TextEncoder().encode(key);
18
+ const out = new Uint8Array(bytes.length);
19
+ for (let i = 0; i < bytes.length; i++) {
20
+ out[i] = bytes[i] ^ keyBytes[i % keyBytes.length];
21
+ }
22
+ return new TextDecoder().decode(out);
23
23
  }
24
24
 
25
25
  // ─── IndexedDB ────────────────────────────────────────────────────────────────
@@ -73,6 +73,38 @@ async function cacheSet(key, value) {
73
73
  await idbSet(key, value);
74
74
  }
75
75
 
76
+ // ─── Load external CDN assets ─────────────────────────────────────────────────
77
+
78
+ function loadAsset(asset) {
79
+ return new Promise((resolve) => {
80
+ const existing = asset.type === "style"
81
+ ? document.querySelector(`link[href="${asset.url}"]`)
82
+ : document.querySelector(`script[src="${asset.url}"]`);
83
+ if (existing) return resolve();
84
+
85
+ if (asset.type === "style") {
86
+ const link = document.createElement("link");
87
+ link.rel = "stylesheet";
88
+ link.href = asset.url;
89
+ link.onload = resolve;
90
+ link.onerror = resolve;
91
+ document.head.appendChild(link);
92
+ } else {
93
+ const script = document.createElement("script");
94
+ script.src = asset.url;
95
+ if (asset.defer) script.defer = true;
96
+ if (asset.async) script.async = true;
97
+ script.onload = resolve;
98
+ script.onerror = resolve;
99
+ document.head.appendChild(script);
100
+ }
101
+ });
102
+ }
103
+
104
+ async function loadAssets(assets = []) {
105
+ for (const asset of assets) await loadAsset(asset);
106
+ }
107
+
76
108
  // ─── Fetch ────────────────────────────────────────────────────────────────────
77
109
 
78
110
  async function fetchComponent(cdnUrl, token, expires) {
@@ -143,6 +175,12 @@ export async function load(cdnUrl, projectId, token, expires, selector = "[data-
143
175
  payload = await fetchComponent(cdnUrl, token, expires);
144
176
  await cacheSet(cacheKey, payload);
145
177
  }
178
+
179
+ try {
180
+ const parsed = JSON.parse(payload);
181
+ if (parsed.assets && parsed.assets.length) await loadAssets(parsed.assets);
182
+ } catch (_) {}
183
+
146
184
  injectComponent(payload, projectId, selector);
147
185
  } catch (err) {
148
186
  console.error("[WebHanger] Load failed:", err.message);
@@ -153,13 +191,34 @@ export async function load(cdnUrl, projectId, token, expires, selector = "[data-
153
191
  * Clear cached components from localStorage + IndexedDB.
154
192
  */
155
193
  export async function clearCache() {
194
+ // localStorage
156
195
  Object.keys(localStorage)
157
196
  .filter(k => k.startsWith(LS_PREFIX))
158
197
  .forEach(k => localStorage.removeItem(k));
159
198
 
160
- const db = await openIDB();
161
- const tx = db.transaction(IDB_STORE, "readwrite");
162
- tx.objectStore(IDB_STORE).clear();
199
+ // IndexedDB
200
+ try {
201
+ const db = await openIDB();
202
+ const tx = db.transaction(IDB_STORE, "readwrite");
203
+ tx.objectStore(IDB_STORE).clear();
204
+ } catch (_) {}
205
+
206
+ // Service worker caches
207
+ if ("caches" in window) {
208
+ const keys = await caches.keys();
209
+ await Promise.all(keys.map(k => caches.delete(k)));
210
+ }
211
+
212
+ // Unregister service workers
213
+ if ("serviceWorker" in navigator) {
214
+ const regs = await navigator.serviceWorker.getRegistrations();
215
+ await Promise.all(regs.map(r => r.unregister()));
216
+ }
217
+
218
+ // sessionStorage
219
+ Object.keys(sessionStorage)
220
+ .filter(k => k.startsWith(LS_PREFIX))
221
+ .forEach(k => sessionStorage.removeItem(k));
163
222
  }
164
223
 
165
224
  export const version = VERSION;
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "webhanger-front",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "WebHanger browser SDK — load encrypted UI components from CDN",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
7
7
  "browser": "browser.js",
8
8
  "type": "module",
9
- "keywords": ["webhanger", "cdn", "components", "frontend", "sdk"],
9
+ "keywords": [
10
+ "webhanger",
11
+ "cdn",
12
+ "components",
13
+ "frontend",
14
+ "sdk"
15
+ ],
10
16
  "license": "ISC"
11
17
  }