@joryio/web-sdk 1.3.0 → 1.3.1-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/src/core/inapp.ts CHANGED
@@ -96,7 +96,17 @@ export interface InAppCampaign {
96
96
  * verbatim on web. Server-rendered HTML is now the only thing sent.
97
97
  */
98
98
  content?:
99
- | { kind: 'html'; html: string; css: string; assets?: unknown[] }
99
+ | {
100
+ kind: 'html';
101
+ html: string;
102
+ css: string;
103
+ assets?: unknown[];
104
+ /**
105
+ * Widest the message may be, in CSS px. Sent for `modal` and `slideup`,
106
+ * already resolved by the server; see inAppMaxWidth for the fallback.
107
+ */
108
+ maxWidth?: number;
109
+ }
100
110
  | {
101
111
  kind: 'native';
102
112
  title?: string;
@@ -202,6 +212,39 @@ export function escapeStyleText(css: string): string {
202
212
  return typeof css === 'string' ? css.replace(/<\//g, '<\\/') : '';
203
213
  }
204
214
 
215
+ /**
216
+ * The width a content-sized HTML message gets when its payload carries no
217
+ * `maxWidth` (a server that predates the field). The same two numbers as the
218
+ * backend's IN_APP_DEFAULT_MAX_WIDTH and every other SDK; the server normally
219
+ * sends the resolved value, so this is only ever a fallback.
220
+ */
221
+ export const IN_APP_DEFAULT_MAX_WIDTH = { modal: 600, slideup: 400 } as const;
222
+
223
+ /** Space kept between a content-sized message and each side of the viewport, in px. */
224
+ export const IN_APP_VIEWPORT_MARGIN = 16;
225
+
226
+ /**
227
+ * The maxWidth for this campaign, or null when its type is not content-sized
228
+ * (banner and fullscreen span the page; custom is the host's to place) or its
229
+ * content is native (the card has its own width).
230
+ */
231
+ export function inAppMaxWidth(campaign: Pick<InAppCampaign, 'type' | 'content'>): number | null {
232
+ if (campaign.type !== 'modal' && campaign.type !== 'slideup') return null;
233
+ if (campaign.content?.kind !== 'html') return null;
234
+ const mw = campaign.content.maxWidth;
235
+ return typeof mw === 'number' && Number.isFinite(mw) && mw > 0 ? Math.floor(mw) : IN_APP_DEFAULT_MAX_WIDTH[campaign.type];
236
+ }
237
+
238
+ /**
239
+ * The width the message frame is given: min(maxWidth, viewport - margin on
240
+ * each side). Set BEFORE the content lays out, so the author's media queries
241
+ * see the width the message really has; only the height follows the content.
242
+ */
243
+ export function inAppFrameWidth(maxWidth: number, viewportWidth: number, margin: number = IN_APP_VIEWPORT_MARGIN): number {
244
+ const available = Math.max(0, Math.floor(viewportWidth) - 2 * margin);
245
+ return Math.max(0, Math.min(Math.floor(maxWidth), available));
246
+ }
247
+
205
248
  // Host-window key the in-app iframes call into (same-origin). Internal.
206
249
  const HOST_BRIDGE_KEY = '__joryioInApp';
207
250
 
@@ -379,12 +422,23 @@ export class InAppMessagingManager {
379
422
  private claimed = new Set<string>();
380
423
  // Per-campaign content iframe (so we can auto-size + clean it up).
381
424
  private iframes: Map<string, HTMLIFrameElement> = new Map();
425
+ /**
426
+ * maxWidth of each displayed content-sized (modal / slideup) HTML message.
427
+ * Their frame width is derived from it and the host viewport, and re-derived
428
+ * when the viewport changes - see sizeContentFrame.
429
+ */
430
+ private frameMaxWidths: Map<string, number> = new Map();
431
+ private viewportListener?: () => void;
432
+ private viewportTimer?: ReturnType<typeof setTimeout>;
382
433
 
383
434
  /**
384
- * Whether HTML in-app messages may run. Off by default, because an HTML
385
- * message executes author-supplied JavaScript on the host page - which is
386
- * exactly why it is rendered in a sandboxed iframe. Native messages are
387
- * unaffected: they are data rendered as DOM, with no interpreter.
435
+ * Whether HTML in-app messages are displayed. Off by default. The markup
436
+ * is kept as written by the server, and author scripts in it do NOT run:
437
+ * the frame's per-message nonce CSP (buildSrcdoc) lets only the SDK's own
438
+ * bridge bootstrap execute. But that frame can execute script at the host
439
+ * page's origin, so a gap in the CSP would be XSS - hence opt-in. Native
440
+ * messages are unaffected: they are data rendered as DOM, with no
441
+ * interpreter.
388
442
  */
389
443
  private allowHtmlJsInAppMessages = false;
390
444
 
@@ -579,10 +633,15 @@ export class InAppMessagingManager {
579
633
  this.trackImpression(campaignId, 'clicked').catch(() => {});
580
634
  return this.submitForm(campaignId, values);
581
635
  },
582
- // Size the content iframe from the message's reported dimensions. For
583
- // content-sized types (modal/slideup) both width + height track the content;
584
- // banner/fullscreen stay full-width (height tracks content for banner).
585
- setSize: (campaignId: string, width: number, height: number) => {
636
+ // Size the content iframe from the message's reported dimensions. Only
637
+ // the HEIGHT follows the content. The width is the host's decision:
638
+ // modal/slideup get min(maxWidth, viewport - margin) before the content
639
+ // lays out (sizeContentFrame), banner/fullscreen are 100%. Following the
640
+ // reported width is what made responsive messages impossible - the frame
641
+ // started narrow, the author's media queries fired, the content measured
642
+ // narrow, and the frame stayed narrow. `width` stays in the signature so
643
+ // the bridge every message calls is unchanged.
644
+ setSize: (campaignId: string, _width: number, height: number) => {
586
645
  // Guard on the iframe (the thing being sized), set synchronously in
587
646
  // display() — not displayedMessages, so a setSize on iframe-load is never
588
647
  // rejected by a timing gap.
@@ -590,9 +649,6 @@ export class InAppMessagingManager {
590
649
  if (!iframe || !Number.isFinite(height) || height <= 0) return;
591
650
  const type = this.displayedMessages.get(campaignId)?.campaign.type;
592
651
  iframe.style.height = type === 'fullscreen' ? '100%' : `${Math.ceil(height)}px`;
593
- if ((type === 'modal' || type === 'slideup') && Number.isFinite(width) && width > 0) {
594
- iframe.style.width = `${Math.ceil(width)}px`;
595
- }
596
652
  },
597
653
  };
598
654
  }
@@ -1203,8 +1259,9 @@ export class InAppMessagingManager {
1203
1259
 
1204
1260
  if (kind === 'html') {
1205
1261
  if (!renderedHtml.trim()) return 'its `content.html` is empty';
1206
- // A SKIP, not a failure: HTML runs author-supplied JavaScript on the host
1207
- // page, so an integration that has not opted in never creates the iframe.
1262
+ // A SKIP, not a failure: HTML is shown in a frame that can execute script
1263
+ // at the host page's origin (only the SDK bridge, under the nonce CSP),
1264
+ // so an integration that has not opted in never creates the iframe.
1208
1265
  if (!this.allowHtmlJsInAppMessages) {
1209
1266
  return 'it is an HTML message and allowHtmlJsInAppMessages is false';
1210
1267
  }
@@ -1459,11 +1516,14 @@ export class InAppMessagingManager {
1459
1516
  // Render the message HTML inside a SAME-ORIGIN SANDBOXED iframe. The sandbox
1460
1517
  // (allow-scripts allow-same-origin) is the isolation/encapsulation boundary
1461
1518
  // — message CSS can't leak to the host and host CSS can't bleed in — and it
1462
- // lets the message's own JS run AND reach `joryioBridge`. Same-origin is
1463
- // required so `joryioBridge.requestPushPermission()` keeps the click's
1519
+ // lets the SDK's bridge bootstrap run AND reach `joryioBridge`. Same-origin
1520
+ // is required so `joryioBridge.requestPushPermission()` keeps the click's
1464
1521
  // user-gesture activation (a cross-origin postMessage hop would lose it).
1465
- // Content is first-party (authored in-dashboard + sanitized server-side on
1466
- // save); the sandbox is defense-in-depth, not the only line.
1522
+ // Author scripts and on* handlers do NOT run: the server keeps the markup
1523
+ // as written (sanitizeInAppHtml drops only <base> and meta refresh), and
1524
+ // the srcdoc's nonce CSP (buildSrcdoc) is what stops them. The sandbox and
1525
+ // the navigation rules contain the rest; there is no server sanitiser in
1526
+ // front of this.
1467
1527
  const iframe = document.createElement('iframe');
1468
1528
  iframe.id = `joryio-iframe-${campaign.id}`;
1469
1529
  // ── Native content: build DOM directly, no iframe ───────────────────────
@@ -1649,9 +1709,9 @@ export class InAppMessagingManager {
1649
1709
  let isDismissing = false;
1650
1710
 
1651
1711
  if (campaign.content?.kind !== 'native') {
1652
- // HTML runs author-supplied JavaScript on this page - which is why the
1653
- // iframe exists at all. An integration that has not opted in never creates
1654
- // one. This is a SKIP, not a failure: native messages still display,
1712
+ // HTML is shown in an iframe that can execute script at this page's
1713
+ // origin (only the SDK bridge - the nonce CSP blocks author script). An
1714
+ // integration that has not opted in never creates one. This is a SKIP, not a failure: native messages still display,
1655
1715
  // because they are data rendered as DOM with no interpreter.
1656
1716
  if (!this.allowHtmlJsInAppMessages) {
1657
1717
  this.logger.warn(
@@ -1661,7 +1721,8 @@ export class InAppMessagingManager {
1661
1721
  return;
1662
1722
  }
1663
1723
 
1664
- // allow-scripts/allow-same-origin: run the message JS + reach the bridge.
1724
+ // allow-scripts/allow-same-origin: run the SDK bridge bootstrap (the
1725
+ // nonce CSP blocks every other script) + reach the host bridge.
1665
1726
  // allow-popups: a CTA may open a new tab. NOT allow-top-navigation: every
1666
1727
  // link and redirect goes through the bridge's `navigate`, which the HOST
1667
1728
  // page carries out (rule 57) - the message document itself may never
@@ -1688,12 +1749,22 @@ export class InAppMessagingManager {
1688
1749
  });
1689
1750
  iframe.setAttribute('title', campaign.name || 'Message');
1690
1751
  // Start at 0 height (avoids the ~150px default-iframe flash); the bootstrap
1691
- // reports real size on load via the bridge's setSize. Banner/fullscreen fill
1692
- // width; modal/slideup get their content width from setSize.
1752
+ // reports real height on load via the bridge's setSize. Banner/fullscreen
1753
+ // fill the width; modal/slideup get min(maxWidth, viewport - margin) NOW,
1754
+ // before the document loads, so its media queries see the real width.
1693
1755
  iframe.style.height = '0';
1694
1756
  if (campaign.type === 'banner' || campaign.type === 'fullscreen') {
1695
1757
  iframe.style.width = '100%';
1696
1758
  }
1759
+ const maxWidth = inAppMaxWidth(campaign);
1760
+ if (maxWidth !== null) {
1761
+ // The frame width already keeps the margin, so the container's own
1762
+ // caps (90% for modal, 400px for slide-up) would only clip it.
1763
+ container.style.maxWidth = 'none';
1764
+ this.frameMaxWidths.set(campaign.id, maxWidth);
1765
+ iframe.style.width = `${inAppFrameWidth(maxWidth, this.hostViewportWidth())}px`;
1766
+ this.watchViewport();
1767
+ }
1697
1768
  iframe.srcdoc = this.buildSrcdoc(
1698
1769
  campaign.id,
1699
1770
  renderedHtml,
@@ -1788,6 +1859,51 @@ export class InAppMessagingManager {
1788
1859
  this.logger.info(`Displayed campaign: ${campaign.name} (${campaign.id})`);
1789
1860
  }
1790
1861
 
1862
+ /** The host page's layout width: without the scrollbar where the browser says so. */
1863
+ private hostViewportWidth(): number {
1864
+ const doc = typeof document !== 'undefined' ? document.documentElement : null;
1865
+ const cw = doc && typeof doc.clientWidth === 'number' ? doc.clientWidth : 0;
1866
+ if (cw > 0) return cw;
1867
+ return typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : 0;
1868
+ }
1869
+
1870
+ /** Re-derive a content-sized frame's width from its maxWidth and the viewport. */
1871
+ private sizeContentFrame(campaignId: string): void {
1872
+ const iframe = this.iframes.get(campaignId);
1873
+ const maxWidth = this.frameMaxWidths.get(campaignId);
1874
+ if (!iframe || maxWidth === undefined) return;
1875
+ iframe.style.width = `${inAppFrameWidth(maxWidth, this.hostViewportWidth())}px`;
1876
+ }
1877
+
1878
+ /**
1879
+ * Follow the viewport while a content-sized message is open: rotate a phone
1880
+ * or narrow a window and the message is re-fitted (debounced). The content's
1881
+ * ResizeObserver then reports the height at the new width.
1882
+ */
1883
+ private watchViewport(): void {
1884
+ if (this.viewportListener || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
1885
+ this.viewportListener = () => {
1886
+ if (this.viewportTimer) clearTimeout(this.viewportTimer);
1887
+ this.viewportTimer = setTimeout(() => {
1888
+ this.viewportTimer = undefined;
1889
+ for (const id of this.frameMaxWidths.keys()) this.sizeContentFrame(id);
1890
+ }, 100);
1891
+ };
1892
+ window.addEventListener('resize', this.viewportListener);
1893
+ }
1894
+
1895
+ private unwatchViewportIfIdle(): void {
1896
+ if (this.frameMaxWidths.size > 0 || !this.viewportListener) return;
1897
+ if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
1898
+ window.removeEventListener('resize', this.viewportListener);
1899
+ }
1900
+ this.viewportListener = undefined;
1901
+ if (this.viewportTimer) {
1902
+ clearTimeout(this.viewportTimer);
1903
+ this.viewportTimer = undefined;
1904
+ }
1905
+ }
1906
+
1791
1907
  /**
1792
1908
  * Build the iframe srcdoc: the message HTML + its CSS + a bootstrap that
1793
1909
  * (a) exposes `window.joryioBridge` (proxying same-origin to the host bridge),
@@ -1800,8 +1916,9 @@ export class InAppMessagingManager {
1800
1916
  * event name, received `undefined`, and the host guard dropped it - a control
1801
1917
  * that looked like it worked and logged nothing. Verbs taking two arguments
1802
1918
  * (`navigate`) still cannot be driven declaratively.
1803
- * NOT sanitized here — the sandbox is the boundary and the HTML is first-party
1804
- * (dashboard-authored + sanitized server-side on save). campaignId + the host
1919
+ * NOT sanitized here, nor by the server beyond <base> and meta refresh: the
1920
+ * nonce CSP below is what keeps author script from running, and the sandbox
1921
+ * and navigation rules contain the document. campaignId + the host
1805
1922
  * key are injected as JSON literals (our own values).
1806
1923
  */
1807
1924
  private buildSrcdoc(
@@ -1812,13 +1929,19 @@ export class InAppMessagingManager {
1812
1929
  ): string {
1813
1930
  const cid = JSON.stringify(campaignId);
1814
1931
  const key = JSON.stringify(HOST_BRIDGE_KEY);
1815
- // Content-sized types shrink the body to its content so scrollWidth reflects
1816
- // the authored width (the host sets the iframe to it). Full-bleed types keep
1817
- // body at full width (the host forces the iframe to 100%, ignoring width).
1932
+ // Content-sized types: the HOST picks the width (min(maxWidth, viewport -
1933
+ // margin)) and only the height follows the content. The body used to be
1934
+ // inline-block so its width could be measured and the frame set to it -
1935
+ // which let the frame start narrow and stay narrow. Now the body is a
1936
+ // normal block at the frame's width; flow-root keeps the first and last
1937
+ // child's margins inside it, so its height is the content's height.
1938
+ // That height is measured from the BODY: the root's scrollHeight is never
1939
+ // less than the frame itself, so a message that got shorter (wider
1940
+ // viewport, a form message replaced by a line) could never shrink back.
1818
1941
  const contentSized = type === 'modal' || type === 'slideup';
1819
- const bodySizing = contentSized ? 'body{display:inline-block;max-width:100vw;}' : '';
1942
+ const bodySizing = contentSized ? 'body{display:flow-root;}' : '';
1820
1943
  const bootstrap = `(function(){
1821
- var CID=${cid},KEY=${key};
1944
+ var CID=${cid},KEY=${key},SIZED=${contentSized ? 'true' : 'false'};
1822
1945
  // Per-device visibility: tag the root with the REAL device class from the
1823
1946
  // HOST page viewport (the iframe is message-sized, so its own innerWidth is
1824
1947
  // useless here). The jr-vp-* → jr-hide-* rules in <style> then hide elements
@@ -1867,7 +1990,7 @@ export class InAppMessagingManager {
1867
1990
  var p=window.joryioBridge.submitForm(d);if(!p||typeof p.then!=='function'){msg('This form cannot be sent here.',false);f.removeAttribute('data-jry-busy');if(btn)btn.disabled=false;return}
1868
1991
  p.then(function(r){if(r&&r.ok){if(r.redirect){window.joryioBridge.navigate(r.redirect,'_top');return}var els2=f.querySelectorAll('input,select,textarea,button');for(var j=0;j<els2.length;j++){els2[j].disabled=true}msg(r.message||'Thank you.',true)}else{msg((r&&(r.message||(r.errors&&r.errors[0])))||'Something went wrong. Please try again.',false);f.removeAttribute('data-jry-busy');if(btn)btn.disabled=false}}).catch(function(){msg('Something went wrong. Please try again.',false);f.removeAttribute('data-jry-busy');if(btn)btn.disabled=false});
1869
1992
  },true);
1870
- function reportSize(){try{var b=document.body,de=document.documentElement;var w=Math.max(b?b.scrollWidth:0,de.scrollWidth);var h=Math.max(b?b.scrollHeight:0,de.scrollHeight);call('setSize',[w,h]);}catch(e){}}
1993
+ function reportSize(){try{var b=document.body,de=document.documentElement;var w=Math.max(b?b.scrollWidth:0,de.scrollWidth);var h=SIZED&&b?Math.ceil(Math.max(b.scrollHeight,b.getBoundingClientRect?b.getBoundingClientRect().height:0)):Math.max(b?b.scrollHeight:0,de.scrollHeight);call('setSize',[w,h]);}catch(e){}}
1871
1994
  if(document.readyState!=='loading')reportSize();else document.addEventListener('DOMContentLoaded',reportSize);
1872
1995
  window.addEventListener('load',reportSize);
1873
1996
  try{if(window.ResizeObserver&&document.body){new ResizeObserver(reportSize).observe(document.body);}}catch(e){}
@@ -1880,22 +2003,20 @@ export class InAppMessagingManager {
1880
2003
  /*
1881
2004
  * A per-document CSP, and the nonce is the point.
1882
2005
  *
1883
- * The server strips <script> and every on* handler before this HTML is
1884
- * sent, so no author JavaScript runs today. The residual risk is a
1885
- * SANITISER BYPASS - mXSS in HTML sanitisers is a recurring class - and
1886
- * because this iframe is `allow-scripts allow-same-origin`, injected script
1887
- * would run at the HOST PAGE'S ORIGIN. That is XSS on a customer's site,
1888
- * reachable from a compromised marketing account.
2006
+ * The server keeps the author's markup as written (sanitizeInAppHtml drops
2007
+ * only <base> and meta refresh), so author <script> blocks and on*
2008
+ * handlers ARE in this document. Because this iframe is `allow-scripts
2009
+ * allow-same-origin`, they would run at the HOST PAGE'S ORIGIN - XSS on a
2010
+ * customer's site, reachable from a compromised marketing account.
1889
2011
  *
1890
- * `script-src 'nonce-...'` closes that: only the bootstrap below carries
1891
- * the nonce, so anything that survives sanitisation still cannot execute.
1892
- * Two independent controls instead of one, which matters when the first is
1893
- * a parser fighting an adversary.
2012
+ * `script-src 'nonce-...'` is the control: only the bootstrap below carries
2013
+ * the nonce, so nothing the author wrote executes. There is no server
2014
+ * sanitiser in front of it; this CSP is the first line, not a backstop.
1894
2015
  *
1895
2016
  * The rest is least-privilege for content that is only ever a message:
1896
2017
  * default-src 'none' nothing is allowed unless named below
1897
- * style-src unsafe-inline the sanitiser ALLOWS style="" attributes, so
1898
- * this must stay permissive; styles cannot execute
2018
+ * style-src unsafe-inline author style="" attributes are kept, so this
2019
+ * must stay permissive; styles cannot execute
1899
2020
  * img/media/font https: data: no http subresources - the mixed-content
1900
2021
  * control Android enforced and iOS could not
1901
2022
  * upgrade-insecure-requests an author's http image is upgraded rather
@@ -1984,6 +2105,8 @@ export class InAppMessagingManager {
1984
2105
  container.remove(); // also removes the child content iframe
1985
2106
  }
1986
2107
  this.iframes.delete(campaignId);
2108
+ this.frameMaxWidths.delete(campaignId);
2109
+ this.unwatchViewportIfIdle();
1987
2110
  if (backdrop) {
1988
2111
  backdrop.remove();
1989
2112
  }
@@ -2343,6 +2466,8 @@ export class InAppMessagingManager {
2343
2466
  this.displayedMessages.clear();
2344
2467
  this.watchedAttributes.clear();
2345
2468
  this.iframes.clear();
2469
+ this.frameMaxWidths.clear();
2470
+ this.unwatchViewportIfIdle();
2346
2471
 
2347
2472
  this.logger.info('In-app messaging state cleared');
2348
2473
  }
@@ -2408,6 +2533,8 @@ export class InAppMessagingManager {
2408
2533
  this.impressionTimestamps.clear();
2409
2534
  this.watchedAttributes.clear();
2410
2535
  this.iframes.clear();
2536
+ this.frameMaxWidths.clear();
2537
+ this.unwatchViewportIfIdle();
2411
2538
 
2412
2539
  // Tear down the host bridge so a later new JoryioSDK() rebinds a fresh one
2413
2540
  // (installHostBridge overwrites, but clearing here avoids a dangling closure