@funnelsgrove/runtime 0.3.0 → 0.5.0

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.
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type ResolvedFunnelRuntimeConfig } from '../services/funnel-runtime-config.js';
3
+ export type FunnelRuntimeConfigState = {
4
+ status: 'loading' | 'published' | 'fallback';
5
+ config: ResolvedFunnelRuntimeConfig | null;
6
+ error: Error | null;
7
+ };
8
+ type FunnelRuntimeConfigBoundaryProps = {
9
+ funnelId: string;
10
+ funnelVersionId?: string;
11
+ children: ReactNode;
12
+ loadingFallback?: ReactNode;
13
+ disabled?: boolean;
14
+ };
15
+ export declare function FunnelRuntimeConfigBoundary({ disabled, ...props }: FunnelRuntimeConfigBoundaryProps): import("react/jsx-runtime").JSX.Element;
16
+ export declare const useFunnelRuntimeConfig: () => FunnelRuntimeConfigState;
17
+ export {};
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+ var __rest = (this && this.__rest) || function (s, e) {
3
+ var t = {};
4
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
+ t[p] = s[p];
6
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
+ t[p[i]] = s[p[i]];
10
+ }
11
+ return t;
12
+ };
13
+ import { jsx as _jsx } from "react/jsx-runtime";
14
+ import { createContext, useContext, useEffect, useMemo, useState, } from 'react';
15
+ import { loadFunnelRuntimeConfig, resolvePublishedFunnelRuntimeConfig, } from '../services/funnel-runtime-config.js';
16
+ const FunnelRuntimeConfigContext = createContext({
17
+ status: 'fallback',
18
+ config: null,
19
+ error: null,
20
+ });
21
+ const toError = (value) => (value instanceof Error ? value : new Error('Unable to load funnel runtime config'));
22
+ /**
23
+ * Loads the active publication once before the funnel mounts. The resolved
24
+ * snapshot is deeply frozen and stays fixed for the lifetime of this boundary,
25
+ * so one visitor can never switch experiments or prices mid-session.
26
+ *
27
+ * Missing, invalid, or temporarily unavailable publications fail open to the
28
+ * generated build-time config by exposing `config: null`.
29
+ */
30
+ function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children, loadingFallback = null, }) {
31
+ const [state, setState] = useState({
32
+ status: 'loading',
33
+ config: null,
34
+ error: null,
35
+ });
36
+ useEffect(() => {
37
+ let active = true;
38
+ loadFunnelRuntimeConfig(funnelId)
39
+ .then((published) => resolvePublishedFunnelRuntimeConfig(published, {
40
+ expectedFunnelVersionId: funnelVersionId,
41
+ }))
42
+ .then((config) => {
43
+ if (active) {
44
+ setState({ status: 'published', config, error: null });
45
+ }
46
+ })
47
+ .catch((error) => {
48
+ if (active) {
49
+ setState({ status: 'fallback', config: null, error: toError(error) });
50
+ }
51
+ });
52
+ return () => {
53
+ active = false;
54
+ };
55
+ }, [funnelId, funnelVersionId]);
56
+ const contextValue = useMemo(() => state, [state]);
57
+ if (state.status === 'loading') {
58
+ return loadingFallback;
59
+ }
60
+ return (_jsx(FunnelRuntimeConfigContext.Provider, { value: contextValue, children: children }));
61
+ }
62
+ export function FunnelRuntimeConfigBoundary(_a) {
63
+ var { disabled = false } = _a, props = __rest(_a, ["disabled"]);
64
+ if (disabled) {
65
+ return (_jsx(FunnelRuntimeConfigContext.Provider, { value: {
66
+ status: 'fallback',
67
+ config: null,
68
+ error: null,
69
+ }, children: props.children }));
70
+ }
71
+ return _jsx(ActiveFunnelRuntimeConfigBoundary, Object.assign({}, props));
72
+ }
73
+ export const useFunnelRuntimeConfig = () => (useContext(FunnelRuntimeConfigContext));
package/dist/index.d.ts CHANGED
@@ -37,9 +37,11 @@ export * from './services/project-env.js';
37
37
  export * from './services/public-env.js';
