@revenuecat/purchases-ui-js 4.8.5 → 4.8.7

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.
@@ -1,19 +1,16 @@
1
1
  <script lang="ts">
2
2
  import Stack from "../stack/Stack.svelte";
3
3
  import type { FooterProps } from "../../types/components/footer";
4
- import { STICKY_OVERLAY_Z_INDEX } from "../../utils/constants";
5
4
 
6
5
  const { stack }: FooterProps = $props();
7
6
  </script>
8
7
 
9
- <div style="z-index: {STICKY_OVERLAY_Z_INDEX};">
8
+ <div>
10
9
  <Stack {...stack} />
11
10
  </div>
12
11
 
13
12
  <style>
14
13
  div {
15
- position: sticky;
16
- bottom: 0;
17
14
  width: 100%;
18
15
 
19
16
  display: flex;
@@ -8,6 +8,7 @@
8
8
  Stack,
9
9
  Timeline,
10
10
  Video,
11
+ WebView,
11
12
  } from "../..";
12
13
  import ButtonNode from "../button/ButtonNode.svelte";
13
14
  import Countdown from "../countdown/Countdown.svelte";
@@ -64,6 +65,7 @@
64
65
  timeline: Timeline,
65
66
  video: Video,
66
67
  wallet_button: WalletButton,
68
+ web_view: WebView,
67
69
  } satisfies {
68
70
  [key in RenderableComponent["type"]]: SvelteComponent<
69
71
  Extract<RenderableComponent, { type: key }>
@@ -39,6 +39,11 @@
39
39
  DUELINGUE_PAYWALL,
40
40
  } from "./fixtures/express-purchase-button-paywall";
41
41
  import { CustomVariableValue } from "../../types/variables";
42
+ import {
43
+ paywallWithTransparentFooter,
44
+ paywallWithCenteredBodyAndFooter,
45
+ paywallWithHeaderAndFooter,
46
+ } from "./fixtures/transparent-footer-paywall";
42
47
 
43
48
  const { Story } = defineMeta({
44
49
  title: "Example/Paywall",
@@ -480,6 +485,28 @@
480
485
  }}
481
486
  />
482
487
 
488
+ <Story
489
+ name="Sticky Footer - transparent (overlaps content)"
490
+ args={{
491
+ paywallData: paywallWithTransparentFooter,
492
+ }}
493
+ />
494
+
495
+ <Story
496
+ name="Sticky Footer - centered body above footer"
497
+ decorators={[viewportDecorator(500, 500, 0)]}
498
+ args={{
499
+ paywallData: paywallWithCenteredBodyAndFooter,
500
+ }}
501
+ />
502
+
503
+ <Story
504
+ name="Sticky Footer - with header"
505
+ args={{
506
+ paywallData: paywallWithHeaderAndFooter,
507
+ }}
508
+ />
509
+
483
510
  <Story
484
511
  name="Header"
