feat: add headless CDN API client and provider methods - #126
feat: add headless CDN API client and provider methods#126ethanpschoen wants to merge 16 commits into
Conversation
Expose pre-WebView headless endpoints via KetchHeadless, wire them through KetchServiceProvider, and harden JSON/network error handling in the client.
When jurisdiction/environment aren't fully set, getFullConfiguration() falls back to the short config.json path, which previously carried no language hint. Now the short path always sends a language query param: the explicit languageCode if set, otherwise the device locale (formatted to match ketch-tag's formatLanguage convention), plus an Accept-Language header. jurisdiction/region are forwarded when explicitly set. The full static path is unchanged. Device-locale detection is extracted from KetchServiceProvider into a shared util so both the WebView and headless paths use it.
hasUsableConsentFields checked purposes and protocols but not vendors, so a consent response carrying only vendors was discarded and replaced with an empty consent. The test asserted this behaviour, so it is updated too. Also applies pending prettier formatting in the same test file, which was failing lint on this branch.
trigger() fires `ketch('trigger', 'custom', fn, options)` on the booted tag.
Calls made before the tag is ready are queued one deep and dispatched on the
onConfigLoaded event rather than on isServiceReady, which flips on the first
message of any kind and so can fire before the tag can accept calls. A later call
supersedes an earlier queued one, and a WebView remount drops it.
Returns false for an invalid function name or while an experience is showing.
Function names are validated against the same pattern ketch-tag uses, which is the
only guard against breaking out of the quoted JS literal.
onWillShowExperience surfaces an event that was previously handled internally only.
Adds OnHideExperienceArgument.none, WillShowExperienceType, and TriggerName; an
unrecognized hide reason now reports none instead of passing through a raw string
that the parameter type claimed was an enum member.
…add ketchMobileSdkUrl getRegion prefers a locally set regionCode and otherwise resolves a GeoIP lookup, cached for the lifetime of the provider. getJurisdiction prefers a locally set jurisdictionCode and otherwise reads the resolved code from the CDN configuration. Both mirror the Android SDK. getLocation is no longer on the service context; it remains on KetchHeadless as the low-level client call. ketchMobileSdkUrl overrides the CDN base URL for both the WebView and the headless client, which previously derived it from dataCenter alone. It is applied after the parameter loop so it wins regardless of key iteration order.
The IAB strings the tag persists could only be read by calling nativeStorage.read with key names the package did not export. Adds the key constants and getSavedString / getTCFTCString / getUSPrivacyString / getGPPHDRGppString to the service context, matching the Android and Flutter SDKs. These return promises rather than values, unlike the synchronous Android equivalents, because native storage access is asynchronous here.
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 381f86c. Configure here.
| useEffect(() => { | ||
| isConfigLoadedRef.current = false; | ||
| pendingTriggerRef.current = null; | ||
| }, [webViewMountKey]); |
There was a problem hiding this comment.
Mount effect drops queued trigger
High Severity
The webViewMountKey's useEffect clears pendingTriggerRef on mount and key changes. This can drop a successfully queued trigger() call before the WebView's onConfigLoaded event can process it, preventing the trigger from firing.
Reviewed by Cursor Bugbot for commit 381f86c. Configure here.
| propertyCode, | ||
| identities, | ||
| languageCode = deviceLanguage, | ||
| languageCode = getDeviceLanguage(), |
There was a problem hiding this comment.
Raw locale breaks headless language
Medium Severity
The languageCode defaults to the raw device language (e.g., en_US on Android) and is passed unformatted to getFullConfiguration by getJurisdiction. The headless client's BCP-47 formatting logic is bypassed when languageCode is explicitly provided, causing the CDN to receive an incorrect language tag.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 381f86c. Configure here.
| if (request.jurisdictionCode) | ||
| query.jurisdiction = request.jurisdictionCode; | ||
| if (request.regionCode) query.region = request.regionCode; | ||
| } |
There was a problem hiding this comment.
Environment dropped without language
Medium Severity
getFullConfiguration only uses the full path when environmentCode, jurisdictionCode, and languageCode are all truthy. If environmentCode and jurisdictionCode are set but languageCode is omitted, the client takes the short path and never sends environmentCode (it is not a short-path query param), even though short path already defaults language from the device. Staging/production selection is silently ignored in that case.
Reviewed by Cursor Bugbot for commit 381f86c. Configure here.
The README covered install and provider setup only. Documents the experience methods, rule triggers, the consent and privacy string getters, and the provider props beyond the required codes.
getLocation was gone from the useKetchService() context but still reachable via new HeadlessApiClient().getLocation(), since the client class was exported from the package barrel. Neither symbol has shipped in a released tag. Stops exporting HeadlessApiClient from headless/index.ts. KetchHeadless gains getRegion(), which performs its own cached GeoIP lookup internally instead of exposing getLocation() at all - mirroring Android's already-internal HeadlessApiClient.getLocation. The provider's getRegion now delegates to it instead of managing its own location cache.
These were optional only because ketchServiceContext.ts's default object never declared them, even though KetchServiceProvider already supplies all three unconditionally in its context value. Making them required is what actually gives RN's headless surface the same always-there availability Android/iOS have.
|
Superseded by a stack of 5 PRs: #137 → #138 → #139 → #140 → #141. Converting to draft; kept open to preserve review history. Will close when the stack lands. All 3 unresolved review threads on this PR are addressed in the stack:
|
getSavedString and the IAB helpers always read through nativeStorage.read, so they never saw values persisted via a custom preferenceStorage backend. That backend is required for Expo Android in the README, and on that setup crossPlatformRead falls back to React Native Settings, which is a no-op on Android — so these accessors returned empty strings after consent updates whenever preferenceStorage was configured. This PR adds the capability (an optional reader parameter, defaulting to the existing nativeStorage.read). The provider wiring that actually builds a reader from the configured preferenceStorage and binds these accessors to it lands in #141, alongside the rest of the KetchService exposure. Refs #126.
Adds an imperative trigger() method that fires an onFunction rule trigger and queues until the tag's config has loaded, plus enum values for normalizing the hide/show experience callbacks. onHideExperience now receives a normalized enum, falling back to none, instead of the raw tag payload. Also carries the ketch_mobilesdk_url URL-param override, which lives in the same helpers.ts region as the new trigger helpers. Part 3/5 of the split of #126.
The webViewMountKey reset effect nulled the pending trigger on every mount-key change, including the async ATT-driven change that always fires once on iOS before the WebView's first mount. trigger() had already returned true by then, so the call was silently swallowed instead of firing once the tag booted. Refs #126.
getDeviceLanguage() returns the raw platform identifier, underscore-separated on Android (en_US) and on iOS whenever AppleLocale is used. Every default-configured install therefore sent language=en_US to both the headless CDN client and the WebView, since the headless client's own BCP-47 formatting is only reached when languageCode is falsy — and the provider always supplied one. Refs #126.
Adds a standalone client for the config/consent/rights/subscriptions CDN endpoints, independent of the WebView-hosted tag. Part 4/5 of the split of #126.
…onfig requests The long config path (which carries environment) was only taken when environment, jurisdiction, and language were all present. A caller who set environment and jurisdiction but not language fell to the short path, which carries no environment at all, so a staging-configured app with a jurisdiction set silently received the default environment's config. Synthesize language from the device locale whenever environment and jurisdiction are both set, so the long path is always used in that case. Not adding ?environment= to the short path — nothing in ketch-tag suggests the CDN reads it. Refs #126.
parseConsent() left purposes undefined whenever a server response omitted it, while emptyConsent() returns { purposes: {} }. A vendors-only or protocols-only response therefore returned an inconsistent shape, and a caller doing Object.keys(consent.purposes) on it would throw.
Refs #126.
Wires the headless CDN client into the provider: getRegion, getJurisdiction, getBootstrapConfiguration, getFullConfiguration, fetchConsent, setConsentOnServer, invokeRight, getSubscriptions, setSubscriptions, and preferenceQRUrl are now exposed via useKetchService(), alongside a ketchMobileSdkUrl override that takes precedence over the data-center-implied CDN URL for both the WebView and the headless client. Breaking change: getRegion, getJurisdiction, and fetchConsent are now required on the KetchService interface, not optional. Any external implementer of KetchService must add them. Part 5/5 of the split of #126.
getSavedString and the IAB helpers accepted an optional reader (previous PR in this stack) but nothing in the provider ever passed one, so they always read via nativeStorage regardless of preferenceStorage. Writes went through preferenceStorage when configured; reads did not, so privacy strings written to a custom backend (the README's own recommendation for Expo Android) were invisible to these accessors. Adds a read counterpart to preferenceStorage (SharedPrefencesInterface.getItemAsync, optional) and a readPreference builder mirroring the existing storePreference one, then binds the four accessors to it before exposing them via contextValue. Refs #126.


Description of this change
Adds the pre-WebView headless CDN API and brings the React Native surface in line with ketch-android.
KetchHeadless/HeadlessApiClient(web/v3), surfaced throughKetchServiceProvideranduseKetchService().trigger()for onFunction rules. Calls made before the tag has loaded its config are queued one deep and dispatched ononConfigLoaded.getRegion()/getJurisdiction()— preferring a locally set value over a network lookup, with the GeoIP result cached per provider.getLocation()is no longer on the service context.getSavedString/getTCFTCString/getUSPrivacyString/getGPPHDRGppString, plus the IAB key constants.onWillShowExperiencecallback,ketchMobileSdkUrlto override the CDN base URL, and theTriggerName/WillShowExperienceType/OnHideExperienceArgument.noneenum values.Fix: a consent response containing only
vendorswas discarded and replaced with an empty consent. An unrecognized hide reason now maps tononeinstead of passing through a raw string the parameter type claimed was an enum member.Why is this change being made?
How was this tested? How can the reviewer verify your testing?
cd package && npm run typecheck && npm run lint && npm test— 59 tests, 0 failures.KETCH_INTEGRATION_TESTS=1 npm run test:integration.Note for reviewers: CI does not run the test suite —
npm run allcovers lint, typecheck, and build only. Tests need running locally.Related issues
KD-17456
Checklist
Note
Medium Risk
Touches consent fetch/update, WebView JS injection for triggers, and new pre-WebView network paths that affect jurisdiction/region resolution; mitigated by validation, tests, and conservative consent parsing fallbacks.
Overview
Adds a fetch-based headless web/v3 layer (
KetchHeadless/HeadlessApiClient) and wires it throughKetchServiceProvider/useKetchService()so apps can call GeoIP, bootstrap/full config, server consent, rights, subscriptions, and preference QR URLs without relying on the WebView.trigger()runs onFunction rules via injectedketch("trigger", …)JS, with one-deep queuing untilonConfigLoaded(not merely first WebView message). Function names are validated to block injection into the quoted literal.New service surface includes
getRegion/getJurisdiction(local params beat network; GeoIP cached per provider), IAB privacy string readers from native storage,onWillShowExperience, andketchMobileSdkUrloverriding the CDN base for both WebView init params and headless calls. Device locale is centralized viagetDeviceLanguage/formatLanguageTagfor config requests.Fixes: headless
getConsentnow keeps vendors-only responses;onHideExperiencemaps unknown reasons tononeinstead of raw strings.Broad unit tests plus optional
test:integrationagainst live CDN.Reviewed by Cursor Bugbot for commit 381f86c. Configure here.