38
38
  export * from './services/runtime-api.config.js';
39
39
  export * from './services/runtime-mode.service.js';
40
+ export * from './services/funnel-runtime-config.js';
40
41
  export * from './sdk/userAnswers.js';
41
42
  export * from './components/FunnelContext.js';
42
43
  export * from './components/FunnelEditorPanel.js';
44
+ export * from './components/FunnelRuntimeConfigBoundary.js';
43
45
  export * from './components/ManageSubscriptionScreen.js';
44
46
  export * from './components/PaymentRuntimeBoundary.js';
45
47
  export * from './components/RuntimeDevInfoBox.js';
package/dist/index.js CHANGED
@@ -39,10 +39,12 @@ export * from './services/project-env.js';
39
39
  export * from './services/public-env.js';
40
40
  export * from './services/runtime-api.config.js';
41
41
  export * from './services/runtime-mode.service.js';
42
+ export * from './services/funnel-runtime-config.js';
42
43
  // SDK and generic runtime UI.
43
44
  export * from './sdk/userAnswers.js';
44
45
  export * from './components/FunnelContext.js';
45
46
  export * from './components/FunnelEditorPanel.js';
47
+ export * from './components/FunnelRuntimeConfigBoundary.js';
46
48
  export * from './components/ManageSubscriptionScreen.js';
47
49
  export * from './components/PaymentRuntimeBoundary.js';
48
50
  export * from './components/RuntimeDevInfoBox.js';
@@ -324,8 +324,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
324
324
  const attributesRef = useRef(attributes);
325
325
  const postHogFeatureFlagsRef = useRef({});
326
326
  const prevStepIdRef = useRef(null);
327
- const funnelStartedTrackedRef = useRef(false);
328
- const firstStepViewedVisitRef = useRef(null);
327
+ const firstStepViewedTrackedRef = useRef(false);
329
328
  const firstStepClickedVisitRef = useRef(null);
330
329
  // One controller mount is one funnel run. A full reload intentionally begins a new run.
331
330
  const funnelCompletionClaimedRef = useRef(false);
@@ -968,7 +967,7 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
968
967
  safeActiveStepId,
969
968
  ]);
970
969
  useEffect(() => {
971
- var _a, _b, _c;
970
+ var _a, _b;
972
971
  if (safeActiveStepId !== activeStepId) {
973
972
  logger.warn(`[FunnelFlow] Unknown or non-renderable step "${activeStepId}", falling back to "${safeActiveStepId}".`);
974
973
  claimCurrentVisit({ type: 'exit', reason: 'replace' });
@@ -1021,10 +1020,10 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
1021
1020
  featureFlags: postHogFeatureFlagsRef.current,
1022
1021
  });
1023
1022
  if (safeActiveStepId === defaultStepId) {
1024
- const initialAttribution = takeInitialFunnelAttributionMetadata(funnelStartedTrackedRef.current, attributionReady, initialAttributionRef.current);
1025
- funnelStartedTrackedRef.current = initialAttribution.tracked;
1023
+ const initialAttribution = takeInitialFunnelAttributionMetadata(firstStepViewedTrackedRef.current, attributionReady, initialAttributionRef.current);
1024
+ firstStepViewedTrackedRef.current = initialAttribution.tracked;
1026
1025
  if (initialAttribution.metadata) {
1027
- (_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFunnelStarted) === null || _b === void 0 ? void 0 : _b.call(analytics, {
1026
+ (_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _b === void 0 ? void 0 : _b.call(analytics, {
1028
1027
  userId: currentUserIdRef.current,
1029
1028
  environment: analyticsRuntimeMode,
1030
1029
  stepId: renderedStepId,
@@ -1036,20 +1035,6 @@ export function useFunnelFlowController({ api = apiService, analytics, stepContr
1036
1035
  featureFlags: postHogFeatureFlagsRef.current,
1037
1036
  });
1038
1037
  }
1039
- if (firstStepViewedVisitRef.current !== startIntent.visit.visitId) {
1040
- firstStepViewedVisitRef.current = startIntent.visit.visitId;
1041
- (_c = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _c === void 0 ? void 0 : _c.call(analytics, {
1042
- userId: currentUserIdRef.current,
1043
- environment: analyticsRuntimeMode,
1044
- stepId: renderedStepId,
1045
- stepName,
1046
- stepType,
1047
- stepContractVersion,
1048
- occurredAt: startedAt,
1049
- metadata: attributionMetadata,
1050
- featureFlags: postHogFeatureFlagsRef.current,
1051
- });
1052
- }
1053
1038
  }
1054
1039
  }
1055
1040
  }, [
@@ -0,0 +1,36 @@
1
+ import type { FunnelExperimentDefinition } from '../config/funnel.experiments.types.js';
2
+ import type { GeneratedOfferSet } from '../runtime/offer-set-runtime.js';
3
+ export type FunnelRuntimeConfig = {
4
+ schemaVersion: number;
5
+ funnelId: string;
6
+ projectId: string;
7
+ plans: unknown[];
8
+ offerSets: unknown[];
9
+ experiments: unknown[];
10
+ };
11
+ export type ResolvedFunnelRuntimeConfig = {
12
+ revisionId: string;
13
+ sourceDeploymentId: string;
14
+ sourceVersionId: string;
15
+ publishedAt: string;
16
+ offerSets: readonly GeneratedOfferSet[];
17
+ experiments: readonly FunnelExperimentDefinition[];
18
+ };
19
+ export type PublishedFunnelRuntimeConfig = {
20
+ revisionId: string;
21
+ sourceDeploymentId: string;
22
+ sourceVersionId: string;
23
+ schemaVersion: number;
24
+ publishedAt: string;
25
+ config: FunnelRuntimeConfig;
26
+ };
27
+ type RuntimeConfigFetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
28
+ export declare const resolvePublishedFunnelRuntimeConfig: (published: PublishedFunnelRuntimeConfig, options?: {
29
+ expectedFunnelVersionId?: string;
30
+ }) => ResolvedFunnelRuntimeConfig;
31
+ export declare const loadFunnelRuntimeConfig: (funnelId: string, options?: {
32
+ fetcher?: RuntimeConfigFetcher;
33
+ basePath?: string;
34
+ }) => Promise<PublishedFunnelRuntimeConfig>;
35
+ export declare const clearFunnelRuntimeConfigInflight: () => void;
36
+ export {};
@@ -0,0 +1,276 @@
1
+ const inflightByFunnelId = new Map();
2
+ const isRecord = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value));
3
+ const readString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
4
+ const readNumber = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
5
+ const readBoolean = (value) => (typeof value === 'boolean' ? value : null);
6
+ const readRecordArray = (value) => (Array.isArray(value) ? value.filter(isRecord) : []);
7
+ const pick = (record, ...keys) => {
8
+ for (const key of keys) {
9
+ if (record[key] !== undefined && record[key] !== null) {
10
+ return record[key];
11
+ }
12
+ }
13
+ return undefined;
14
+ };
15
+ const pickString = (record, ...keys) => (readString(pick(record, ...keys)));
16
+ const pickNumber = (record, ...keys) => (readNumber(pick(record, ...keys)));
17
+ const pickBoolean = (record, ...keys) => (readBoolean(pick(record, ...keys)));
18
+ const deepFreeze = (value) => {
19
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
20
+ return value;
21
+ }
22
+ Object.freeze(value);
23
+ for (const nested of Object.values(value)) {
24
+ deepFreeze(nested);
25
+ }
26
+ return value;
27
+ };
28
+ const getPlanById = (plans, projectBillingPlanId) => {
29
+ var _a;
30
+ return (projectBillingPlanId
31
+ ? (_a = plans.find((plan) => pickString(plan, 'id') === projectBillingPlanId)) !== null && _a !== void 0 ? _a : null
32
+ : null);
33
+ };
34
+ const getProviderMapping = (plan, mode) => {
35
+ var _a;
36
+ return ((_a = readRecordArray(plan === null || plan === void 0 ? void 0 : plan.providerMappings)
37
+ .find((mapping) => (pickString(mapping, 'environment') === mode
38
+ && pickBoolean(mapping, 'isActive', 'is_active') !== false))) !== null && _a !== void 0 ? _a : null);
39
+ };
40
+ const buildPlanModeConfig = (item, plan, mode) => {
41
+ var _a, _b;
42
+ const metadata = isRecord(item.metadata) ? item.metadata : {};
43
+ const metadataMappings = isRecord(metadata.providerMappingsByMode)
44
+ ? metadata.providerMappingsByMode
45
+ : {};
46
+ const metadataMapping = isRecord(metadataMappings[mode]) ? metadataMappings[mode] : {};
47
+ const planMapping = getProviderMapping(plan, mode);
48
+ const prefix = mode === 'test' ? 'test' : 'live';
49
+ const providerPlanId = (pickString(item, `${prefix}ProviderPlanId`, `${prefix}_provider_plan_id`)
50
+ || pickString(metadataMapping, 'providerPlanId')
51
+ || (planMapping ? pickString(planMapping, 'providerPlanId', 'provider_plan_id') : null));
52
+ if (!providerPlanId) {
53
+ return null;
54
+ }
55
+ const amountMinor = (_a = pickNumber(metadataMapping, 'amountCents')) !== null && _a !== void 0 ? _a : (planMapping ? pickNumber(planMapping, 'amountMinor', 'amount_minor') : null);
56
+ const interval = pickString(metadataMapping, 'billingInterval')
57
+ || (planMapping ? pickString(planMapping, 'interval') : null);
58
+ const intervalCount = (_b = pickNumber(metadataMapping, 'billingIntervalCount')) !== null && _b !== void 0 ? _b : (planMapping ? pickNumber(planMapping, 'intervalCount', 'interval_count') : null);
59
+ const currency = planMapping ? pickString(planMapping, 'currency') : null;
60
+ const amountMajor = amountMinor === null ? null : amountMinor / 100;
61
+ const priceLabel = pickString(metadataMapping, 'priceLabel') || (amountMajor === null
62
+ ? null
63
+ : new Intl.NumberFormat('en-US', {
64
+ style: 'currency',
65
+ currency: (currency === null || currency === void 0 ? void 0 : currency.toUpperCase()) || 'USD',
66
+ minimumFractionDigits: 2,
67
+ maximumFractionDigits: 2,
68
+ }).format(amountMajor) + (interval ? `/${interval}` : ''));
69
+ return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ providerPlanId }, (priceLabel ? { priceLabel } : {})), (pickString(metadataMapping, 'perDayAmount')
70
+ ? { perDayAmount: pickString(metadataMapping, 'perDayAmount') }
71
+ : {})), (pickString(metadataMapping, 'perDayLabel')
72
+ ? { perDayLabel: pickString(metadataMapping, 'perDayLabel') }
73
+ : {})), (amountMinor === null ? {} : { amountCents: amountMinor })), (pickString(metadataMapping, 'checkoutSummaryLabel')
74
+ ? { checkoutSummaryLabel: pickString(metadataMapping, 'checkoutSummaryLabel') }
75
+ : amountMajor === null
76
+ ? {}
77
+ : { checkoutSummaryLabel: `${new Intl.NumberFormat('en-US', {
78
+ style: 'currency',
79
+ currency: (currency === null || currency === void 0 ? void 0 : currency.toUpperCase()) || 'USD',
80
+ minimumFractionDigits: 2,
81
+ maximumFractionDigits: 2,
82
+ }).format(amountMajor)} today` })), (interval ? { billingInterval: interval } : {})), (intervalCount === null ? {} : { billingIntervalCount: intervalCount }));
83
+ };
84
+ const normalizePublishedOfferSets = (config) => {
85
+ const plans = readRecordArray(config.plans);
86
+ return readRecordArray(config.offerSets).map((offerSet) => {
87
+ var _a;
88
+ const key = pickString(offerSet, 'key');
89
+ if (!key) {
90
+ throw new Error('Published runtime offer set is missing its key');
91
+ }
92
+ const items = readRecordArray(offerSet.items).map((item) => {
93
+ var _a;
94
+ const metadata = isRecord(item.metadata) ? item.metadata : {};
95
+ const funnelPlanKey = pickString(item, 'funnelPlanKey', 'funnel_plan_key');
96
+ if (!funnelPlanKey) {
97
+ throw new Error(`Published runtime offer set "${key}" has an item without a plan key`);
98
+ }
99
+ const projectBillingPlanId = pickString(item, 'projectBillingPlanId', 'project_billing_plan_id');
100
+ const plan = getPlanById(plans, projectBillingPlanId);
101
+ const testMapping = buildPlanModeConfig(item, plan, 'test');
102
+ const liveMapping = buildPlanModeConfig(item, plan, 'live');
103
+ const title = pickString(item, 'title', 'displayNameOverride', 'display_name_override')
104
+ || pickString(metadata, 'title', 'publicPlanName', 'public_plan_name')
105
+ || (plan ? pickString(plan, 'displayName', 'display_name') : null);
106
+ const description = pickString(item, 'description')
107
+ || pickString(metadata, 'description')
108
+ || (plan ? pickString(plan, 'description') : null);
109
+ return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ funnelPlanKey }, (pickString(metadata, 'runtimePlanKey', 'planKey', 'billingPlanKey')
110
+ ? { runtimePlanKey: pickString(metadata, 'runtimePlanKey', 'planKey', 'billingPlanKey') }
111
+ : {})), (projectBillingPlanId ? { projectBillingPlanId } : {})), (pickString(item, 'testProviderPlanId', 'test_provider_plan_id')
112
+ ? { testProviderPlanId: pickString(item, 'testProviderPlanId', 'test_provider_plan_id') }
113
+ : {})), (pickString(item, 'liveProviderPlanId', 'live_provider_plan_id')
114
+ ? { liveProviderPlanId: pickString(item, 'liveProviderPlanId', 'live_provider_plan_id') }
115
+ : {})), (pickString(item, 'followUpTestProviderPlanId', 'follow_up_test_provider_plan_id')
116
+ ? { followUpTestProviderPlanId: pickString(item, 'followUpTestProviderPlanId', 'follow_up_test_provider_plan_id') }
117
+ : {})), (pickString(item, 'followUpLiveProviderPlanId', 'follow_up_live_provider_plan_id')
118
+ ? { followUpLiveProviderPlanId: pickString(item, 'followUpLiveProviderPlanId', 'follow_up_live_provider_plan_id') }
119
+ : {})), (pickString(item, 'followUpTitle', 'follow_up_title')
120
+ ? { followUpTitle: pickString(item, 'followUpTitle', 'follow_up_title') }
121
+ : {})), (pickString(item, 'followUpBillingInterval', 'follow_up_billing_interval')
122
+ ? { followUpBillingInterval: pickString(item, 'followUpBillingInterval', 'follow_up_billing_interval') }
123
+ : {})), (pickNumber(item, 'followUpBillingIntervalCount', 'follow_up_billing_interval_count') === null
124
+ ? {}
125
+ : { followUpBillingIntervalCount: pickNumber(item, 'followUpBillingIntervalCount', 'follow_up_billing_interval_count') })), (pickNumber(item, 'introIterations', 'intro_iterations') === null
126
+ ? {}
127
+ : { introIterations: pickNumber(item, 'introIterations', 'intro_iterations') })), (pickNumber(item, 'analyticsPurchaseValue', 'analytics_purchase_value') === null
128
+ ? {}
129
+ : { analyticsPurchaseValue: pickNumber(item, 'analyticsPurchaseValue', 'analytics_purchase_value') })), (title ? { title } : {})), (description ? { description } : {})), (pickString(item, 'featuredTag', 'featured_tag')
130
+ ? { featuredTag: pickString(item, 'featuredTag', 'featured_tag') }
131
+ : {})), (pickString(item, 'oldPriceLabel', 'old_price_label')
132
+ ? { oldPriceLabel: pickString(item, 'oldPriceLabel', 'old_price_label') }
133
+ : {})), { isDefault: (_a = pickBoolean(item, 'isDefault', 'is_default')) !== null && _a !== void 0 ? _a : false, providerMappingsByMode: Object.assign(Object.assign({}, (testMapping ? { test: testMapping } : {})), (liveMapping ? { live: liveMapping } : {})), metadata });
134
+ });
135
+ return Object.assign(Object.assign({}, (pickString(offerSet, 'id') ? { id: pickString(offerSet, 'id') } : {})), { key, isActive: (_a = pickBoolean(offerSet, 'isActive', 'is_active')) !== null && _a !== void 0 ? _a : true, metadata: isRecord(offerSet.metadata) ? offerSet.metadata : {}, items });
136
+ });
137
+ };
138
+ const normalizePublishedExperiments = (config, publishedAt) => readRecordArray(config.experiments).map((experiment) => {
139
+ const conversionConfig = isRecord(experiment.conversionConfig)
140
+ ? experiment.conversionConfig
141
+ : isRecord(experiment.conversion_config)
142
+ ? experiment.conversion_config
143
+ : {};
144
+ const id = pickString(experiment, 'posthogFlagKey', 'posthog_flag_key', 'id');
145
+ const name = pickString(experiment, 'name');
146
+ const stepId = pickString(conversionConfig, 'stepId', 'paywallStepId')
147
+ || pickString(experiment, 'stepId', 'step_id');
148
+ const rawStatus = pickString(experiment, 'status') || 'draft';
149
+ const status = rawStatus === 'running' || rawStatus === 'paused' || rawStatus === 'stopped'
150
+ ? rawStatus
151
+ : 'draft';
152
+ const type = pickString(conversionConfig, 'type') || 'step';
153
+ const variants = readRecordArray(experiment.variants);
154
+ if (!id || !name || !stepId || variants.length < 2) {
155
+ throw new Error('Published runtime experiment is incomplete');
156
+ }
157
+ const normalizedVariants = variants.map((variant, index) => {
158
+ var _a;
159
+ return ({
160
+ variantKey: pickString(variant, 'variantKey', 'variant_key', 'key')
161
+ || (index === 0 ? 'control' : `variant_${String.fromCharCode(96 + index)}`),
162
+ label: pickString(variant, 'label') || undefined,
163
+ trafficPercent: (_a = pickNumber(variant, 'trafficPercent', 'traffic_percent')) !== null && _a !== void 0 ? _a : 0,
164
+ routeToStepId: pickString(variant, 'routeToStepId', 'route_to_step_id') || stepId,
165
+ });
166
+ });
167
+ if (normalizedVariants.reduce((sum, variant) => sum + variant.trafficPercent, 0) !== 100) {
168
+ throw new Error(`Published runtime experiment "${id}" traffic must sum to 100`);
169
+ }
170
+ const launchDate = (pickString(conversionConfig, 'launchDate')
171
+ || pickString(experiment, 'startedAt', 'started_at')
172
+ || publishedAt).slice(0, 10);
173
+ if (type === 'pricing') {
174
+ const configuredKeys = Array.isArray(conversionConfig.offerSetKeys)
175
+ ? conversionConfig.offerSetKeys.map(readString).filter((value) => Boolean(value))
176
+ : [
177
+ pickString(conversionConfig, 'controlOfferSetKey'),
178
+ pickString(conversionConfig, 'variantOfferSetKey'),
179
+ ].filter((value) => Boolean(value));
180
+ if (configuredKeys.length !== normalizedVariants.length) {
181
+ throw new Error(`Published pricing experiment "${id}" does not map every variant to an offer set`);
182
+ }
183
+ const pricingVariants = normalizedVariants.map((variant, index) => (Object.assign(Object.assign({ variantKey: variant.variantKey }, (variant.label ? { label: variant.label } : {})), { trafficPercent: variant.trafficPercent, offerSetKey: configuredKeys[index] })));
184
+ return {
185
+ id,
186
+ name,
187
+ type: 'pricing',
188
+ status,
189
+ launchDate,
190
+ paywallStepId: stepId,
191
+ control: pricingVariants[0],
192
+ variant: pricingVariants[1],
193
+ variants: pricingVariants,
194
+ };
195
+ }
196
+ if (type !== 'step' && type !== 'paywall') {
197
+ throw new Error(`Published runtime experiment "${id}" has an unsupported type`);
198
+ }
199
+ const routeVariants = normalizedVariants.map((variant) => (Object.assign(Object.assign({ variantKey: variant.variantKey }, (variant.label ? { label: variant.label } : {})), { trafficPercent: variant.trafficPercent, stepId: variant.routeToStepId })));
200
+ return {
201
+ id,
202
+ name,
203
+ type,
204
+ status,
205
+ launchDate,
206
+ stepId,
207
+ control: routeVariants[0],
208
+ variant: routeVariants[1],
209
+ variants: routeVariants,
210
+ };
211
+ });
212
+ export const resolvePublishedFunnelRuntimeConfig = (published, options = {}) => {
213
+ if (options.expectedFunnelVersionId
214
+ && published.sourceVersionId !== options.expectedFunnelVersionId) {
215
+ throw new Error('Published runtime config belongs to a different funnel version');
216
+ }
217
+ return deepFreeze({
218
+ revisionId: published.revisionId,
219
+ sourceDeploymentId: published.sourceDeploymentId,
220
+ sourceVersionId: published.sourceVersionId,
221
+ publishedAt: published.publishedAt,
222
+ offerSets: normalizePublishedOfferSets(published.config),
223
+ experiments: normalizePublishedExperiments(published.config, published.publishedAt),
224
+ });
225
+ };
226
+ const parsePublishedRuntimeConfig = (value) => {
227
+ if (!isRecord(value) || !isRecord(value.config)) {
228
+ throw new Error('Funnel runtime config response is invalid');
229
+ }
230
+ const config = value.config;
231
+ if (typeof value.revisionId !== 'string'
232
+ || typeof value.sourceDeploymentId !== 'string'
233
+ || typeof value.sourceVersionId !== 'string'
234
+ || typeof value.schemaVersion !== 'number'
235
+ || typeof value.publishedAt !== 'string'
236
+ || typeof config.schemaVersion !== 'number'
237
+ || typeof config.funnelId !== 'string'
238
+ || typeof config.projectId !== 'string'
239
+ || !Array.isArray(config.plans)
240
+ || !Array.isArray(config.offerSets)
241
+ || !Array.isArray(config.experiments)) {
242
+ throw new Error('Funnel runtime config response is invalid');
243
+ }
244
+ return value;
245
+ };
246
+ export const loadFunnelRuntimeConfig = (funnelId, options = {}) => {
247
+ const normalizedFunnelId = funnelId.trim();
248
+ if (!normalizedFunnelId) {
249
+ return Promise.reject(new Error('funnelId is required'));
250
+ }
251
+ const cachedInflight = inflightByFunnelId.get(normalizedFunnelId);
252
+ if (cachedInflight) {
253
+ return cachedInflight;
254
+ }
255
+ const fetcher = options.fetcher || globalThis.fetch;
256
+ const basePath = (options.basePath || '/api/funnel-config').replace(/\/$/, '');
257
+ const request = fetcher(`${basePath}/${encodeURIComponent(normalizedFunnelId)}`, {
258
+ headers: { accept: 'application/json' },
259
+ }).then(async (response) => {
260
+ if (!response.ok) {
261
+ throw new Error(`Unable to load funnel runtime config (${response.status})`);
262
+ }
263
+ const published = parsePublishedRuntimeConfig(await response.json());
264
+ if (published.config.funnelId !== normalizedFunnelId) {
265
+ throw new Error('Funnel runtime config belongs to a different funnel');
266
+ }
267
+ return published;
268
+ }).finally(() => {
269
+ inflightByFunnelId.delete(normalizedFunnelId);
270
+ });
271
+ inflightByFunnelId.set(normalizedFunnelId, request);
272
+ return request;
273
+ };
274
+ export const clearFunnelRuntimeConfigInflight = () => {
275
+ inflightByFunnelId.clear();
276
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",