485
512
  args={{
@@ -352,6 +352,13 @@
352
352
  const pullContentUnderHeader = !!header && firstComponentIsFullWidthImage;
353
353
 
354
354
  let headerHeight = $state(0);
355
+ let footerHeight = $state(0);
356
+
357
+ const contentColumnStyle = $derived(
358
+ maxContentWidth
359
+ ? `max-width: ${maxContentWidth}; margin-inline: auto;`
360
+ : "",
361
+ );
355
362
  </script>
356
363
 
357
364
  <svelte:boundary onerror={onError}>
@@ -366,19 +373,16 @@
366
373
  <Header {...header} />
367
374
  </div>
368
375
  {/if}
369
- <div
370
- class="paywall-content"
371
- style={[
372
- maxContentWidth
373
- ? `max-width: ${maxContentWidth}; margin-inline: auto;`
374
- : "",
375
- ]
376
- .filter(Boolean)
377
- .join(" ")}
378
- >
376
+ <div class="paywall-content" style={contentColumnStyle}>
379
377
  <Stack
380
378
  {...stack}
381
379
  class="paywall-content-scroll"
380
+ padding={sticky_footer
381
+ ? {
382
+ ...stack.padding,
383
+ bottom: (stack.padding?.bottom ?? 0) + footerHeight,
384
+ }
385
+ : stack.padding}
382
386
  style={{
383
387
  height: "auto",
384
388
  ...(pullContentUnderHeader
@@ -386,10 +390,19 @@
386
390
  : {}),
387
391
  }}
388
392
  />
389
- {#if sticky_footer}
390
- <Footer {...sticky_footer} />
391
- {/if}
392
393
  </div>
394
+ {#if sticky_footer}
395
+ <!-- Overlaps the content so translucent footers show it; sticky rather than
396
+ absolute so it stays visible when an ancestor scrollport is shorter than
397
+ the paywall. -->
398
+ <div
399
+ class="footer-wrapper"
400
+ style="z-index: {STICKY_OVERLAY_Z_INDEX}; margin-top: -{footerHeight}px; {contentColumnStyle}"
401
+ bind:clientHeight={footerHeight}
402
+ >
403
+ <Footer {...sticky_footer} />
404
+ </div>
405
+ {/if}
393
406
  </div>
394
407
 
395
408
  {#if sheet}
@@ -422,6 +435,13 @@
422
435
  width: 100%;
423
436
  }
424
437
 
438
+ .footer-wrapper {
439
+ position: sticky;
440
+ bottom: 0;
441
+ width: 100%;
442
+ flex-shrink: 0;
443
+ }
444
+
425
445
  .paywall-content {
426
446
  z-index: 2;
427
447
  width: 100%;
@@ -0,0 +1,7 @@
1
+ import type { PaywallData } from "../../../types/paywall";
2
+ /** Translucent sticky footer over long scrollable content. */
3
+ export declare const paywallWithTransparentFooter: PaywallData;
4
+ /** Small body that must center in the space above the footer, not the whole screen. */
5
+ export declare const paywallWithCenteredBodyAndFooter: PaywallData;
6
+ /** Header and sticky footer together: the footer overlay must not affect the header. */
7
+ export declare const paywallWithHeaderAndFooter: PaywallData;
@@ -0,0 +1,59 @@
1
+ import { paywallWithFooter, paywallWithHeader } from "../../../stories/fixtures";
2
+ function footerStack(paywall) {
3
+ const footer = paywall.components_config.base.sticky_footer;
4
+ if (!footer) {
5
+ throw new Error("Fixture has no sticky footer");
6
+ }
7
+ return footer.stack;
8
+ }
9
+ function lastTextComponent(components) {
10
+ return components.findLast((component) => component.type === "text");
11
+ }
12
+ /** Duplicates the last text component so the content is tall enough to scroll. */
13
+ function appendScrollableContent(paywall, count) {
14
+ const components = paywall.components_config.base.stack.components;
15
+ const lastText = lastTextComponent(components);
16
+ if (!lastText) {
17
+ return;
18
+ }
19
+ for (let index = 0; index < count; index++) {
20
+ const copy = structuredClone(lastText);
21
+ copy.id = `scroll-filler-${index}`;
22
+ components.push(copy);
23
+ }
24
+ }
25
+ function setFooterBackground(paywall, hex) {
26
+ footerStack(paywall).background_color = {
27
+ light: { type: "hex", value: hex },
28
+ };
29
+ }
30
+ /** Translucent sticky footer over long scrollable content. */
31
+ export const paywallWithTransparentFooter = (() => {
32
+ const paywall = structuredClone(paywallWithFooter);
33
+ appendScrollableContent(paywall, 25);
34
+ setFooterBackground(paywall, "#057C5BAA");
35
+ return paywall;
36
+ })();
37
+ /** Small body that must center in the space above the footer, not the whole screen. */
38
+ export const paywallWithCenteredBodyAndFooter = (() => {
39
+ const paywall = structuredClone(paywallWithFooter);
40
+ const stack = paywall.components_config.base.stack;
41
+ const lastText = lastTextComponent(stack.components);
42
+ stack.components = lastText ? [lastText] : [];
43
+ stack.dimension.distribution = "center";
44
+ setFooterBackground(paywall, "#057C5BAA");
45
+ return paywall;
46
+ })();
47
+ /** Header and sticky footer together: the footer overlay must not affect the header. */
48
+ export const paywallWithHeaderAndFooter = (() => {
49
+ const paywall = structuredClone(paywallWithFooter);
50
+ appendScrollableContent(paywall, 15);
51
+ const headerDonor = structuredClone(paywallWithHeader);
52
+ paywall.components_config.base.header =
53
+ headerDonor.components_config.base.header;
54
+ paywall.components_localizations.en_US = {
55
+ ...paywall.components_localizations.en_US,
56
+ ...headerDonor.components_localizations.en_US,
57
+ };
58
+ return paywall;
59
+ })();
@@ -0,0 +1,105 @@
1
+ <script module lang="ts">
2
+ import WebView from "./WebView.svelte";
3
+ import { componentDecorator } from "../../stories/component-decorator";
4
+ import { defineMeta } from "@storybook/addon-svelte-csf";
5
+
6
+ const { Story } = defineMeta({
7
+ title: "Components/WebView",
8
+ component: WebView,
9
+ decorators: [componentDecorator()],
10
+ parameters: {
11
+ // The iframe loads a real external URL, so screenshots are not stable.
12
+ chromatic: { disableSnapshot: true },
13
+ },
14
+ args: {
15
+ type: "web_view",
16
+ id: "web-view",
17
+ name: "Web View",
18
+ protocol_version: 1,
19
+ // Live RC bundle on a content-hash subdomain (Paywalls team). If this 404s,
20
+ // re-point at a current upload or replace with a committed static fixture.
21
+ url: "https://zoro7mkmsiy3biuznkoabsdaabga3v6kmn3753jlt5dgszc55woa.components.revenuecat-static.com/index.html",
22
+ size: {
23
+ width: { type: "fill" },
24
+ height: { type: "fixed", value: 400 },
25
+ },
26
+ },
27
+ });
28
+ </script>
29
+
30
+ <Story name="Default" />
31
+
32
+ <Story
33
+ name="Fill (full bleed)"
34
+ args={{
35
+ size: {
36
+ width: { type: "fill" },
37
+ height: { type: "fill" },
38
+ },
39
+ }}
40
+ />
41
+
42
+ <Story
43
+ name="Fixed Size"
44
+ args={{
45
+ size: {
46
+ width: { type: "fixed", value: 320 },
47
+ height: { type: "fixed", value: 480 },
48
+ },
49
+ }}
50
+ />
51
+
52
+ <Story
53
+ name="Relative Size (50% width)"
54
+ args={{
55
+ size: {
56
+ width: { type: "relative", value: 0.5 },
57
+ height: { type: "fixed", value: 400 },
58
+ },
59
+ }}
60
+ />
61
+
62
+ <Story
63
+ name="Fit Height (placeholder min-height)"
64
+ args={{
65
+ size: {
66
+ width: { type: "fill" },
67
+ height: { type: "fit" },
68
+ },
69
+ }}
70
+ />
71
+
72
+ <Story
73
+ name="Fit Width (placeholder min-width)"
74
+ args={{
75
+ size: {
76
+ width: { type: "fit" },
77
+ height: { type: "fixed", value: 400 },
78
+ },
79
+ }}
80
+ />
81
+
82
+ <!--
83
+ An https URL pointing at a site that disallows framing (X-Frame-Options /
84
+ CSP frame-ancestors) renders a blank frame: the browser refuses to display it
85
+ and, being cross-origin, the failure does not fire `onerror`. This illustrates
86
+ the render-only limitation - reliable load-failure detection needs the
87
+ protocol_version postMessage handshake.
88
+ -->
89
+ <Story
90
+ name="Framing blocked (X-Frame-Options)"
91
+ args={{
92
+ url: "https://www.google.com",
93
+ }}
94
+ />
95
+
96
+ <!--
97
+ A rejected URL renders nothing (the empty canvas is expected). Any non-https
98
+ scheme is rejected by the https-only guard, not just http.
99
+ -->
100
+ <Story
101
+ name="Blocked URL (renders nothing)"
102
+ args={{
103
+ url: "http://insecure.example.com",
104
+ }}
105
+ />
@@ -0,0 +1,19 @@
1
+ import WebView from "./WebView.svelte";
2
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
3
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
4
+ $$bindings?: Bindings;
5
+ } & Exports;
6
+ (internal: unknown, props: {
7
+ $$events?: Events;
8
+ $$slots?: Slots;
9
+ }): Exports & {
10
+ $set?: any;
11
+ $on?: any;
12
+ };
13
+ z_$$bindings?: Bindings;
14
+ }
15
+ declare const WebView: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
16
+ [evt: string]: CustomEvent<any>;
17
+ }, {}, {}, string>;
18
+ type WebView = InstanceType<typeof WebView>;
19
+ export default WebView;
@@ -0,0 +1,203 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import { getOptionalPackageInfoContext } from "../../stores/packageInfo";
4
+ import { getPaywallContext } from "../../stores/paywall";
5
+ import { getOptionalVariablesContext } from "../../stores/variables";
6
+ import type { WebViewProps } from "../../types/components/web-view";
7
+ import { css, mapSize, px } from "../../utils/base-utils";
8
+ import { evaluateVisibilityConditions } from "../../utils/style-utils";
9
+ import { isSameOriginAsHost, sanitizeWebViewUrl } from "./web-view-utils";
10
+ import {
11
+ FIT_MESSAGE,
12
+ loadCreateHostBridge,
13
+ RESIZE_MESSAGE,
14
+ } from "./web-view-sdk";
15
+ import type { ResizePayload, WebViewHostBridge } from "./web-view-sdk";
16
+
17
+ // Placeholder for a `fit` axis until the content reports its size — a cross-origin
18
+ // iframe exposes no intrinsic size, so `fit` would otherwise collapse to nothing.
19
+ const FIT_FALLBACK_SIZE_PX = 300;
20
+
21
+ // Cap a content-reported dimension so a buggy/hostile bundle can't blow up layout.
22
+ const MAX_FIT_SIZE_PX = 10_000;
23
+
24
+ const clampFitSize = (value: number): number =>
25
+ Math.max(1, Math.min(MAX_FIT_SIZE_PX, Math.round(value)));
26
+
27
+ const isPositiveFinite = (value: unknown): value is number =>
28
+ typeof value === "number" && Number.isFinite(value) && value > 0;
29
+
30
+ const props: WebViewProps = $props();
31
+
32
+ const { selectedPackageId } = getPaywallContext();
33
+ const packageInfo = getOptionalPackageInfoContext();
34
+ const variables = getOptionalVariablesContext();
35
+
36
+ // Gate the origin guard on a post-mount flag (not `typeof window`) so SSR and the
37
+ // first client render match; otherwise a cross-origin URL mismatches on hydration.
38
+ let mounted = $state(false);
39
+ onMount(() => {
40
+ mounted = true;
41
+ });
42
+
43
+ const safeUrl = $derived(sanitizeWebViewUrl(props.url));
44
+
45
+ // Only grant `allow-same-origin` once we confirm the bundle is cross-origin: on a
46
+ // same-origin frame that combo could script the host. Best-effort on the initial
47
+ // URL only (post-redirect origins aren't observable); bundle URLs are restricted
48
+ // to the RC asset origin by the backend (khepri #21861).
49
+ const canEmbed = $derived(
50
+ mounted &&
51
+ safeUrl !== null &&
52
+ !isSameOriginAsHost(safeUrl, window.location.origin),
53
+ );
54
+
55
+ // Content-reported sizes for `fit` axes (set by the resize handler below).
56
+ let fitWidth = $state<number | undefined>(undefined);
57
+ let fitHeight = $state<number | undefined>(undefined);
58
+
59
+ const fitAxisFallback = (axis: typeof props.size.width): number =>
60
+ axis.type === "fit" && typeof axis.default === "number"
61
+ ? axis.default
62
+ : FIT_FALLBACK_SIZE_PX;
63
+
64
+ const style = $derived.by(() => {
65
+ const { width, height } = props.size;
66
+ const widthFit = width.type === "fit";
67
+ const heightFit = height.type === "fit";
68
+ // Pin a `fit` axis with an explicit size. `flex-shrink: 0` keeps flex parents
69
+ // from collapsing the box; avoid mirroring the size in `min-*` or it beats `max-*`.
70
+ // Prefer author-declared `default` when present; else the hard 300px fallback.
71
+ const fitWidthPx = widthFit
72
+ ? clampFitSize(fitWidth ?? fitAxisFallback(width))
73
+ : null;
74
+ const fitHeightPx = heightFit
75
+ ? clampFitSize(fitHeight ?? fitAxisFallback(height))
76
+ : null;
77
+ return css({
78
+ width: fitWidthPx != null ? px(fitWidthPx) : mapSize(width),
79
+ height: fitHeightPx != null ? px(fitHeightPx) : mapSize(height),
80
+ // Cap a fit axis to the parent. Do not mirror the reported size in `min-*`:
81
+ // that defeats `max-*` and clips content when the report exceeds the viewport.
82
+ ...(fitWidthPx != null ? { "max-width": "100%" } : {}),
83
+ ...(fitHeightPx != null ? { "max-height": "100%" } : {}),
84
+ "flex-shrink": width.type === "fixed" || widthFit || heightFit ? 0 : 1,
85
+ "line-height": 0,
86
+ // No transition: the iframe is height:100% of this box, so animating the
87
+ // resize would flash the iframe's own scrollbar mid-change.
88
+ });
89
+ });
90
+
91
+ const isVisible = $derived(
92
+ evaluateVisibilityConditions(
93
+ {
94
+ selectedPackageId: $selectedPackageId,
95
+ packageInfo: $packageInfo,
96
+ variables: $variables ?? {},
97
+ },
98
+ undefined,
99
+ props.visible,
100
+ ),
101
+ );
102
+
103
+ // Host<->content bridge (client-only): the host build is loaded from the CDN by
104
+ // protocol version and attached to the live iframe. The content reports its size
105
+ // (`resize`), driving `fit` axes; a CDN import failure degrades to the plain iframe.
106
+ let iframeEl = $state<HTMLIFrameElement | undefined>(undefined);
107
+ let previousSafeUrl: string | null | undefined;
108
+
109
+ $effect(() => {
110
+ const url = safeUrl;
111
+ if (url !== previousSafeUrl) {
112
+ fitWidth = undefined;
113
+ fitHeight = undefined;
114
+ previousSafeUrl = url;
115
+ }
116
+
117
+ const iframe = iframeEl;
118
+ // Read every prop the bridge is configured with synchronously: Svelte only
119
+ // tracks reads in the effect body, not inside the async `.then` below, so
120
+ // capturing them here re-runs the effect (rebuilding the bridge) when the
121
+ // component id or `fit` axes change in place.
122
+ const componentId = props.id;
123
+ const protocolVersion = props.protocol_version;
124
+ const widthFit = props.size.width.type === "fit";
125
+ const heightFit = props.size.height.type === "fit";
126
+ if (!iframe || !url) return;
127
+
128
+ let disposed = false;
129
+ let bridge: WebViewHostBridge | undefined;
130
+
131
+ loadCreateHostBridge(protocolVersion)
132
+ .then((createHostBridge) => {
133
+ if (disposed) return;
134
+ try {
135
+ bridge = createHostBridge({
136
+ iframe,
137
+ allowedOrigin: new URL(url).origin,
138
+ componentId,
139
+ protocolVersion,
140
+ });
141
+ // Tell the content which axes are `fit` so it hides its own scrollbar there
142
+ // (it would otherwise flash one while we catch up to a new size).
143
+ if (widthFit || heightFit) {
144
+ bridge.onReady(() =>
145
+ bridge?.send(FIT_MESSAGE, { width: widthFit, height: heightFit }),
146
+ );
147
+ }
148
+ bridge.on(RESIZE_MESSAGE, (payload: unknown) => {
149
+ const { width, height } = (payload ?? {}) as ResizePayload;
150
+ if (widthFit && isPositiveFinite(width)) {
151
+ fitWidth = width;
152
+ }
153
+ if (heightFit && isPositiveFinite(height)) {
154
+ fitHeight = height;
155
+ }
156
+ });
157
+ } catch (error) {
158
+ console.warn("web_view: failed to wire host bridge", error);
159
+ }
160
+ })
161
+ .catch(() => {
162
+ // Degrade to the plain iframe if the CDN import fails (e.g. CSP).
163
+ });
164
+
165
+ return () => {
166
+ disposed = true;
167
+ bridge?.destroy();
168
+ };
169
+ });
170
+ </script>
171
+
172
+ {#if isVisible && safeUrl && canEmbed}
173
+ <div class="web-view" {style}>
174
+ <!--
175
+ `allow-scripts allow-same-origin` runs the bundle under its own origin (for
176
+ same-origin fetch/XHR); restricting it to that origin relies on the bundle's
177
+ CSP. `referrerpolicy`/`allow` and the omitted sandbox flags limit leakage and
178
+ block top navigation.
179
+ -->
180
+ <iframe
181
+ bind:this={iframeEl}
182
+ title={props.name ?? "Web view"}
183
+ src={safeUrl}
184
+ sandbox="allow-scripts allow-same-origin"
185
+ referrerpolicy="no-referrer"
186
+ allow=""
187
+ ></iframe>
188
+ </div>
189
+ {/if}
190
+
191
+ <style>
192
+ .web-view {
193
+ position: relative;
194
+ overflow: hidden;
195
+ }
196
+
197
+ iframe {
198
+ display: block;
199
+ width: 100%;
200
+ height: 100%;
201
+ border: 0;
202
+ }
203
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { WebViewProps } from "../../types/components/web-view";
2
+ declare const WebView: import("svelte").Component<WebViewProps, {}, "">;
3
+ type WebView = ReturnType<typeof WebView>;
4
+ export default WebView;
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import { setPackageInfoContext } from "../../stores/packageInfo";
3
+ import { setPaywallContext } from "../../stores/paywall";
4
+ import { setVariablesContext } from "../../stores/variables";
5
+ import type { WebViewProps } from "../../types/components/web-view";
6
+ import { readable, writable } from "svelte/store";
7
+ import WebView from "./WebView.svelte";
8
+
9
+ const props: WebViewProps = $props();
10
+
11
+ setPaywallContext({
12
+ defaultPackageId: undefined,
13
+ selectedPackageId: writable(undefined),
14
+ variablesPerPackage: readable(undefined),
15
+ baseVariables: readable(undefined),
16
+ infoPerPackage: readable(undefined),
17
+ onPurchase: () => {},
18
+ emitComponentInteraction: () => {},
19
+ onButtonAction: () => {},
20
+ uiConfig: {} as never,
21
+ hideBackButtons: false,
22
+ });
23
+ setPackageInfoContext(readable(undefined));
24
+ setVariablesContext(readable({}));
25
+ </script>
26
+
27
+ <WebView {...props} />
@@ -0,0 +1,4 @@
1
+ import type { WebViewProps } from "../../types/components/web-view";
2
+ declare const WebViewTestWrapper: import("svelte").Component<WebViewProps, {}, "">;
3
+ type WebViewTestWrapper = ReturnType<typeof WebViewTestWrapper>;
4
+ export default WebViewTestWrapper;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Runtime loader for the RevenueCat web-components HOST bridge.
3
+ *
4
+ * The host bridge is deliberately NOT bundled into purchases-ui-js. It is
5
+ * dynamic-imported from the CDN by protocol major, so a `web_view` with
6
+ * `protocol_version: N` always gets the host build that matches the content build
7
+ * injected into its iframe. Both are served from a stable, rolling per-major path:
8
+ *
9
+ * https://sdk.revenuecat-static.com/v<major>/rc-host.js (ESM, exports createHostBridge)
10
+ *
11
+ * Shipping a new SDK patch just republishes that path, so neither this code nor any
12
+ * stored bundle HTML needs to change. Embedding apps must allow the SDK origin in
13
+ * their CSP `script-src` for the module import to succeed; if it fails, the caller
14
+ * keeps the plain (render-only) iframe.
15
+ *
16
+ * Message names, payload types, and the bridge/config shapes come from
17
+ * `@revenuecat/workflow-web-components-sdk` (the source of truth) rather than being
18
+ * mirrored here; only the CDN loader is local.
19
+ *
20
+ * Operational note: compile-time types come from the pinned npm package, while the
21
+ * runtime host build is loaded from the rolling per-major CDN path (`/v<major>/rc-host.js`).
22
+ * Dynamic `import()` cannot use SRI; a failed import is handled by the caller.
23
+ */
24
+ import type { Bridge, HostBridgeConfig } from "@revenuecat/workflow-web-components-sdk";
25
+ export { FIT_MESSAGE, RESIZE_MESSAGE, } from "@revenuecat/workflow-web-components-sdk";
26
+ export type { ResizePayload } from "@revenuecat/workflow-web-components-sdk";
27
+ /** Base origin the versioned SDK builds are served from. */
28
+ export declare const SDK_BASE_URL = "https://sdk.revenuecat-static.com";
29
+ /**
30
+ * The subset of the SDK bridge purchases-ui-js uses: subscribe to a message, run
31
+ * a callback once the channel opens, fire-and-forget, and tear down.
32
+ */
33
+ export type WebViewHostBridge = Pick<Bridge, "on" | "onReady" | "send" | "destroy">;
34
+ export type CreateHostBridge = (config: HostBridgeConfig) => Bridge;
35
+ /** Stable, rolling per-major URL of the host bridge ESM build. */
36
+ export declare function hostSdkUrl(protocolVersion: number): string;
37
+ type ModuleImporter = (url: string) => Promise<unknown>;
38
+ /**
39
+ * Resolve `createHostBridge` for a protocol major from the CDN. Rejects if the
40
+ * module can't be loaded or doesn't export the factory, so callers can degrade to
41
+ * a render-only iframe.
42
+ */
43
+ export declare function loadCreateHostBridge(protocolVersion: number): Promise<CreateHostBridge>;
44
+ /**
45
+ * Override the module importer (and clear the cache). Intended for tests, which
46
+ * inject a fake so no real network/module resolution happens. Pass `null` to
47
+ * restore the default dynamic import.
48
+ */
49
+ export declare function __setHostModuleImporterForTests(fn: ModuleImporter | null): void;
@@ -0,0 +1,61 @@
1
+ export { FIT_MESSAGE, RESIZE_MESSAGE, } from "@revenuecat/workflow-web-components-sdk";
2
+ /** Base origin the versioned SDK builds are served from. */
3
+ export const SDK_BASE_URL = "https://sdk.revenuecat-static.com";
4
+ /** Stable, rolling per-major URL of the host bridge ESM build. */
5
+ export function hostSdkUrl(protocolVersion) {
6
+ return `${SDK_BASE_URL}/v${protocolVersion}/rc-host.js`;
7
+ }
8
+ // The bare dynamic import. `@vite-ignore` / `webpackIgnore` keep bundlers from
9
+ // resolving the absolute CDN URL at build time; the browser fetches it at runtime.
10
+ const defaultImporter = (url) => import(/* @vite-ignore */ /* webpackIgnore: true */ url);
11
+ let importer = defaultImporter;
12
+ // Memoize per protocol major: the module is stable for the page's lifetime and
13
+ // multiple web views sharing a `protocol_version` should import it once.
14
+ const moduleCache = new Map();
15
+ function assertValidProtocolVersion(protocolVersion) {
16
+ if (!Number.isInteger(protocolVersion) || protocolVersion < 1) {
17
+ throw new Error(`invalid web_view protocol_version: ${protocolVersion}`);
18
+ }
19
+ }
20
+ function importHostModule(protocolVersion) {
21
+ const cached = moduleCache.get(protocolVersion);
22
+ if (cached)
23
+ return cached;
24
+ const url = hostSdkUrl(protocolVersion);
25
+ const promise = importer(url).then((module) => {
26
+ const createHostBridge = module
27
+ .createHostBridge;
28
+ if (typeof createHostBridge !== "function") {
29
+ throw new Error(`rc-host.js at ${url} does not export createHostBridge`);
30
+ }
31
+ return { createHostBridge };
32
+ });
33
+ // Don't cache a rejected import so a later mount can retry (e.g. after a
34
+ // transient network failure).
35
+ promise.catch(() => {
36
+ if (moduleCache.get(protocolVersion) === promise) {
37
+ moduleCache.delete(protocolVersion);
38
+ }
39
+ });
40
+ moduleCache.set(protocolVersion, promise);
41
+ return promise;
42
+ }
43
+ /**
44
+ * Resolve `createHostBridge` for a protocol major from the CDN. Rejects if the
45
+ * module can't be loaded or doesn't export the factory, so callers can degrade to
46
+ * a render-only iframe.
47
+ */
48
+ export async function loadCreateHostBridge(protocolVersion) {
49
+ assertValidProtocolVersion(protocolVersion);
50
+ const { createHostBridge } = await importHostModule(protocolVersion);
51
+ return createHostBridge;
52
+ }
53
+ /**
54
+ * Override the module importer (and clear the cache). Intended for tests, which
55
+ * inject a fake so no real network/module resolution happens. Pass `null` to
56
+ * restore the default dynamic import.
57
+ */
58
+ export function __setHostModuleImporterForTests(fn) {
59
+ importer = fn ?? defaultImporter;
60
+ moduleCache.clear();
61
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Returns a sanitized URL if it is a well-formed absolute `https:` URL,
3
+ * otherwise `null`.
4
+ *
5
+ * The web view embeds remote, third-party bundles, so only `https` is allowed:
6
+ * `http`, `data:`, `blob:`, `javascript:`, protocol-relative (`//host`) and
7
+ * relative URLs are all rejected, as are URLs carrying embedded credentials
8
+ * (`https://user:pass@host`). Whitespace and control characters (code points
9
+ * <= 0x20) are stripped before validation so obfuscated payloads such as
10
+ * "https\t://" or "jAvAsCrIpT:" cannot slip through. The normalized, parsed
11
+ * `href` is returned (not the raw input) so no control characters reach the
12
+ * iframe `src`.
13
+ */
14
+ export declare function sanitizeWebViewUrl(url: string): string | null;
15
+ /**
16
+ * Returns whether `url` resolves to the same origin as `hostOrigin` (typically
17
+ * `window.location.origin`). Used as a guard before applying the
18
+ * `allow-scripts allow-same-origin` sandbox: that combination is only safe when
19
+ * the framed bundle is on a different origin than the host page, otherwise it
20
+ * could script and un-sandbox the host. Callers should pass an already
21
+ * sanitized URL; an unparseable URL conservatively reports `true` (treat as
22
+ * same-origin -> block).
23
+ */
24
+ export declare function isSameOriginAsHost(url: string, hostOrigin: string): boolean;
@@ -0,0 +1,52 @@
1
+ const SAFE_WEB_VIEW_PROTOCOL = "https:";
2
+ /**
3
+ * Returns a sanitized URL if it is a well-formed absolute `https:` URL,
4
+ * otherwise `null`.
5
+ *
6
+ * The web view embeds remote, third-party bundles, so only `https` is allowed:
7
+ * `http`, `data:`, `blob:`, `javascript:`, protocol-relative (`//host`) and
8
+ * relative URLs are all rejected, as are URLs carrying embedded credentials
9
+ * (`https://user:pass@host`). Whitespace and control characters (code points
10
+ * <= 0x20) are stripped before validation so obfuscated payloads such as
11
+ * "https\t://" or "jAvAsCrIpT:" cannot slip through. The normalized, parsed
12
+ * `href` is returned (not the raw input) so no control characters reach the
13
+ * iframe `src`.
14
+ */
15
+ export function sanitizeWebViewUrl(url) {
16
+ const normalized = Array.from(url)
17
+ .filter((character) => character.charCodeAt(0) > 0x20)
18
+ .join("");
19
+ let parsed;
20
+ try {
21
+ parsed = new URL(normalized);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ if (parsed.protocol.toLowerCase() !== SAFE_WEB_VIEW_PROTOCOL) {
27
+ return null;
28
+ }
29
+ // Reject embedded credentials: they have no place in an asset URL and can be
30
+ // used to obscure the real host (e.g. `https://trusted.com@evil.com`).
31
+ if (parsed.username !== "" || parsed.password !== "") {
32
+ return null;
33
+ }
34
+ return parsed.href;
35
+ }
36
+ /**
37
+ * Returns whether `url` resolves to the same origin as `hostOrigin` (typically
38
+ * `window.location.origin`). Used as a guard before applying the
39
+ * `allow-scripts allow-same-origin` sandbox: that combination is only safe when
40
+ * the framed bundle is on a different origin than the host page, otherwise it
41
+ * could script and un-sandbox the host. Callers should pass an already
42
+ * sanitized URL; an unparseable URL conservatively reports `true` (treat as
43
+ * same-origin -> block).
44
+ */
45
+ export function isSameOriginAsHost(url, hostOrigin) {
46
+ try {
47
+ return new URL(url).origin === hostOrigin;
48
+ }
49
+ catch {
50
+ return true;
51
+ }
52
+ }
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { default as Timeline } from "./components/timeline/Timeline.svelte";
15
15
  export { default as Screen } from "./components/workflows/Screen.svelte";
16
16
  export { default as Workflow } from "./components/workflows/Workflow.svelte";
17
17
  export { default as Video } from "./components/video/Video.svelte";
18
+ export { default as WebView } from "./components/web-view/WebView.svelte";
18
19
  export * from "./types";
19
20
  export { type PaywallData } from "./types/paywall";
20
21
  export { type WorkflowNavData, type WorkflowStepChangeEvent, workflowDataToNavData, } from "./types/workflow-nav";
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export { default as Timeline } from "./components/timeline/Timeline.svelte";
16
16
  export { default as Screen } from "./components/workflows/Screen.svelte";
17
17
  export { default as Workflow } from "./components/workflows/Workflow.svelte";
18
18
  export { default as Video } from "./components/video/Video.svelte";
19
+ export { default as WebView } from "./components/web-view/WebView.svelte";
19
20
  export * from "./types";
20
21
  export {} from "./types/paywall";
21
22
  export { workflowDataToNavData, } from "./types/workflow-nav";
@@ -16,7 +16,8 @@ import type { TextNodeProps } from "./components/text";
16
16
  import type { TimelineProps } from "./components/timeline";
17
17
  import type { VideoProps } from "./components/video";
18
18
  import type { WalletButtonProps } from "./components/wallet-button";
19
+ import type { WebViewProps } from "./components/web-view";
19
20
  import type { SkeletonLoaderProps } from "./components/skeleton-loader-props";
20
21
  import type { ExpressPurchaseButtonProps } from "./components/express-purchase-button-props";
21
22
  import type { HeaderProps } from "./components/header";
22
- export type Component = ButtonProps | CarouselProps | CountdownProps | ExpressPurchaseButtonProps | FallbackHeaderProps | FooterProps | HeaderProps | IconProps | ImageProps | InputMultipleChoiceProps | InputOptionProps | InputSingleChoiceProps | InputTextProps | PackageProps | PurchaseButtonProps | RedemptionButtonProps | SkeletonLoaderProps | StackProps | TabControlButtonProps | TabControlToggleProps | TabControlProps | TabProps | TabsProps | TextNodeProps | TimelineProps | WalletButtonProps | VideoProps;
23
+ export type Component = ButtonProps | CarouselProps | CountdownProps | ExpressPurchaseButtonProps | FallbackHeaderProps | FooterProps | HeaderProps | IconProps | ImageProps | InputMultipleChoiceProps | InputOptionProps | InputSingleChoiceProps | InputTextProps | PackageProps | PurchaseButtonProps | RedemptionButtonProps | SkeletonLoaderProps | StackProps | TabControlButtonProps | TabControlToggleProps | TabControlProps | TabProps | TabsProps | TextNodeProps | TimelineProps | WalletButtonProps | VideoProps | WebViewProps;
@@ -0,0 +1,15 @@
1
+ import type { SizeType } from "..";
2
+ import type { BaseComponent } from "../base";
3
+ export interface WebViewProps extends BaseComponent {
4
+ type: "web_view";
5
+ visible?: boolean | null;
6
+ /**
7
+ * Version of the host <-> web-component postMessage bridge protocol. Only `1`
8
+ * exists today. Selects the CDN host-bridge build loaded at runtime (see
9
+ * `web-view-sdk.ts`).
10
+ */
11
+ protocol_version: 1;
12
+ /** External, `https`-only asset URL the iframe is pointed at. */
13
+ url: string;
14
+ size: SizeType;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -48,11 +48,17 @@ type RelativeSize = {
48
48
  type: "relative";
49
49
  value: number;
50
50
  };
51
- type FitFillSize = {
52
- type: "fit" | "fill";
51
+ type FitSize = {
52
+ type: "fit";
53
53
  value?: null;
54
+ /** Placeholder px until the content reports its measured size. */
55
+ default?: number;
54
56
  };
55
- export type Size = FixedSize | RelativeSize | FitFillSize;
57
+ type FillSize = {
58
+ type: "fill";
59
+ value?: null;
60
+ };
61
+ export type Size = FixedSize | RelativeSize | FitSize | FillSize;
56
62
  export type SizeType = {
57
63
  width: Size;
58
64
  height: Size;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@revenuecat/purchases-ui-js",
3
3
  "description": "Web components for Paywalls. Powered by RevenueCat",
4
4
  "private": false,
5
- "version": "4.8.5",
5
+ "version": "4.8.7",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },
@@ -76,6 +76,7 @@
76
76
  },
77
77
  "packageManager": "npm@11.7.0+sha512.c22099a6fff8d5b2286c2a09df5352b4858a7c0c716320f58989d60ad8b29ecf2ce6fdfe97ccb41c23ffb1272e1fa079f868487dd6b81d02a2a9e199c095a117",
78
78
  "dependencies": {
79
+ "@revenuecat/workflow-web-components-sdk": "^0.1.4",
79
80
  "qrcode": "^1.5.4"
80
81
  },
81
82
  "peerDependencies": {