@revenuecat/purchases-ui-js 4.8.4 → 4.8.6
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/dist/components/package/Package.svelte +3 -0
- package/dist/components/paywall/Node.svelte +2 -0
- package/dist/components/purchase-button/PurchaseButton.svelte +7 -1
- package/dist/components/stack/Stack.svelte +15 -1
- package/dist/components/web-view/WebView.stories.svelte +105 -0
- package/dist/components/web-view/WebView.stories.svelte.d.ts +19 -0
- package/dist/components/web-view/WebView.svelte +197 -0
- package/dist/components/web-view/WebView.svelte.d.ts +4 -0
- package/dist/components/web-view/WebViewTestWrapper.svelte +27 -0
- package/dist/components/web-view/WebViewTestWrapper.svelte.d.ts +4 -0
- package/dist/components/web-view/web-view-sdk.d.ts +49 -0
- package/dist/components/web-view/web-view-sdk.js +61 -0
- package/dist/components/web-view/web-view-utils.d.ts +24 -0
- package/dist/components/web-view/web-view-utils.js +52 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/stores/packageId.d.ts +2 -0
- package/dist/stores/packageId.js +8 -0
- package/dist/types/component.d.ts +2 -1
- package/dist/types/components/web-view.d.ts +15 -0
- package/dist/types/components/web-view.js +1 -0
- package/package.json +2 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import Stack from "../stack/Stack.svelte";
|
|
3
3
|
import { getPaywallContext } from "../../stores/paywall";
|
|
4
|
+
import { setPackageIdContext } from "../../stores/packageId";
|
|
4
5
|
import { setSelectedStateContext } from "../../stores/selected";
|
|
5
6
|
import {
|
|
6
7
|
getVariablesContext,
|
|
@@ -18,6 +19,8 @@
|
|
|
18
19
|
const { stack, package_id, is_selected_by_default, name }: PackageProps =
|
|
19
20
|
$props();
|
|
20
21
|
|
|
22
|
+
setPackageIdContext(package_id);
|
|
23
|
+
|
|
21
24
|
const {
|
|
22
25
|
defaultPackageId,
|
|
23
26
|
emitComponentInteraction,
|
|
@@ -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 }>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import Stack from "../stack/Stack.svelte";
|
|
3
3
|
import { getLocalizationContext } from "../../stores/localization";
|
|
4
|
+
import { getOptionalPackageIdContext } from "../../stores/packageId";
|
|
4
5
|
import { getPaywallContext } from "../../stores/paywall";
|
|
5
6
|
import type { PurchaseButtonProps } from "../../types/components/purchase-button";
|
|
6
7
|
import type { PurchaseButtonInteractionData } from "../../types/paywall-component-interaction";
|
|
@@ -16,6 +17,9 @@
|
|
|
16
17
|
} = getPaywallContext();
|
|
17
18
|
const { getLocalizedString } = getLocalizationContext();
|
|
18
19
|
|
|
20
|
+
// Set when embedded in a package; standalone buttons fall back to the store.
|
|
21
|
+
const enclosingPackageId = getOptionalPackageIdContext();
|
|
22
|
+
|
|
19
23
|
const appendQueryParam = (url: string, key: string, value: string) => {
|
|
20
24
|
const hashIndex = url.indexOf("#");
|
|
21
25
|
const hash = hashIndex === -1 ? "" : url.slice(hashIndex);
|
|
@@ -64,8 +68,10 @@
|
|
|
64
68
|
};
|
|
65
69
|
|
|
66
70
|
const onclick = () => {
|
|
67
|
-
const packageId = $selectedPackageId;
|
|
71
|
+
const packageId = enclosingPackageId ?? $selectedPackageId;
|
|
68
72
|
if (packageId !== undefined) {
|
|
73
|
+
// Sync before purchasing so onPurchase reads this package, not a stale one.
|
|
74
|
+
$selectedPackageId = packageId;
|
|
69
75
|
emitComponentInteraction(getPurchaseButtonInteractionData(packageId));
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -187,7 +187,7 @@
|
|
|
187
187
|
style="position: relative; width: 100%; height: 100%; isolation: isolate;"
|
|
188
188
|
>
|
|
189
189
|
{#each components as component, index}
|
|
190
|
-
<div style={getLayerStyle(index)}>
|
|
190
|
+
<div class="rc-zlayer-layer" style={getLayerStyle(index)}>
|
|
191
191
|
<Node nodeData={component} />
|
|
192
192
|
</div>
|
|
193
193
|
{/each}
|
|
@@ -236,4 +236,18 @@
|
|
|
236
236
|
position: relative;
|
|
237
237
|
z-index: 3;
|
|
238
238
|
}
|
|
239
|
+
|
|
240
|
+
/*
|
|
241
|
+
* Each overlay layer is a full-size (inset:0) box, so by default it swallows
|
|
242
|
+
* clicks anywhere over the stack — including where it has no content —
|
|
243
|
+
* blocking interactive elements on the layers beneath. Only its own content
|
|
244
|
+
* should be hit-testable.
|
|
245
|
+
*/
|
|
246
|
+
.rc-zlayer-layer {
|
|
247
|
+
pointer-events: none;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.rc-zlayer-layer > :global(*) {
|
|
251
|
+
pointer-events: auto;
|
|
252
|
+
}
|
|
239
253
|
</style>
|
|
@@ -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,197 @@
|
|
|
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 style = $derived.by(() => {
|
|
60
|
+
const { width, height } = props.size;
|
|
61
|
+
const widthFit = width.type === "fit";
|
|
62
|
+
const heightFit = height.type === "fit";
|
|
63
|
+
// Pin a `fit` axis with an explicit size. `flex-shrink: 0` keeps flex parents
|
|
64
|
+
// from collapsing the box; avoid mirroring the size in `min-*` or it beats `max-*`.
|
|
65
|
+
const fitWidthPx = widthFit
|
|
66
|
+
? clampFitSize(fitWidth ?? FIT_FALLBACK_SIZE_PX)
|
|
67
|
+
: null;
|
|
68
|
+
const fitHeightPx = heightFit
|
|
69
|
+
? clampFitSize(fitHeight ?? FIT_FALLBACK_SIZE_PX)
|
|
70
|
+
: null;
|
|
71
|
+
return css({
|
|
72
|
+
width: fitWidthPx != null ? px(fitWidthPx) : mapSize(width),
|
|
73
|
+
height: fitHeightPx != null ? px(fitHeightPx) : mapSize(height),
|
|
74
|
+
// Cap a fit axis to the parent. Do not mirror the reported size in `min-*`:
|
|
75
|
+
// that defeats `max-*` and clips content when the report exceeds the viewport.
|
|
76
|
+
...(fitWidthPx != null ? { "max-width": "100%" } : {}),
|
|
77
|
+
...(fitHeightPx != null ? { "max-height": "100%" } : {}),
|
|
78
|
+
"flex-shrink": width.type === "fixed" || widthFit || heightFit ? 0 : 1,
|
|
79
|
+
"line-height": 0,
|
|
80
|
+
// No transition: the iframe is height:100% of this box, so animating the
|
|
81
|
+
// resize would flash the iframe's own scrollbar mid-change.
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const isVisible = $derived(
|
|
86
|
+
evaluateVisibilityConditions(
|
|
87
|
+
{
|
|
88
|
+
selectedPackageId: $selectedPackageId,
|
|
89
|
+
packageInfo: $packageInfo,
|
|
90
|
+
variables: $variables ?? {},
|
|
91
|
+
},
|
|
92
|
+
undefined,
|
|
93
|
+
props.visible,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Host<->content bridge (client-only): the host build is loaded from the CDN by
|
|
98
|
+
// protocol version and attached to the live iframe. The content reports its size
|
|
99
|
+
// (`resize`), driving `fit` axes; a CDN import failure degrades to the plain iframe.
|
|
100
|
+
let iframeEl = $state<HTMLIFrameElement | undefined>(undefined);
|
|
101
|
+
let previousSafeUrl: string | null | undefined;
|
|
102
|
+
|
|
103
|
+
$effect(() => {
|
|
104
|
+
const url = safeUrl;
|
|
105
|
+
if (url !== previousSafeUrl) {
|
|
106
|
+
fitWidth = undefined;
|
|
107
|
+
fitHeight = undefined;
|
|
108
|
+
previousSafeUrl = url;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const iframe = iframeEl;
|
|
112
|
+
// Read every prop the bridge is configured with synchronously: Svelte only
|
|
113
|
+
// tracks reads in the effect body, not inside the async `.then` below, so
|
|
114
|
+
// capturing them here re-runs the effect (rebuilding the bridge) when the
|
|
115
|
+
// component id or `fit` axes change in place.
|
|
116
|
+
const componentId = props.id;
|
|
117
|
+
const protocolVersion = props.protocol_version;
|
|
118
|
+
const widthFit = props.size.width.type === "fit";
|
|
119
|
+
const heightFit = props.size.height.type === "fit";
|
|
120
|
+
if (!iframe || !url) return;
|
|
121
|
+
|
|
122
|
+
let disposed = false;
|
|
123
|
+
let bridge: WebViewHostBridge | undefined;
|
|
124
|
+
|
|
125
|
+
loadCreateHostBridge(protocolVersion)
|
|
126
|
+
.then((createHostBridge) => {
|
|
127
|
+
if (disposed) return;
|
|
128
|
+
try {
|
|
129
|
+
bridge = createHostBridge({
|
|
130
|
+
iframe,
|
|
131
|
+
allowedOrigin: new URL(url).origin,
|
|
132
|
+
componentId,
|
|
133
|
+
protocolVersion,
|
|
134
|
+
});
|
|
135
|
+
// Tell the content which axes are `fit` so it hides its own scrollbar there
|
|
136
|
+
// (it would otherwise flash one while we catch up to a new size).
|
|
137
|
+
if (widthFit || heightFit) {
|
|
138
|
+
bridge.onReady(() =>
|
|
139
|
+
bridge?.send(FIT_MESSAGE, { width: widthFit, height: heightFit }),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
bridge.on(RESIZE_MESSAGE, (payload: unknown) => {
|
|
143
|
+
const { width, height } = (payload ?? {}) as ResizePayload;
|
|
144
|
+
if (widthFit && isPositiveFinite(width)) {
|
|
145
|
+
fitWidth = width;
|
|
146
|
+
}
|
|
147
|
+
if (heightFit && isPositiveFinite(height)) {
|
|
148
|
+
fitHeight = height;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.warn("web_view: failed to wire host bridge", error);
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
.catch(() => {
|
|
156
|
+
// Degrade to the plain iframe if the CDN import fails (e.g. CSP).
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
disposed = true;
|
|
161
|
+
bridge?.destroy();
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
{#if isVisible && safeUrl && canEmbed}
|
|
167
|
+
<div class="web-view" {style}>
|
|
168
|
+
<!--
|
|
169
|
+
`allow-scripts allow-same-origin` runs the bundle under its own origin (for
|
|
170
|
+
same-origin fetch/XHR); restricting it to that origin relies on the bundle's
|
|
171
|
+
CSP. `referrerpolicy`/`allow` and the omitted sandbox flags limit leakage and
|
|
172
|
+
block top navigation.
|
|
173
|
+
-->
|
|
174
|
+
<iframe
|
|
175
|
+
bind:this={iframeEl}
|
|
176
|
+
title={props.name ?? "Web view"}
|
|
177
|
+
src={safeUrl}
|
|
178
|
+
sandbox="allow-scripts allow-same-origin"
|
|
179
|
+
referrerpolicy="no-referrer"
|
|
180
|
+
allow=""
|
|
181
|
+
></iframe>
|
|
182
|
+
</div>
|
|
183
|
+
{/if}
|
|
184
|
+
|
|
185
|
+
<style>
|
|
186
|
+
.web-view {
|
|
187
|
+
position: relative;
|
|
188
|
+
overflow: hidden;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
iframe {
|
|
192
|
+
display: block;
|
|
193
|
+
width: 100%;
|
|
194
|
+
height: 100%;
|
|
195
|
+
border: 0;
|
|
196
|
+
}
|
|
197
|
+
</style>
|
|
@@ -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,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/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
|
+
"version": "4.8.6",
|
|
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": {
|