@haven-team/helix-sdk 1.0.12

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 (3) hide show
  1. package/README.md +27 -0
  2. package/helix-sdk.js +422 -0
  3. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # @haven-team/helix-sdk
2
+
3
+ Client-side SDK for Helix Apps embedded via iframe. Vanilla JS, zero
4
+ dependencies, UMD (script tag or `require`).
5
+
6
+ - Auth context from the Helix shell (`onReady`, `getContext`, `getToken`)
7
+ - Authenticated API helpers (`apiGet` / `apiPost` / `apiPatch` / `apiDelete`)
8
+ - Automatic URL + title sync with the shell (deep links, refresh restore)
9
+ - Opt-in LogRocket session replay with automatic Helix-user identify
10
+
11
+ Most apps load it from the shell and never install anything:
12
+
13
+ ```html
14
+ <script src="https://app.havenhelix.com/helix-sdk.js"></script>
15
+ <script>
16
+ const helix = new HelixSDK();
17
+ helix.onReady(async (ctx) => {
18
+ const stores = await helix.apiGet('/stores');
19
+ });
20
+ </script>
21
+ ```
22
+
23
+ The npm package exists so hosts (the Helix shell itself, apps that bundle)
24
+ can consume the SDK as a dependency instead of copying the file.
25
+
26
+ Canonical source and docs: [haven-team/helix-toolkit](https://github.com/haven-team/helix-toolkit)
27
+ (`sdk/helix-sdk.js`, `docs/`). Versioning follows the toolkit `VERSION` file.
package/helix-sdk.js ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Helix SDK — Client-side SDK for Helix Apps embedded via iframe.
3
+ *
4
+ * Usage:
5
+ * <script src="https://your-helix-toolkit/sdk/helix-sdk.js"></script>
6
+ * <script>
7
+ * const helix = new HelixSDK();
8
+ * helix.onReady((ctx) => {
9
+ * console.log('Authenticated as:', ctx.user);
10
+ * console.log('Token:', ctx.token);
11
+ * // Use ctx.token for API calls to Helix
12
+ * });
13
+ * </script>
14
+ *
15
+ * Or as ES module:
16
+ * import { HelixSDK } from '@haven-team/helix-sdk';
17
+ *
18
+ * URL + title sync (auto):
19
+ * Once the SDK is loaded inside a Helix iframe, it automatically mirrors the
20
+ * inner app's URL and document.title up to the Helix shell. The shell uses
21
+ * that to keep the browser address bar fragment and tab title in sync with
22
+ * what the user is actually looking at, so deep-linking, sharing, and
23
+ * refresh-restore all work. No per-app code needed — any framework router
24
+ * (React, Angular, Vue) that uses the History API is captured automatically.
25
+ * Pass `{ autoBroadcast: false }` to disable.
26
+ *
27
+ * LogRocket session replay (opt-in):
28
+ * const helix = new HelixSDK({ logrocket: true });
29
+ * // or with options:
30
+ * const helix = new HelixSDK({ logrocket: { appId: 'lto7os/my-app' } });
31
+ * // or enable later:
32
+ * const lr = await helix.enableLogRocket({ excludeEmails: ['ai@myhavenbot.com'] });
33
+ *
34
+ * The SDK injects the LogRocket script, initializes it, and identifies the
35
+ * session with the Helix user (email) once auth is ready — recordings show
36
+ * up tied to the acting user with no per-app code. Recording is skipped on
37
+ * localhost unless `force: true`. Failures never break the app; the SDK
38
+ * logs a warning and `enableLogRocket()` resolves to null. See
39
+ * docs/logrocket.md for the full manual.
40
+ */
41
+ (function (root, factory) {
42
+ if (typeof module !== 'undefined' && module.exports) {
43
+ module.exports = factory();
44
+ } else {
45
+ root.HelixSDK = factory().HelixSDK;
46
+ }
47
+ }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
48
+ 'use strict';
49
+
50
+ // Haven's existing LogRocket project (the Helix shell records here too).
51
+ // Apps with their own LogRocket project pass { logrocket: { appId } }.
52
+ const DEFAULT_LOGROCKET_APP_ID = 'lto7os/helix-frontend';
53
+ const LOGROCKET_CDN = 'https://cdn.lr-ingest.com/LogRocket.min.js';
54
+
55
+ class HelixSDK {
56
+ constructor(options = {}) {
57
+ this._ready = false;
58
+ this._readyCallbacks = [];
59
+ this._urlCallbacks = [];
60
+ this._context = null;
61
+ this._apiBase = options.apiBase || null;
62
+ this._instrumented = false;
63
+ this._suspendBroadcast = false;
64
+ this._lastBroadcast = null;
65
+ this._autoBroadcast = options.autoBroadcast !== false;
66
+ this._logrocket = null;
67
+ this._lrShouldRecord = true;
68
+ this._lrPromise = null;
69
+
70
+ if (options.logrocket) {
71
+ this.enableLogRocket(options.logrocket === true ? {} : options.logrocket);
72
+ }
73
+
74
+ // Parse auth + restore-path from URL params (injected by Helix iframe loader)
75
+ const params = new URLSearchParams(window.location.search);
76
+ const token = params.get('helix_token');
77
+ const user = params.get('helix_user');
78
+ const appSecret = params.get('helix_app_secret');
79
+ const apiBase = params.get('helix_api_base');
80
+ const initPath = params.get('helix_init_path');
81
+
82
+ if (apiBase) this._apiBase = apiBase;
83
+
84
+ const stripAuthParams = () => {
85
+ for (const k of [
86
+ 'helix_token', 'helix_user', 'helix_app_secret',
87
+ 'helix_api_base', 'helix_init_path'
88
+ ]) params.delete(k);
89
+ const clean = params.toString();
90
+ const path = window.location.pathname + (clean ? '?' + clean : '');
91
+ window.history.replaceState({}, '', path);
92
+ };
93
+
94
+ if (token && user) {
95
+ this._context = { token, user, appSecret };
96
+ this._ready = true;
97
+ stripAuthParams();
98
+ // If the shell told us where to land (deep link / refresh), apply it
99
+ // before the app's own router has a chance to read location.
100
+ if (initPath) this._applyRestoredPath(initPath);
101
+ this._instrumentNavigation();
102
+ this._instrumentTitle();
103
+ this._fireReady();
104
+ }
105
+
106
+ // Also listen for postMessage auth + shell-driven URL restore
107
+ window.addEventListener('message', (event) => {
108
+ const data = event.data;
109
+ if (!data || typeof data !== 'object') return;
110
+
111
+ if (data.type === 'helix-auth') {
112
+ this._context = {
113
+ token: data.token,
114
+ user: data.user,
115
+ appSecret: data.appSecret
116
+ };
117
+ this._ready = true;
118
+ this._instrumentNavigation();
119
+ this._instrumentTitle();
120
+ this._fireReady();
121
+ return;
122
+ }
123
+
124
+ if (data.type === 'helix-restore-url' && typeof data.path === 'string') {
125
+ this._applyRestoredPath(data.path);
126
+ }
127
+ });
128
+ }
129
+
130
+ /** Apply a path (path + search + hash) from the shell without re-broadcasting it back. */
131
+ _applyRestoredPath(target) {
132
+ try {
133
+ this._suspendBroadcast = true;
134
+ const url = new URL(target, window.location.origin);
135
+ const next = url.pathname + url.search + url.hash;
136
+ if (next !== window.location.pathname + window.location.search + window.location.hash) {
137
+ window.history.replaceState({}, '', next);
138
+ // Nudge framework routers (React/Vue/Angular) to pick up the change.
139
+ window.dispatchEvent(new PopStateEvent('popstate'));
140
+ }
141
+ } catch (e) {
142
+ // Bad input from shell — ignore rather than break the app.
143
+ } finally {
144
+ this._suspendBroadcast = false;
145
+ }
146
+ this._fireUrlCallbacks();
147
+ }
148
+
149
+ /** Monkey-patch History API + listen for popstate/hashchange so any router we sit
150
+ * on top of (React, Angular, Vue, plain anchors) is auto-mirrored to the shell. */
151
+ _instrumentNavigation() {
152
+ if (this._instrumented || window.parent === window || !this._autoBroadcast) return;
153
+ this._instrumented = true;
154
+
155
+ const sdk = this;
156
+ const origPush = window.history.pushState;
157
+ const origReplace = window.history.replaceState;
158
+ window.history.pushState = function patchedPush() {
159
+ const result = origPush.apply(this, arguments);
160
+ sdk._postUrl();
161
+ return result;
162
+ };
163
+ window.history.replaceState = function patchedReplace() {
164
+ const result = origReplace.apply(this, arguments);
165
+ sdk._postUrl();
166
+ return result;
167
+ };
168
+ window.addEventListener('popstate', () => this._postUrl());
169
+ window.addEventListener('hashchange', () => this._postUrl());
170
+
171
+ // Broadcast once on boot so the shell can mirror the current URL even if
172
+ // the app never navigates.
173
+ setTimeout(() => this._postUrl(), 0);
174
+ }
175
+
176
+ /** Observe <title> and broadcast changes so the browser tab reflects the inner view. */
177
+ _instrumentTitle() {
178
+ if (this._titleInstrumented || window.parent === window || !this._autoBroadcast) return;
179
+ this._titleInstrumented = true;
180
+ const post = () => this._postTitle();
181
+ post();
182
+ const titleEl = document.head && document.head.querySelector('title');
183
+ if (titleEl && typeof MutationObserver !== 'undefined') {
184
+ const obs = new MutationObserver(post);
185
+ obs.observe(titleEl, { childList: true, characterData: true, subtree: true });
186
+ }
187
+ // Some apps swap the <title> element entirely; watch <head> for that.
188
+ if (document.head && typeof MutationObserver !== 'undefined') {
189
+ const headObs = new MutationObserver(post);
190
+ headObs.observe(document.head, { childList: true });
191
+ }
192
+ }
193
+
194
+ _postUrl() {
195
+ if (this._suspendBroadcast) return;
196
+ if (window.parent === window) return;
197
+ const payload = {
198
+ type: 'helix-url',
199
+ path: window.location.pathname,
200
+ search: window.location.search,
201
+ hash: window.location.hash
202
+ };
203
+ const key = payload.path + payload.search + payload.hash;
204
+ if (key === this._lastBroadcast) return;
205
+ this._lastBroadcast = key;
206
+ try { window.parent.postMessage(payload, '*'); } catch (e) { /* ignore */ }
207
+ this._fireUrlCallbacks();
208
+ }
209
+
210
+ _postTitle() {
211
+ if (window.parent === window) return;
212
+ const title = document.title;
213
+ if (!title) return;
214
+ if (title === this._lastTitle) return;
215
+ this._lastTitle = title;
216
+ try { window.parent.postMessage({ type: 'helix-title', title }, '*'); } catch (e) { /* ignore */ }
217
+ }
218
+
219
+ /** Push an explicit URL state (path + optional search/hash). Useful for apps
220
+ * that don't use the History API (e.g. server-rendered or query-only state). */
221
+ setUrlState(target) {
222
+ if (typeof target !== 'string' || !target) return;
223
+ try {
224
+ const url = new URL(target, window.location.origin);
225
+ const next = url.pathname + url.search + url.hash;
226
+ window.history.replaceState({}, '', next);
227
+ this._postUrl();
228
+ } catch (e) { /* ignore */ }
229
+ }
230
+
231
+ /** Subscribe to URL changes (inner or shell-driven). */
232
+ onUrlState(callback) {
233
+ if (typeof callback === 'function') this._urlCallbacks.push(callback);
234
+ }
235
+
236
+ _fireUrlCallbacks() {
237
+ const state = {
238
+ path: window.location.pathname,
239
+ search: window.location.search,
240
+ hash: window.location.hash
241
+ };
242
+ for (const cb of this._urlCallbacks) {
243
+ try { cb(state); } catch (e) { console.error('HelixSDK: onUrlState callback error', e); }
244
+ }
245
+ }
246
+
247
+ /** Register a callback for when auth is ready. Fires immediately if already authenticated. */
248
+ onReady(callback) {
249
+ if (this._ready && this._context) {
250
+ callback(this._context);
251
+ } else {
252
+ this._readyCallbacks.push(callback);
253
+ }
254
+ }
255
+
256
+ /** Get the current auth context, or null if not authenticated. */
257
+ getContext() {
258
+ return this._context;
259
+ }
260
+
261
+ /** Check if the SDK has authenticated. */
262
+ isReady() {
263
+ return this._ready;
264
+ }
265
+
266
+ /** Get the Helix auth token for API calls. */
267
+ getToken() {
268
+ return this._context?.token || null;
269
+ }
270
+
271
+ /** Make an authenticated GET request to the Helix API. */
272
+ async apiGet(path) {
273
+ return this._apiFetch('GET', path);
274
+ }
275
+
276
+ /** Make an authenticated POST request to the Helix API. */
277
+ async apiPost(path, body) {
278
+ return this._apiFetch('POST', path, body);
279
+ }
280
+
281
+ /** Make an authenticated PATCH request to the Helix API. */
282
+ async apiPatch(path, body) {
283
+ return this._apiFetch('PATCH', path, body);
284
+ }
285
+
286
+ /** Make an authenticated DELETE request to the Helix API. */
287
+ async apiDelete(path) {
288
+ return this._apiFetch('DELETE', path);
289
+ }
290
+
291
+ /** Internal: make an authenticated fetch to the Helix API. */
292
+ async _apiFetch(method, path, body) {
293
+ if (!this._context) throw new Error('HelixSDK: not authenticated');
294
+ const base = this._apiBase || this._guessApiBase();
295
+ const url = base + (path.startsWith('/') ? path : '/' + path);
296
+ const opts = {
297
+ method,
298
+ headers: {
299
+ 'Authorization': 'Bearer ' + this._context.token,
300
+ 'Content-Type': 'application/json',
301
+ 'Accept': 'application/json'
302
+ }
303
+ };
304
+ if (body && method !== 'GET') {
305
+ opts.body = JSON.stringify(body);
306
+ }
307
+ const res = await fetch(url, opts);
308
+ if (!res.ok) {
309
+ const err = await res.json().catch(() => ({ error: res.statusText }));
310
+ throw new Error(err.error || 'API request failed: ' + res.status);
311
+ }
312
+ return res.json();
313
+ }
314
+
315
+ /** Guess the Helix API base URL from the parent window origin. */
316
+ _guessApiBase() {
317
+ // If we're in an iframe, the parent origin is the Helix app
318
+ if (window.parent !== window) {
319
+ try {
320
+ // Can't access parent.location in cross-origin, use document.referrer
321
+ const referrer = document.referrer;
322
+ if (referrer) {
323
+ const url = new URL(referrer);
324
+ return url.origin + '/api';
325
+ }
326
+ } catch (e) { /* ignore */ }
327
+ }
328
+ return '/api';
329
+ }
330
+
331
+ _fireReady() {
332
+ for (const cb of this._readyCallbacks) {
333
+ try { cb(this._context); } catch (e) { console.error('HelixSDK: onReady callback error', e); }
334
+ }
335
+ this._readyCallbacks = [];
336
+ }
337
+
338
+ /**
339
+ * Enable LogRocket session replay. Loads the LogRocket script (unless one
340
+ * is already on the page), initializes it, and identifies the session with
341
+ * the Helix user email once auth is ready.
342
+ *
343
+ * Options:
344
+ * appId — LogRocket project id (default: Haven's lto7os/helix-frontend)
345
+ * excludeEmails — array of emails whose sessions should not be recorded
346
+ * identify — false to skip the automatic LogRocket.identify call
347
+ * force — true to record even on localhost
348
+ *
349
+ * Resolves to the LogRocket object, or null if disabled/failed. Never
350
+ * rejects and never throws — session replay must not break the app.
351
+ */
352
+ enableLogRocket(options = {}) {
353
+ if (this._lrPromise) return this._lrPromise;
354
+ this._lrPromise = this._initLogRocket(options).catch((e) => {
355
+ console.warn('HelixSDK: LogRocket setup failed', e);
356
+ return null;
357
+ });
358
+ return this._lrPromise;
359
+ }
360
+
361
+ /** The LogRocket object once enableLogRocket has resolved, else null. */
362
+ getLogRocket() {
363
+ return this._logrocket;
364
+ }
365
+
366
+ async _initLogRocket(options) {
367
+ if (typeof document === 'undefined') return null;
368
+ const host = window.location && window.location.hostname;
369
+ const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0';
370
+ if (isLocal && !options.force) {
371
+ console.info('HelixSDK: LogRocket disabled on localhost (pass force: true to override)');
372
+ return null;
373
+ }
374
+
375
+ const excludeEmails = options.excludeEmails || [];
376
+ const lr = await this._loadLogRocketScript();
377
+ if (!lr) return null;
378
+
379
+ lr.init(options.appId || DEFAULT_LOGROCKET_APP_ID, {
380
+ shouldSendData: () => this._lrShouldRecord
381
+ });
382
+ this._logrocket = lr;
383
+
384
+ // Identify with the Helix user as soon as auth context exists. If the
385
+ // user is excluded, flip the shouldSendData gate instead of identifying.
386
+ if (options.identify !== false) {
387
+ this.onReady((ctx) => {
388
+ try {
389
+ const email = ctx && ctx.user;
390
+ if (email && excludeEmails.indexOf(email) !== -1) {
391
+ this._lrShouldRecord = false;
392
+ return;
393
+ }
394
+ if (email) lr.identify(email, { email, via: 'helix-sdk' });
395
+ } catch (e) {
396
+ console.warn('HelixSDK: LogRocket identify failed', e);
397
+ }
398
+ });
399
+ }
400
+ return lr;
401
+ }
402
+
403
+ _loadLogRocketScript() {
404
+ // Reuse an instance the page already loaded (e.g. app vendored its own).
405
+ if (window.LogRocket) return Promise.resolve(window.LogRocket);
406
+ return new Promise((resolve) => {
407
+ const script = document.createElement('script');
408
+ script.src = LOGROCKET_CDN;
409
+ script.async = true;
410
+ script.crossOrigin = 'anonymous';
411
+ script.onload = () => resolve(window.LogRocket || null);
412
+ script.onerror = () => {
413
+ console.warn('HelixSDK: failed to load LogRocket from ' + LOGROCKET_CDN);
414
+ resolve(null);
415
+ };
416
+ (document.head || document.body || document.documentElement).appendChild(script);
417
+ });
418
+ }
419
+ }
420
+
421
+ return { HelixSDK };
422
+ }));
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@haven-team/helix-sdk",
3
+ "version": "1.0.12",
4
+ "description": "Client-side SDK for Helix Apps embedded via iframe — auth context, API helpers, URL/title sync, LogRocket session replay.",
5
+ "license": "MIT",
6
+ "main": "helix-sdk.js",
7
+ "exports": {
8
+ ".": "./helix-sdk.js"
9
+ },
10
+ "files": [
11
+ "helix-sdk.js",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "node --test --test-concurrency=1 test/*.test.js"
16
+ },
17
+ "keywords": [
18
+ "helix",
19
+ "iframe",
20
+ "sdk"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/haven-team/helix-toolkit.git",
25
+ "directory": "sdk"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }