@envive-ai/react-hooks 0.3.61 → 0.3.63
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/application/utils/elementObserver.d.cts +2 -2
- package/dist/application/utils/widgetTextRequestToApiRequest.cjs +1 -0
- package/dist/application/utils/widgetTextRequestToApiRequest.js +1 -1
- package/dist/atoms/app/index.d.cts +7 -7
- package/dist/atoms/app/index.d.ts +7 -7
- package/dist/atoms/app/variant.d.ts +6 -6
- package/dist/atoms/chat/chatState.d.cts +19 -19
- package/dist/atoms/chat/chatState.d.ts +19 -19
- package/dist/atoms/chat/form.d.cts +2 -2
- package/dist/atoms/chat/form.d.ts +2 -2
- package/dist/atoms/chat/index.d.cts +2 -2
- package/dist/atoms/chat/index.d.ts +3 -3
- package/dist/atoms/chat/lastMessage.d.cts +2 -2
- package/dist/atoms/chat/lastMessage.d.ts +2 -2
- package/dist/atoms/chat/messageQueue.d.cts +6 -6
- package/dist/atoms/chat/messageQueue.d.ts +7 -7
- package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
- package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
- package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
- package/dist/atoms/chat/replies.d.cts +3 -3
- package/dist/atoms/chat/replies.d.ts +3 -3
- package/dist/atoms/chat/suggestions.d.cts +2 -2
- package/dist/atoms/chat/suggestions.d.ts +2 -2
- package/dist/atoms/envive/enviveConfig.d.cts +14 -14
- package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
- package/dist/atoms/org/customerService.d.cts +6 -6
- package/dist/atoms/org/customerService.d.ts +6 -6
- package/dist/atoms/org/graphqlConfig.d.cts +4 -4
- package/dist/atoms/org/graphqlConfig.d.ts +4 -4
- package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
- package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
- package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
- package/dist/atoms/search/chatSearch.d.cts +17 -17
- package/dist/atoms/search/chatSearch.d.ts +17 -17
- package/dist/atoms/search/searchAPI.d.cts +13 -13
- package/dist/atoms/search/searchAPI.d.ts +13 -13
- package/dist/atoms/search/types.d.cts +1 -1
- package/dist/atoms/search/utils.d.cts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
- package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
- package/dist/contexts/types.d.cts +1 -1
- package/dist/contexts/types.d.ts +1 -1
- package/dist/contexts/typesV3.d.cts +1 -1
- package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
- package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.ts +2 -2
- package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
- package/dist/hooks/utils.d.cts +1 -1
- package/dist/hooks/utils.d.ts +1 -1
- package/dist/services/enviveConfigService/enviveConfigService.cjs +49 -3
- package/dist/services/enviveConfigService/enviveConfigService.d.cts +16 -2
- package/dist/services/enviveConfigService/enviveConfigService.d.ts +16 -2
- package/dist/services/enviveConfigService/enviveConfigService.js +49 -3
- package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +102 -0
- package/dist/services/enviveConfigService/fetchBootstrapConfig.js +102 -0
- package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +2 -1
- package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -2
- package/dist/services/hardcopyService/hardcopyService.cjs +20 -4
- package/dist/services/hardcopyService/hardcopyService.d.cts +3 -2
- package/dist/services/hardcopyService/hardcopyService.d.ts +3 -2
- package/dist/services/hardcopyService/hardcopyService.js +20 -5
- package/dist/services/hardcopyService/index.cjs +1 -0
- package/dist/services/hardcopyService/index.d.cts +2 -2
- package/dist/services/hardcopyService/index.d.ts +2 -2
- package/dist/services/hardcopyService/index.js +2 -2
- package/dist/services/urlResolverWarmupService/index.cjs +3 -3
- package/dist/services/urlResolverWarmupService/index.d.cts +8 -2
- package/dist/services/urlResolverWarmupService/index.d.ts +8 -2
- package/dist/services/urlResolverWarmupService/index.js +3 -3
- package/package.json +1 -1
- package/src/hooks/Intersection/__tests__/useIntersection.test.ts +1 -3
- package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +226 -0
- package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +271 -0
- package/src/services/enviveConfigService/enviveConfigService.ts +124 -5
- package/src/services/enviveConfigService/fetchBootstrapConfig.ts +276 -0
- package/src/services/enviveConfigService/fetchGraphQLConfig.ts +7 -4
- package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +60 -1
- package/src/services/hardcopyService/hardcopyService.ts +45 -3
- package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +55 -0
- package/src/services/urlResolverWarmupService/index.ts +18 -1
|
@@ -2,7 +2,15 @@ import { OrgConfigExperimentAssignment, OrgConfigFeatureGate } from 'src/applica
|
|
|
2
2
|
import { FeatureGates } from 'src/application/models/featureGates';
|
|
3
3
|
import type { GraphQlConfigValues } from 'src/contexts/graphqlContext';
|
|
4
4
|
import { configVersionOverride } from 'src/types/config-versions';
|
|
5
|
+
import { FeatureFlagService } from 'src/services/featureFlagService';
|
|
6
|
+
import Logger from 'src/application/logging/logger';
|
|
7
|
+
import type { UrlResolverResponse } from 'src/atoms/app/variant';
|
|
8
|
+
import { getAtomStore } from 'src/atoms/atomStore/atomStore';
|
|
9
|
+
import { chatIdAtom } from 'src/atoms/app';
|
|
5
10
|
import { fetchUnifiedGraphQLConfig } from './fetchGraphQLConfig';
|
|
11
|
+
import { fetchBootstrapConfig } from './fetchBootstrapConfig';
|
|
12
|
+
|
|
13
|
+
const logger = new Logger('enviveConfigService');
|
|
6
14
|
|
|
7
15
|
// Short-lived so config changes (gates, experiments) still propagate within
|
|
8
16
|
// a session, while sparing every page navigation from re-fetching org
|
|
@@ -21,6 +29,12 @@ type EnviveConfigServiceProps = {
|
|
|
21
29
|
userId: string;
|
|
22
30
|
namespace: string;
|
|
23
31
|
source: string;
|
|
32
|
+
// The url_resolving context bits the hooks layer can't derive itself, supplied
|
|
33
|
+
// by the injection bundle so getEnviveConfig can call /v1/session/bootstrap on
|
|
34
|
+
// the use_unified_config gate-ON path. Optional: when absent (mocks, non-bundle
|
|
35
|
+
// callers) the bootstrap branch is skipped and getWidgetConfig stands.
|
|
36
|
+
env?: string;
|
|
37
|
+
contextSource?: string;
|
|
24
38
|
};
|
|
25
39
|
|
|
26
40
|
export type EnviveServiceConfig = {
|
|
@@ -36,9 +50,10 @@ export type EnviveServiceConfig = {
|
|
|
36
50
|
};
|
|
37
51
|
};
|
|
38
52
|
gates: OrgConfigFeatureGate[];
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
53
|
+
// The user's Statsig experiment-layer assignments, from the widget config's
|
|
54
|
+
// resolved experiments — getWidgetConfig, or bootstrap's `widget_config`
|
|
55
|
+
// (both ungated by use_unified_config; empty only under a version pin).
|
|
56
|
+
// amplitudeService reads these to enrich events with `experiment.{ns}.*` props.
|
|
42
57
|
experiment_assignments?: OrgConfigExperimentAssignment[];
|
|
43
58
|
// orgConfig.resolution is populated only on the unified path (sourced from
|
|
44
59
|
// getWidgetConfig.resolution, not /v1/org/config.resolution) so the version
|
|
@@ -51,8 +66,18 @@ export interface IEnviveConfigService {
|
|
|
51
66
|
readonly baseUrl: string;
|
|
52
67
|
readonly apiKey: string;
|
|
53
68
|
getEnviveConfig(): Promise<EnviveServiceConfig>;
|
|
69
|
+
// NB: `takeBootstrapUrlResolving` is intentionally NOT on this interface. It's
|
|
70
|
+
// a bootstrap-only detail consumed solely by the injection bundle via the
|
|
71
|
+
// concrete EnviveConfigService; keeping it off the public DI contract avoids a
|
|
72
|
+
// breaking change for external consumers that supply their own config service
|
|
73
|
+
// (EnviveProvider only ever calls getEnviveConfig / baseUrl / apiKey).
|
|
54
74
|
}
|
|
55
75
|
|
|
76
|
+
// The bootstrap url_resolving payload plus the URL it was resolved for, so the
|
|
77
|
+
// warm-up can verify the URL still matches before seeding (see F1: a SPA nav
|
|
78
|
+
// during the bootstrap round trip must not cache URL A's result under URL B).
|
|
79
|
+
type BootstrapUrlResolving = { url: string; response: UrlResolverResponse };
|
|
80
|
+
|
|
56
81
|
export class EnviveConfigService implements IEnviveConfigService {
|
|
57
82
|
public readonly baseUrl: string;
|
|
58
83
|
|
|
@@ -64,14 +89,40 @@ export class EnviveConfigService implements IEnviveConfigService {
|
|
|
64
89
|
|
|
65
90
|
private readonly source: string;
|
|
66
91
|
|
|
92
|
+
private readonly env?: string;
|
|
93
|
+
|
|
94
|
+
private readonly contextSource?: string;
|
|
95
|
+
|
|
67
96
|
private response: EnviveServiceConfig | null = null;
|
|
68
97
|
|
|
69
|
-
|
|
98
|
+
private bootstrapUrlResolving: BootstrapUrlResolving | null = null;
|
|
99
|
+
|
|
100
|
+
constructor({
|
|
101
|
+
baseUrl,
|
|
102
|
+
apiKey,
|
|
103
|
+
userId,
|
|
104
|
+
namespace,
|
|
105
|
+
source,
|
|
106
|
+
env,
|
|
107
|
+
contextSource,
|
|
108
|
+
}: EnviveConfigServiceProps) {
|
|
70
109
|
this.baseUrl = baseUrl;
|
|
71
110
|
this.apiKey = apiKey;
|
|
72
111
|
this.userId = userId;
|
|
73
112
|
this.namespace = namespace;
|
|
74
113
|
this.source = source;
|
|
114
|
+
this.env = env;
|
|
115
|
+
this.contextSource = contextSource;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Hands off (once) the url_resolving payload that /v1/session/bootstrap
|
|
119
|
+
// returned alongside the config — paired with the URL it was resolved for — so
|
|
120
|
+
// the warm-up seeds urlResolverAtom with it instead of firing a separate
|
|
121
|
+
// /v1/url_resolving. null on the getWidgetConfig / cached path.
|
|
122
|
+
takeBootstrapUrlResolving(): BootstrapUrlResolving | null {
|
|
123
|
+
const value = this.bootstrapUrlResolving;
|
|
124
|
+
this.bootstrapUrlResolving = null;
|
|
125
|
+
return value;
|
|
75
126
|
}
|
|
76
127
|
|
|
77
128
|
private getCacheKey(): string {
|
|
@@ -132,16 +183,84 @@ export class EnviveConfigService implements IEnviveConfigService {
|
|
|
132
183
|
// list of gates the app checks, so the resolver evaluates and returns them.
|
|
133
184
|
const gateNames = Object.values(FeatureGates).map(featureGate => featureGate.toString());
|
|
134
185
|
|
|
135
|
-
|
|
186
|
+
const widgetConfig = await fetchUnifiedGraphQLConfig(
|
|
136
187
|
this.baseUrl,
|
|
137
188
|
this.apiKey,
|
|
138
189
|
this.userId,
|
|
139
190
|
gateNames,
|
|
140
191
|
this.source,
|
|
141
192
|
);
|
|
193
|
+
|
|
194
|
+
this.response = await this.maybeUpgradeToBootstrap(widgetConfig, gateNames);
|
|
142
195
|
this.writeCachedConfig(this.response);
|
|
143
196
|
return this.response;
|
|
144
197
|
}
|
|
198
|
+
|
|
199
|
+
// The unified-config ramp (`use_unified_config` Statsig gate). When the gate
|
|
200
|
+
// is ON for this user, swap the getWidgetConfig result for Matt's
|
|
201
|
+
// /v1/session/bootstrap — the same config PLUS the initial url_resolving in
|
|
202
|
+
// ONE round trip (the consolidation this migration is about). The gate is read
|
|
203
|
+
// off the getWidgetConfig response just fetched, because the FE has no Statsig
|
|
204
|
+
// SDK: pymono evaluates the gate server-side and returns it. `FeatureFlagService`
|
|
205
|
+
// also honors a `?use_unified_config=true` override, so the bootstrap path is
|
|
206
|
+
// testable before the gate ramps.
|
|
207
|
+
//
|
|
208
|
+
// Only runs on the cache-miss path (bootstrap's config is then cached like any
|
|
209
|
+
// other), so a cached load never re-fetches. Needs env + contextSource for the
|
|
210
|
+
// url_resolving context — absent for mocks / non-bundle callers, where the
|
|
211
|
+
// branch is skipped. A bootstrap failure falls back to the getWidgetConfig
|
|
212
|
+
// config already in hand: a bootstrap problem degrades to the proven path
|
|
213
|
+
// instead of aborting injection. At 100% the getWidgetConfig call above is
|
|
214
|
+
// dropped and bootstrap becomes the sole config call (step 8).
|
|
215
|
+
private async maybeUpgradeToBootstrap(
|
|
216
|
+
widgetConfig: EnviveServiceConfig,
|
|
217
|
+
gateNames: string[],
|
|
218
|
+
): Promise<EnviveServiceConfig> {
|
|
219
|
+
if (this.env === undefined || this.contextSource === undefined) {
|
|
220
|
+
return widgetConfig;
|
|
221
|
+
}
|
|
222
|
+
// Built once: reads the gate (override-aware) AND supplies the override-aware
|
|
223
|
+
// gate map bootstrap's url_resolving needs (matches the legacy resolveUrl path).
|
|
224
|
+
const featureFlagService = new FeatureFlagService(widgetConfig.gates);
|
|
225
|
+
if (!featureFlagService.isFeatureGateEnabled(FeatureGates.UseUnifiedConfig)) {
|
|
226
|
+
return widgetConfig;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Captured once and reused for both the bootstrap request and the stash, so
|
|
230
|
+
// the URL carried to the warm-up is provably the one bootstrap resolved. The
|
|
231
|
+
// warm-up only seeds it if this still matches the current URL (a SPA nav
|
|
232
|
+
// during the round trip would otherwise cache this result under a later URL).
|
|
233
|
+
// Same cleansing as warmUrlResolver so the urlResolverAtom key lines up.
|
|
234
|
+
const cleansedUrl = window.location.href.toLowerCase().trim();
|
|
235
|
+
try {
|
|
236
|
+
const { config, urlResolving } = await fetchBootstrapConfig({
|
|
237
|
+
baseUrl: this.baseUrl,
|
|
238
|
+
apiKey: this.apiKey,
|
|
239
|
+
userId: this.userId,
|
|
240
|
+
source: this.source,
|
|
241
|
+
// Scope org_config.configs to this namespace so bootstrap doesn't return
|
|
242
|
+
// internal-only org configs to the browser (matches the legacy /v1/org/config).
|
|
243
|
+
namespace: this.namespace,
|
|
244
|
+
gateNames,
|
|
245
|
+
url: cleansedUrl,
|
|
246
|
+
orgId: widgetConfig.org.org.id,
|
|
247
|
+
orgShortName: widgetConfig.org.org.short_name,
|
|
248
|
+
contextSource: this.contextSource,
|
|
249
|
+
env: this.env,
|
|
250
|
+
featureGates: featureFlagService.getFeatureFlags(),
|
|
251
|
+
// The persistent chat session id (same source resolveUrl reads) so
|
|
252
|
+
// bootstrap's url_resolving carries it instead of an empty string.
|
|
253
|
+
chatId: getAtomStore().get(chatIdAtom),
|
|
254
|
+
});
|
|
255
|
+
this.bootstrapUrlResolving = urlResolving
|
|
256
|
+
? { url: cleansedUrl, response: urlResolving }
|
|
257
|
+
: null;
|
|
258
|
+
return config;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
logger.logError('getEnviveConfig | bootstrap upgrade failed, using getWidgetConfig', err);
|
|
261
|
+
return widgetConfig;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
145
264
|
}
|
|
146
265
|
|
|
147
266
|
/**
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import Logger from 'src/application/logging/logger';
|
|
2
|
+
import type { UrlResolverResponse } from 'src/atoms/app/variant';
|
|
3
|
+
import {
|
|
4
|
+
ExperimentConfigResolutionMetadata,
|
|
5
|
+
OrgConfigExperimentAssignment,
|
|
6
|
+
OrgConfigFeatureGate,
|
|
7
|
+
} from 'src/application/models/api/orgConfigResults';
|
|
8
|
+
import type { GraphQlConfigValues } from 'src/contexts/graphqlContext';
|
|
9
|
+
import { configVersionOverride } from 'src/types/config-versions';
|
|
10
|
+
import { transformV3ProductsConfig } from './fetchGraphQLConfig';
|
|
11
|
+
// Type-only import — erased at compile time, so this does not create a runtime
|
|
12
|
+
// import cycle with enviveConfigService (which imports fetchBootstrapConfig).
|
|
13
|
+
import type { EnviveServiceConfig } from './enviveConfigService';
|
|
14
|
+
|
|
15
|
+
const logger = new Logger('fetchBootstrapConfig');
|
|
16
|
+
|
|
17
|
+
// The url-resolving context bits the hooks layer can't derive on its own — the
|
|
18
|
+
// injection bundle supplies env (a build-time var) and contextSource. org
|
|
19
|
+
// identity is threaded from the getWidgetConfig response fetched moments earlier
|
|
20
|
+
// (see enviveConfigService: bootstrap only runs on the gate-ON path, which reads
|
|
21
|
+
// the gate off getWidgetConfig first, so org id/short_name are already in hand).
|
|
22
|
+
export interface FetchBootstrapConfigParams {
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
apiKey: string;
|
|
25
|
+
userId: string;
|
|
26
|
+
// Config `source` (Statsig user property), 'app' in the injection bundle.
|
|
27
|
+
source: string;
|
|
28
|
+
// Scopes org_config.configs to this namespace. REQUIRED: without it pymono
|
|
29
|
+
// returns EVERY namespace on the org — including internal-only configs (the AI
|
|
30
|
+
// system prompt, brand-voice instructions, etc.) — in the response the public
|
|
31
|
+
// bundle receives. The legacy /v1/org/config call scoped it the same way.
|
|
32
|
+
namespace: string;
|
|
33
|
+
gateNames: string[];
|
|
34
|
+
// url_resolving inputs:
|
|
35
|
+
url: string;
|
|
36
|
+
orgId: string;
|
|
37
|
+
orgShortName: string;
|
|
38
|
+
// Context `source` for url_resolving ('app' | 'playground'); may differ from
|
|
39
|
+
// the config `source` above (playground = Envive Hub).
|
|
40
|
+
contextSource: string;
|
|
41
|
+
env: string;
|
|
42
|
+
chatId?: string;
|
|
43
|
+
// The override-aware feature gates (FeatureFlagService.getFeatureFlags()), so
|
|
44
|
+
// client-side gate overrides (query/window/localStorage) reach page-variant
|
|
45
|
+
// resolution — same as the legacy /v1/url_resolving request. Bootstrap merges
|
|
46
|
+
// these OVER its own server-resolved gates. Harmlessly ignored by pymono until
|
|
47
|
+
// SessionBootstrapUrlResolvingRequest gains the `feature_gates` field (the
|
|
48
|
+
// schema allows unknown fields; the model is `extra='ignore'`), so the FE can
|
|
49
|
+
// ship ahead of the backend.
|
|
50
|
+
featureGates?: Record<string, boolean>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BootstrapResult {
|
|
54
|
+
config: EnviveServiceConfig;
|
|
55
|
+
// The initial page-variant result, ready to seed urlResolverAtom so the
|
|
56
|
+
// warm-up skips its own /v1/url_resolving call. null when bootstrap's
|
|
57
|
+
// (best-effort) url-resolving branch returned nothing.
|
|
58
|
+
urlResolving: UrlResolverResponse | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- raw wire shapes (POST /v1/session/bootstrap is REST snake_case) ---------
|
|
62
|
+
|
|
63
|
+
interface RawFeatureGate {
|
|
64
|
+
name?: string;
|
|
65
|
+
value?: boolean;
|
|
66
|
+
group_name?: string | null;
|
|
67
|
+
rule_id?: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RawExperimentAssignment {
|
|
71
|
+
layer_name?: string;
|
|
72
|
+
namespace?: OrgConfigExperimentAssignment['namespace'];
|
|
73
|
+
allocated_experiment_name?: string | null;
|
|
74
|
+
group_name?: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface RawResolution {
|
|
78
|
+
base_version?: string;
|
|
79
|
+
base_version_source?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface RawBootstrapResponse {
|
|
83
|
+
org_config?: {
|
|
84
|
+
org?: { org?: Record<string, string | undefined> };
|
|
85
|
+
gates?: RawFeatureGate[];
|
|
86
|
+
};
|
|
87
|
+
widget_config?: {
|
|
88
|
+
products_config?: Parameters<typeof transformV3ProductsConfig>[0];
|
|
89
|
+
// Read from widget_config (not org_config) to match getWidgetConfig — see the
|
|
90
|
+
// mapping below.
|
|
91
|
+
experiment_assignments?: RawExperimentAssignment[];
|
|
92
|
+
resolution?: RawResolution;
|
|
93
|
+
};
|
|
94
|
+
url_resolving?: UrlResolverResponse | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// gates arrive snake (`group_name`); getWidgetConfig's arrive camel. Only the
|
|
98
|
+
// name/value are used for gate checks — groupName is carried for amplitude.
|
|
99
|
+
const toBootstrapGates = (raw: RawFeatureGate[] | undefined): OrgConfigFeatureGate[] =>
|
|
100
|
+
(raw ?? []).map(g => ({
|
|
101
|
+
name: g.name,
|
|
102
|
+
value: g.value,
|
|
103
|
+
groupName: g.group_name ?? undefined,
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
// experiment_assignments already match the snake OrgConfigExperimentAssignment
|
|
107
|
+
// wire shape (unlike getWidgetConfig's camel, which needs toExperimentAssignments).
|
|
108
|
+
const toBootstrapAssignments = (
|
|
109
|
+
raw: RawExperimentAssignment[] | undefined,
|
|
110
|
+
): OrgConfigExperimentAssignment[] =>
|
|
111
|
+
(raw ?? []).map(a => ({
|
|
112
|
+
layer_name: a.layer_name,
|
|
113
|
+
namespace: a.namespace,
|
|
114
|
+
allocated_experiment_name: a.allocated_experiment_name,
|
|
115
|
+
group_name: a.group_name,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
// resolution is snake here (REST); getWidgetConfig's is camel (Ariadne).
|
|
119
|
+
const toResolution = (
|
|
120
|
+
raw: RawResolution | undefined,
|
|
121
|
+
): ExperimentConfigResolutionMetadata | undefined =>
|
|
122
|
+
raw ? { baseVersion: raw.base_version, baseVersionSource: raw.base_version_source } : undefined;
|
|
123
|
+
|
|
124
|
+
// One round trip for the config the widget client needs at init: org identity +
|
|
125
|
+
// gates + experiment assignments + productsConfig/resolution, PLUS the initial
|
|
126
|
+
// url-resolving result — the consolidation of `GET /v1/org/config`, GraphQL
|
|
127
|
+
// `me.getWidgetConfig`, and `POST /v1/url_resolving` behind Matt's endpoint.
|
|
128
|
+
//
|
|
129
|
+
// Raw fetch (the npm client `@spiffy-ai/commerce-api-client` has no bootstrap
|
|
130
|
+
// method, same as getWidgetConfig). Throws on network/HTTP failure/timeout — the
|
|
131
|
+
// caller (enviveConfigService) falls back to the getWidgetConfig config it already
|
|
132
|
+
// holds, so a bootstrap problem degrades to the proven path rather than aborting
|
|
133
|
+
// injection.
|
|
134
|
+
//
|
|
135
|
+
// Bootstrap is an OPTIONAL upgrade that BLOCKS the pre-first-paint critical path
|
|
136
|
+
// (a second call after getWidgetConfig already succeeded), and we already hold a
|
|
137
|
+
// usable getWidgetConfig config to fall back to — so it is tightly bounded. Kept
|
|
138
|
+
// low so a slow/hung endpoint adds at most this much to first paint; on timeout
|
|
139
|
+
// the fetch aborts and we fall back to getWidgetConfig. CALIBRATE against the
|
|
140
|
+
// endpoint's real p99 before ramping the gate (a value below p99 will make normal
|
|
141
|
+
// bootstraps false-abort into the fallback, defeating the consolidation).
|
|
142
|
+
const BOOTSTRAP_TIMEOUT_MS = 1500;
|
|
143
|
+
|
|
144
|
+
export const fetchBootstrapConfig = async ({
|
|
145
|
+
baseUrl,
|
|
146
|
+
apiKey,
|
|
147
|
+
userId,
|
|
148
|
+
source,
|
|
149
|
+
namespace,
|
|
150
|
+
gateNames,
|
|
151
|
+
url,
|
|
152
|
+
orgId,
|
|
153
|
+
orgShortName,
|
|
154
|
+
contextSource,
|
|
155
|
+
env,
|
|
156
|
+
chatId,
|
|
157
|
+
featureGates,
|
|
158
|
+
}: FetchBootstrapConfigParams): Promise<BootstrapResult> => {
|
|
159
|
+
const version = configVersionOverride();
|
|
160
|
+
|
|
161
|
+
// Armed before fetch and kept live across response.json() (a stalled body read
|
|
162
|
+
// must also be interruptible), cleared in finally. On timeout the fetch rejects
|
|
163
|
+
// with an AbortError → maybeUpgradeToBootstrap's catch falls back to getWidgetConfig.
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timeoutId = setTimeout(() => controller.abort(), BOOTSTRAP_TIMEOUT_MS);
|
|
166
|
+
let result: RawBootstrapResponse;
|
|
167
|
+
try {
|
|
168
|
+
const response = await fetch(`${baseUrl}/v1/session/bootstrap`, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: {
|
|
171
|
+
'Content-Type': 'application/json',
|
|
172
|
+
Authorization: `Bearer ${apiKey}`,
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
user_id: userId,
|
|
176
|
+
source,
|
|
177
|
+
// Scope org_config.configs to the widget namespace — WITHOUT this, pymono
|
|
178
|
+
// returns every namespace's configs (internal ones included) to the browser.
|
|
179
|
+
namespace,
|
|
180
|
+
include_feature_gates: gateNames,
|
|
181
|
+
version,
|
|
182
|
+
url_resolving: {
|
|
183
|
+
url,
|
|
184
|
+
context: {
|
|
185
|
+
user_id: userId,
|
|
186
|
+
org_id: orgId,
|
|
187
|
+
org_short_name: orgShortName,
|
|
188
|
+
chat_id: chatId ?? '',
|
|
189
|
+
source: contextSource,
|
|
190
|
+
env,
|
|
191
|
+
},
|
|
192
|
+
// Override-aware gates so client gate overrides reach page-variant
|
|
193
|
+
// resolution (bootstrap merges these over its server-resolved gates).
|
|
194
|
+
// Ignored by pymono until the endpoint gains the field — safe to ship early.
|
|
195
|
+
feature_gates: featureGates,
|
|
196
|
+
// The config-version override comes ONLY from the URL query param
|
|
197
|
+
// (configVersionOverride) — the same source getWidgetConfig uses. It must
|
|
198
|
+
// NOT come from getWidgetConfig's resolved version: on the gate-ON path
|
|
199
|
+
// that response is discarded, and bootstrap resolves the config version
|
|
200
|
+
// server-side (scoping url_resolving to that resolved version, if desired,
|
|
201
|
+
// belongs on the server — not a getWidgetConfig dependency here).
|
|
202
|
+
override_config_version: version,
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
signal: controller.signal,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
throw new Error(`Bootstrap config request failed: ${response.statusText}`);
|
|
210
|
+
}
|
|
211
|
+
result = (await response.json()) as RawBootstrapResponse;
|
|
212
|
+
} finally {
|
|
213
|
+
clearTimeout(timeoutId);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A 2xx with a missing/partial payload must NOT silently replace the known-good
|
|
217
|
+
// getWidgetConfig config with empty gates / mock products — that would disable the
|
|
218
|
+
// client session or drop experiments. Throw instead, so maybeUpgradeToBootstrap's
|
|
219
|
+
// catch falls back to the getWidgetConfig result it already holds. `gates` is
|
|
220
|
+
// required as a NON-EMPTY array: we always request the full gateNames list and
|
|
221
|
+
// only reach bootstrap because getWidgetConfig returned real gates, so an
|
|
222
|
+
// empty/absent gates response is degraded → fall back. (A products config that is
|
|
223
|
+
// present-but-malformed still degrades to mock below — only the top-level
|
|
224
|
+
// sections are required here.)
|
|
225
|
+
if (!result.org_config?.org?.org || !result.org_config?.gates?.length || !result.widget_config) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
'Bootstrap config response missing required sections (org_config.org.org / org_config.gates / widget_config)',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const orgConfig = result.org_config;
|
|
231
|
+
const widgetConfig = result.widget_config;
|
|
232
|
+
// The guard above guarantees org.org at runtime; the `?? {}` only satisfies the
|
|
233
|
+
// type-narrowing (TS doesn't carry the optional-chain narrowing through the alias).
|
|
234
|
+
const orgOrg = orgConfig.org?.org ?? {};
|
|
235
|
+
const resolution = toResolution(widgetConfig.resolution);
|
|
236
|
+
|
|
237
|
+
// A malformed products config degrades to mock defaults WITHOUT losing the
|
|
238
|
+
// org / gates / experiments that came back fine — mirrors fetchUnifiedGraphQLConfig.
|
|
239
|
+
let orgConfigValues: GraphQlConfigValues;
|
|
240
|
+
try {
|
|
241
|
+
orgConfigValues = { ...transformV3ProductsConfig(widgetConfig.products_config), resolution };
|
|
242
|
+
} catch (err) {
|
|
243
|
+
logger.logError('fetchBootstrapConfig | Error transforming products config', err);
|
|
244
|
+
orgConfigValues = { colorsConfig: undefined, frontendConfig: undefined, resolution };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const config: EnviveServiceConfig = {
|
|
248
|
+
// org_config is byte-identical to the legacy /v1/org/config (snake), the
|
|
249
|
+
// exact shape EnviveServiceConfig.org was built to hold. Constructed
|
|
250
|
+
// defensively (like fetchUnifiedGraphQLConfig) rather than passed through.
|
|
251
|
+
org: {
|
|
252
|
+
org: {
|
|
253
|
+
id: orgOrg.id ?? '',
|
|
254
|
+
short_name: orgOrg.short_name ?? '',
|
|
255
|
+
display_name: orgOrg.display_name ?? '',
|
|
256
|
+
domain: orgOrg.domain ?? '',
|
|
257
|
+
status: orgOrg.status ?? '',
|
|
258
|
+
created_at: orgOrg.created_at ?? '',
|
|
259
|
+
updated_at: orgOrg.updated_at ?? '',
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
gates: toBootstrapGates(orgConfig.gates),
|
|
263
|
+
// From widget_config, NOT org_config: getWidgetConfig sources assignments from
|
|
264
|
+
// its widget config (ungated), whereas bootstrap's `org_config.experiment_assignments`
|
|
265
|
+
// is populated ONLY when the `use_unified_config` gate evaluates true SERVER-SIDE
|
|
266
|
+
// (pymono `unified_config_active`). Reading org_config would drop assignments —
|
|
267
|
+
// and the amplitude `experiment.{ns}.*` enrichment — on the client-override test
|
|
268
|
+
// path (gate ON via `?use_unified_config=true` but 0% server-side). widget_config
|
|
269
|
+
// matches getWidgetConfig in every case (equal when the gate is on; version pin
|
|
270
|
+
// empties both).
|
|
271
|
+
experiment_assignments: toBootstrapAssignments(widgetConfig.experiment_assignments),
|
|
272
|
+
orgConfig: orgConfigValues,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
return { config, urlResolving: result.url_resolving ?? null };
|
|
276
|
+
};
|
|
@@ -151,10 +151,13 @@ const v3FrontendConfigCleanup = (
|
|
|
151
151
|
};
|
|
152
152
|
};
|
|
153
153
|
|
|
154
|
-
// Turns a raw productsConfig payload (from getProductsConfigByVersion
|
|
155
|
-
// getWidgetConfig
|
|
156
|
-
//
|
|
157
|
-
|
|
154
|
+
// Turns a raw productsConfig payload (from getProductsConfigByVersion,
|
|
155
|
+
// getWidgetConfig, or the /v1/session/bootstrap widget_config) into the
|
|
156
|
+
// camelCase GraphQlConfigValues shape the rest of the toolkit consumes.
|
|
157
|
+
// Returns mock config when colors are absent. Exported so fetchBootstrapConfig
|
|
158
|
+
// reuses the exact same transform (bootstrap's products_config carries the same
|
|
159
|
+
// `.{section}.values` shape as getWidgetConfig's productsConfig).
|
|
160
|
+
export const transformV3ProductsConfig = (
|
|
158
161
|
productsConfig:
|
|
159
162
|
| {
|
|
160
163
|
frontend?: { values?: { merchant_override_css?: string } };
|
|
@@ -4,7 +4,12 @@ import type { AppDetails } from 'src/atoms/app';
|
|
|
4
4
|
import { EnviveMetricsEventName, TrackEventParams } from 'src/services/amplitudeService';
|
|
5
5
|
import { getAtomStore } from 'src/atoms/atomStore/atomStore';
|
|
6
6
|
import { resolvedBaseConfigVersionAtom } from 'src/atoms/envive/resolvedBaseConfigVersion';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
HARDCOPY_TIMEOUT_MS,
|
|
9
|
+
HardcopyService,
|
|
10
|
+
MOCK_HARDCOPY_RESPONSE,
|
|
11
|
+
MockHardcopyService,
|
|
12
|
+
} from '../hardcopyService';
|
|
8
13
|
|
|
9
14
|
const { MockLogger, mockGetHardcopy, mockGetQueryParam } = vi.hoisted(() => {
|
|
10
15
|
const MockLogger = vi.fn(function () {
|
|
@@ -269,6 +274,38 @@ describe('HardcopyService', () => {
|
|
|
269
274
|
);
|
|
270
275
|
});
|
|
271
276
|
|
|
277
|
+
it.each([
|
|
278
|
+
[WidgetTypeV3.ProductCardV3, 'ImagePromptCardV3'],
|
|
279
|
+
[WidgetTypeV3.PromptButtonCarouselWithImageV3, 'SingleImagePromptV3'],
|
|
280
|
+
])(
|
|
281
|
+
'stamps the API-facing (translated) widget type on both events: %s → %s',
|
|
282
|
+
async (internalType, wireType) => {
|
|
283
|
+
// The AI-suggestions accounting joins these events against the backend registry,
|
|
284
|
+
// which is keyed by the wire type. Stamping the internal name zeroed the stats
|
|
285
|
+
// for aliased widgets (Naturalizer PLP).
|
|
286
|
+
const trackEvent = vi.fn();
|
|
287
|
+
mockGetHardcopy.mockResolvedValueOnce(makeWidgetTextResponse());
|
|
288
|
+
|
|
289
|
+
await makeService(trackEvent).getHardcopy({
|
|
290
|
+
widgetType: internalType,
|
|
291
|
+
userEvent: {} as any,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
295
|
+
expect.objectContaining({
|
|
296
|
+
eventName: EnviveMetricsEventName.WidgetTextRequest,
|
|
297
|
+
eventProps: expect.objectContaining({ widget_type: wireType }),
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
301
|
+
expect.objectContaining({
|
|
302
|
+
eventName: EnviveMetricsEventName.WidgetTextResponse,
|
|
303
|
+
eventProps: expect.objectContaining({ widget_type: wireType }),
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
272
309
|
it('forwards ai_suggestions_attribution_by_slot on WidgetTextResponse when present', async () => {
|
|
273
310
|
const trackEvent = vi.fn();
|
|
274
311
|
const attribution = {
|
|
@@ -385,6 +422,28 @@ describe('HardcopyService', () => {
|
|
|
385
422
|
}),
|
|
386
423
|
).rejects.toThrow('No hardcopy response found for widget type: UnknownWidget');
|
|
387
424
|
});
|
|
425
|
+
|
|
426
|
+
it('falls back to the mock when the API request hangs past the timeout', async () => {
|
|
427
|
+
// A request that never settles — the timeout is the only thing that can release it.
|
|
428
|
+
// Without it the widget's loading latch never resolves ("PLP staying in loading state").
|
|
429
|
+
vi.useFakeTimers();
|
|
430
|
+
try {
|
|
431
|
+
mockGetHardcopy.mockReturnValueOnce(new Promise(() => {}));
|
|
432
|
+
|
|
433
|
+
const resultPromise = makeService().getHardcopy({
|
|
434
|
+
widgetType: WidgetTypeV3.PromptCarouselV3,
|
|
435
|
+
userEvent: {} as any,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await vi.advanceTimersByTimeAsync(HARDCOPY_TIMEOUT_MS);
|
|
439
|
+
|
|
440
|
+
await expect(resultPromise).resolves.toBe(
|
|
441
|
+
MOCK_HARDCOPY_RESPONSE[WidgetTypeV3.PromptCarouselV3],
|
|
442
|
+
);
|
|
443
|
+
} finally {
|
|
444
|
+
vi.useRealTimers();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
388
447
|
});
|
|
389
448
|
});
|
|
390
449
|
|
|
@@ -4,6 +4,7 @@ import { WidgetString, WidgetText } from 'src/application/models/api/widgetText'
|
|
|
4
4
|
import { WidgetTextRequest } from 'src/application/models/api/widgetTextRequest';
|
|
5
5
|
import { toCamelCase } from 'src/application/models/utils/snakeToCamelTransformer';
|
|
6
6
|
import { getQueryParam } from 'src/application/utils';
|
|
7
|
+
import { coreWidgetTypeToApiWidgetType } from 'src/application/utils/widgetTextRequestToApiRequest';
|
|
7
8
|
import { UserEvent } from 'src/application/models';
|
|
8
9
|
import Logger from 'src/application/logging/logger';
|
|
9
10
|
import { AppDetails } from 'src/atoms/app';
|
|
@@ -14,6 +15,30 @@ import { EnviveMetricsEventName, TrackEventParams } from '../amplitudeService';
|
|
|
14
15
|
|
|
15
16
|
const logger = new Logger('hardcopyService');
|
|
16
17
|
|
|
18
|
+
// Longest a hardcopy request may hang before we stop waiting for it. The backend-error
|
|
19
|
+
// path below already falls back to static copy, but a request that never settles at all
|
|
20
|
+
// never reaches that catch — it left the widget's loading latch unresolved forever, which
|
|
21
|
+
// is the "PLP staying in loading state" failure. Racing the request against this timeout
|
|
22
|
+
// turns a hang into the same fallback path an error takes, so the widget always leaves its
|
|
23
|
+
// loading state.
|
|
24
|
+
//
|
|
25
|
+
// Kept under the ~4-5s at which session replays show shoppers abandon a still-loading
|
|
26
|
+
// widget: a rescue that fires after they've left helps no one. When it fires, the catch
|
|
27
|
+
// below logs an error (message includes "timed out") — that log is the signal monitoring
|
|
28
|
+
// can count to measure how often hardcopy stalls.
|
|
29
|
+
export const HARDCOPY_TIMEOUT_MS = 4000;
|
|
30
|
+
|
|
31
|
+
// Reject if `promise` has not settled within `ms`. The underlying request is not aborted
|
|
32
|
+
// (a late response is simply discarded) — the only goal is to stop *waiting*, so the
|
|
33
|
+
// caller's error handling can run.
|
|
34
|
+
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> => {
|
|
35
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
36
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
37
|
+
timer = setTimeout(() => reject(new Error(`hardcopy request timed out after ${ms}ms`)), ms);
|
|
38
|
+
});
|
|
39
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise<T>;
|
|
40
|
+
};
|
|
41
|
+
|
|
17
42
|
export interface HardcopyRequest {
|
|
18
43
|
widgetType: WidgetTypeV3;
|
|
19
44
|
userEvent: UserEvent;
|
|
@@ -218,23 +243,40 @@ export class HardcopyService implements IHardcopyService {
|
|
|
218
243
|
url: window.location.href,
|
|
219
244
|
};
|
|
220
245
|
|
|
246
|
+
// The Widget Text events must carry the API-facing (translated) widget type, not the
|
|
247
|
+
// internal one: the AI-suggestions accounting joins these events against the backend
|
|
248
|
+
// registry, which is keyed by the wire type (e.g. ProductCardV3 is sent — and stored —
|
|
249
|
+
// as ImagePromptCardV3). Stamping the internal name here zeroed those stats. An
|
|
250
|
+
// unmapped type keeps the internal name — analytics must never block serving.
|
|
251
|
+
let wireWidgetType: string = request.widgetType;
|
|
252
|
+
try {
|
|
253
|
+
wireWidgetType = coreWidgetTypeToApiWidgetType(request.widgetType);
|
|
254
|
+
} catch {
|
|
255
|
+
logger.logDebug('no wire mapping for widget type — keeping internal name', {
|
|
256
|
+
widgetType: request.widgetType,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
221
260
|
this.config.trackEvent?.({
|
|
222
261
|
eventName: EnviveMetricsEventName.WidgetTextRequest,
|
|
223
262
|
eventProps: {
|
|
224
|
-
widget_type:
|
|
263
|
+
widget_type: wireWidgetType,
|
|
225
264
|
request_id: widgetTextRequest.requestId,
|
|
226
265
|
},
|
|
227
266
|
});
|
|
228
267
|
|
|
229
268
|
try {
|
|
230
|
-
const response = await
|
|
269
|
+
const response = await withTimeout(
|
|
270
|
+
CommerceApiClient.getHardcopy(widgetTextRequest),
|
|
271
|
+
HARDCOPY_TIMEOUT_MS,
|
|
272
|
+
);
|
|
231
273
|
if (response) {
|
|
232
274
|
const convertedResponse = convertToHardcopyResponse(response);
|
|
233
275
|
logger.logDebug('converted response', convertedResponse);
|
|
234
276
|
this.config.trackEvent?.({
|
|
235
277
|
eventName: EnviveMetricsEventName.WidgetTextResponse,
|
|
236
278
|
eventProps: {
|
|
237
|
-
widget_type:
|
|
279
|
+
widget_type: wireWidgetType,
|
|
238
280
|
response_id: response.responseId,
|
|
239
281
|
widget_text: response.values,
|
|
240
282
|
request_id: widgetTextRequest.requestId,
|