@namiml/sdk-core 3.5.0-dev.202611020145 → 3.5.0-dev.202611022057
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/index.cjs +207 -9
- package/dist/index.d.ts +8 -1
- package/dist/index.mjs +207 -9
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -106,7 +106,7 @@ const {
|
|
|
106
106
|
// version — stamped by scripts/version.sh
|
|
107
107
|
NAMI_SDK_VERSION: exports.NAMI_SDK_VERSION = "3.5.0",
|
|
108
108
|
// full package version including dev suffix — stamped by scripts/version.sh
|
|
109
|
-
NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.
|
|
109
|
+
NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202611022057",
|
|
110
110
|
// environments
|
|
111
111
|
PRODUCTION: exports.PRODUCTION = "production", DEVELOPMENT: exports.DEVELOPMENT = "development",
|
|
112
112
|
// error messages
|
|
@@ -57121,6 +57121,8 @@ function isSupportedJourney(meta) {
|
|
|
57121
57121
|
return true;
|
|
57122
57122
|
}
|
|
57123
57123
|
const isJourneyCampaign = (rule) => rule.type === exports.NamiCampaignRuleType.JOURNEY;
|
|
57124
|
+
/** The parsed journey metadata of a journey rule; null for a placement rule or no metadata. */
|
|
57125
|
+
const journeyMetadataOf = (rule) => isJourneyCampaign(rule) ? parseJourneyMetadata(rule.journey) : null;
|
|
57124
57126
|
/**
|
|
57125
57127
|
* Whether this SDK can use the rule. Every placement rule qualifies. A journey also needs
|
|
57126
57128
|
* supported metadata and a flow, because journeys are flow-only. Callers skip an unsupported
|
|
@@ -62880,6 +62882,16 @@ class JourneyEngine {
|
|
|
62880
62882
|
recordDismiss(s, journeyId, now) {
|
|
62881
62883
|
return this.withRecord(s, journeyId, { ...(s.journeys[journeyId] ?? emptyJourneyRecord()), lastDismissedAt: now.getTime() });
|
|
62882
62884
|
}
|
|
62885
|
+
/**
|
|
62886
|
+
* Whether a goal signal counts now (NAM-3236): the journey was entered (an impression since
|
|
62887
|
+
* any earlier goal) and its `timeout_days` window, measured from entry, is still open.
|
|
62888
|
+
*/
|
|
62889
|
+
isGoalOpen(record, journey, now) {
|
|
62890
|
+
if (record?.enteredAt == null)
|
|
62891
|
+
return false;
|
|
62892
|
+
const timeout = journey.goal?.timeout_days;
|
|
62893
|
+
return timeout == null || now.getTime() < record.enteredAt + timeout * DAY_MS;
|
|
62894
|
+
}
|
|
62883
62895
|
/** Records goal completion and clears entry so a re-entry journey starts a fresh timeout. */
|
|
62884
62896
|
recordGoal(s, journeyId, now) {
|
|
62885
62897
|
return this.withRecord(s, journeyId, {
|
|
@@ -63042,6 +63054,11 @@ function presentJourneyCampaign(campaign, stillValid) {
|
|
|
63042
63054
|
return ui.presentCampaign(campaign, { customAttributes: {} });
|
|
63043
63055
|
}
|
|
63044
63056
|
|
|
63057
|
+
let refreshHost = null;
|
|
63058
|
+
/** Called by `NamiRefs` when it is constructed. */
|
|
63059
|
+
const registerJourneyRefreshHost = (host) => {
|
|
63060
|
+
refreshHost = host;
|
|
63061
|
+
};
|
|
63045
63062
|
const monotonicNow = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
63046
63063
|
const liveJourneyEnvironment = {
|
|
63047
63064
|
now: () => new Date(),
|
|
@@ -63056,14 +63073,21 @@ const liveJourneyEnvironment = {
|
|
|
63056
63073
|
get: () => storageService.getJourneyInstallEvaluated(),
|
|
63057
63074
|
set: () => storageService.setJourneyInstallEvaluated(),
|
|
63058
63075
|
},
|
|
63076
|
+
refresh: () => refreshHost?.fetchCampaignsAndPaywalls() ?? Promise.resolve(),
|
|
63077
|
+
refreshAllowed: () => !(refreshHost?.isStartupInFlight() ?? false),
|
|
63078
|
+
// Only while the platform has journey rules: an org with none sees no refresh traffic, and picks
|
|
63079
|
+
// up newly added journeys on the next session's startup fetch. `journey_settings` does not
|
|
63080
|
+
// count: the backend sends it in every platform config, journeys or not.
|
|
63081
|
+
refreshEnabled: () => (refreshHost?.canRefreshJourneys() ?? false) && journeyCampaigns().length > 0,
|
|
63059
63082
|
};
|
|
63060
|
-
const journeyIdOf = (rule) =>
|
|
63083
|
+
const journeyIdOf = (rule) => journeyMetadataOf(rule)?.journey_id;
|
|
63061
63084
|
/**
|
|
63062
63085
|
* Drives journey delivery for the `app_install` and `session_start` triggers (NAM-3233):
|
|
63063
|
-
* evaluation, the single delay slot, the fire-time re-check, delivery
|
|
63064
|
-
* accounting. A port of Apple's `JourneyCoordinator`
|
|
63065
|
-
*
|
|
63066
|
-
* generation alone keeps work from before a
|
|
63086
|
+
* evaluation, the single delay slot, the fire-time re-check, delivery, local impression
|
|
63087
|
+
* accounting, and campaign-rule refresh coalescing. A port of Apple's `JourneyCoordinator`
|
|
63088
|
+
* (NAM-3234), plus goal recording (NAM-3236), which Apple gains in NAM-3237. JS is
|
|
63089
|
+
* single-threaded, so there is no queue: the reset generation alone keeps work from before a
|
|
63090
|
+
* `Nami.reset()` off the fresh state.
|
|
63067
63091
|
*
|
|
63068
63092
|
* Guards on the way to a raise:
|
|
63069
63093
|
* - A winner chosen while the app is hidden starts its delay paused, a 0-second delay included,
|
|
@@ -63080,6 +63104,11 @@ class JourneyCoordinator {
|
|
|
63080
63104
|
this.engine = new JourneyEngine();
|
|
63081
63105
|
this.resetGeneration = 0;
|
|
63082
63106
|
this.backgroundedAt = null;
|
|
63107
|
+
this.refreshInFlight = false;
|
|
63108
|
+
this.refreshPending = false;
|
|
63109
|
+
/** A purchase asked for a refresh while a startup pass was running; flushed once when it ends. */
|
|
63110
|
+
this.refreshDeferred = false;
|
|
63111
|
+
this.refreshTimer = null;
|
|
63083
63112
|
this.delay = new JourneyDelayTimer(env.monotonicNow);
|
|
63084
63113
|
}
|
|
63085
63114
|
/**
|
|
@@ -63109,14 +63138,17 @@ class JourneyCoordinator {
|
|
|
63109
63138
|
}
|
|
63110
63139
|
journeyStateStore.startSession();
|
|
63111
63140
|
this.evaluate(isInstallCandidate);
|
|
63141
|
+
this.startRefreshTimer();
|
|
63112
63142
|
}
|
|
63113
|
-
/** Pauses the delay, keeping the remainder, and starts measuring the session gap. */
|
|
63143
|
+
/** Pauses the delay, keeping the remainder, stops the refresh timer and starts measuring the session gap. */
|
|
63114
63144
|
appDidEnterBackground() {
|
|
63115
63145
|
this.delay.pause();
|
|
63146
|
+
this.stopRefreshTimer();
|
|
63116
63147
|
this.backgroundedAt ??= this.env.monotonicNow();
|
|
63117
63148
|
}
|
|
63118
63149
|
/**
|
|
63119
|
-
* Resumes the delay with the remaining time, including one held since a hidden start
|
|
63150
|
+
* Resumes the delay with the remaining time, including one held since a hidden start, and
|
|
63151
|
+
* restarts the refresh timer.
|
|
63120
63152
|
*
|
|
63121
63153
|
* @returns whether this return begins a new session: it came at least {@link SESSION_GAP_MS}
|
|
63122
63154
|
* after the background (the session boundary Apple and Android share). A foreground with no
|
|
@@ -63126,17 +63158,152 @@ class JourneyCoordinator {
|
|
|
63126
63158
|
const hiddenAt = this.backgroundedAt;
|
|
63127
63159
|
this.backgroundedAt = null;
|
|
63128
63160
|
this.delay.resume();
|
|
63161
|
+
this.startRefreshTimer();
|
|
63129
63162
|
return hiddenAt !== null && this.env.monotonicNow() - hiddenAt >= exports.SESSION_GAP_MS;
|
|
63130
63163
|
}
|
|
63131
63164
|
/** A placement campaign is about to be raised: a journey still waiting must not fire on top of it. */
|
|
63132
63165
|
placementWillRaise() {
|
|
63133
63166
|
this.delay.cancel();
|
|
63134
63167
|
}
|
|
63135
|
-
/**
|
|
63168
|
+
/**
|
|
63169
|
+
* Cancels the delay and the refresh timer, clears refresh state and invalidates in-flight work
|
|
63170
|
+
* (`Nami.reset()`), before the storage wipe.
|
|
63171
|
+
*/
|
|
63136
63172
|
reset() {
|
|
63137
63173
|
this.delay.cancel();
|
|
63174
|
+
this.stopRefreshTimer();
|
|
63138
63175
|
this.backgroundedAt = null;
|
|
63139
63176
|
this.resetGeneration += 1;
|
|
63177
|
+
this.refreshInFlight = false;
|
|
63178
|
+
this.refreshPending = false;
|
|
63179
|
+
this.refreshDeferred = false;
|
|
63180
|
+
}
|
|
63181
|
+
// Goals (NAM-3236)
|
|
63182
|
+
/**
|
|
63183
|
+
* A flow's completion step matched its condition. For a journey whose goal is
|
|
63184
|
+
* `flow_completed`, that is the goal; the completion PATCH that reports it is the flow's own.
|
|
63185
|
+
*
|
|
63186
|
+
* @returns whether a goal was recorded.
|
|
63187
|
+
*/
|
|
63188
|
+
flowCompletionSignaled(campaign) {
|
|
63189
|
+
const journey = campaign ? journeyMetadataOf(campaign) : null;
|
|
63190
|
+
if (journey?.goal?.type !== "flow_completed" || !this.goalReached(journey))
|
|
63191
|
+
return false;
|
|
63192
|
+
this.requestRefresh();
|
|
63193
|
+
return true;
|
|
63194
|
+
}
|
|
63195
|
+
/**
|
|
63196
|
+
* A customer attribute was set. Records the goal of every journey whose `user_context_set` goal
|
|
63197
|
+
* watches `key`, then refreshes the rules when the value changed (journey eligibility can depend
|
|
63198
|
+
* on attributes) or a goal was recorded (it can unlock the next journey).
|
|
63199
|
+
*/
|
|
63200
|
+
customerAttributeSet(key, changed) {
|
|
63201
|
+
let goal = false;
|
|
63202
|
+
for (const rule of this.env.journeys()) {
|
|
63203
|
+
const journey = journeyMetadataOf(rule);
|
|
63204
|
+
if (journey?.goal?.type === "user_context_set" && journey.goal.context_key === key) {
|
|
63205
|
+
goal = this.goalReached(journey) || goal;
|
|
63206
|
+
}
|
|
63207
|
+
}
|
|
63208
|
+
if (changed || goal)
|
|
63209
|
+
this.requestRefresh();
|
|
63210
|
+
}
|
|
63211
|
+
/**
|
|
63212
|
+
* Records the goal and exits the journey: `goalAchievedAt` is stamped and entry cleared, so the
|
|
63213
|
+
* engine rejects it (`goalAchieved`) unless it allows re-entry. Counts only while the journey is
|
|
63214
|
+
* entered and inside its `timeout_days` window; anything else (never shown, already achieved,
|
|
63215
|
+
* timed out) is ignored.
|
|
63216
|
+
*/
|
|
63217
|
+
goalReached(journey) {
|
|
63218
|
+
const id = journey.journey_id;
|
|
63219
|
+
const now = this.env.now();
|
|
63220
|
+
if (!this.engine.isGoalOpen(journeyStateStore.snapshot().journeys[id], journey, now)) {
|
|
63221
|
+
logger.debug(`Journey ${id} goal ignored: not entered, already achieved or timed out`);
|
|
63222
|
+
return false;
|
|
63223
|
+
}
|
|
63224
|
+
journeyStateStore.update((s) => this.engine.recordGoal(s, id, now));
|
|
63225
|
+
logger.debug(`Journey ${id} goal achieved`);
|
|
63226
|
+
return true;
|
|
63227
|
+
}
|
|
63228
|
+
// Refresh
|
|
63229
|
+
/**
|
|
63230
|
+
* Re-fetches campaign rules. At most one fetch is in flight, plus one trailing fetch if more
|
|
63231
|
+
* requests arrive meanwhile. Never evaluates journeys by itself: a newly eligible journey fires on
|
|
63232
|
+
* its own next trigger.
|
|
63233
|
+
*
|
|
63234
|
+
* While a startup pass runs the request is dropped, because the pass fetches the rules itself.
|
|
63235
|
+
* A purchase passes `deferIfBusy` so it is not lost: it refreshes once when the pass ends.
|
|
63236
|
+
*/
|
|
63237
|
+
requestRefresh(deferIfBusy = false) {
|
|
63238
|
+
if (!this.env.refreshEnabled())
|
|
63239
|
+
return;
|
|
63240
|
+
if (this.refreshInFlight) {
|
|
63241
|
+
this.refreshPending = true;
|
|
63242
|
+
}
|
|
63243
|
+
else if (this.env.refreshAllowed()) {
|
|
63244
|
+
this.startRefresh();
|
|
63245
|
+
}
|
|
63246
|
+
else if (deferIfBusy) {
|
|
63247
|
+
this.refreshDeferred = true;
|
|
63248
|
+
}
|
|
63249
|
+
}
|
|
63250
|
+
/** A startup or resume pass ended, either way. Flushes a refresh a purchase deferred during it. */
|
|
63251
|
+
startupPassFinished() {
|
|
63252
|
+
if (!this.refreshDeferred)
|
|
63253
|
+
return;
|
|
63254
|
+
this.refreshDeferred = false;
|
|
63255
|
+
if (!this.env.refreshEnabled())
|
|
63256
|
+
return;
|
|
63257
|
+
if (this.refreshInFlight) {
|
|
63258
|
+
this.refreshPending = true;
|
|
63259
|
+
}
|
|
63260
|
+
else {
|
|
63261
|
+
this.startRefresh();
|
|
63262
|
+
}
|
|
63263
|
+
}
|
|
63264
|
+
startRefresh() {
|
|
63265
|
+
this.refreshInFlight = true;
|
|
63266
|
+
const generation = this.resetGeneration;
|
|
63267
|
+
const settle = () => {
|
|
63268
|
+
if (this.resetGeneration !== generation)
|
|
63269
|
+
return;
|
|
63270
|
+
this.refreshInFlight = false;
|
|
63271
|
+
if (this.refreshPending) {
|
|
63272
|
+
this.refreshPending = false;
|
|
63273
|
+
this.requestRefresh(true);
|
|
63274
|
+
}
|
|
63275
|
+
};
|
|
63276
|
+
let fetch;
|
|
63277
|
+
try {
|
|
63278
|
+
fetch = this.env.refresh();
|
|
63279
|
+
}
|
|
63280
|
+
catch (error) {
|
|
63281
|
+
fetch = Promise.reject(error);
|
|
63282
|
+
}
|
|
63283
|
+
fetch.then(settle, (error) => {
|
|
63284
|
+
logger.warn(`Journey refresh failed: ${error}`);
|
|
63285
|
+
settle();
|
|
63286
|
+
});
|
|
63287
|
+
}
|
|
63288
|
+
/**
|
|
63289
|
+
* Starts the repeating `refresh_interval_seconds` timer, but only while the app is visible: it
|
|
63290
|
+
* never runs in the background. Unreferenced in Node, so it never keeps a CLI process alive.
|
|
63291
|
+
*/
|
|
63292
|
+
startRefreshTimer() {
|
|
63293
|
+
if (this.refreshTimer !== null || !this.env.isAppVisible())
|
|
63294
|
+
return;
|
|
63295
|
+
const ms = this.env.settings().refresh_interval_seconds * 1000;
|
|
63296
|
+
if (!(ms > 0))
|
|
63297
|
+
return;
|
|
63298
|
+
const timer = setInterval(() => this.requestRefresh(), ms);
|
|
63299
|
+
timer.unref?.();
|
|
63300
|
+
this.refreshTimer = timer;
|
|
63301
|
+
}
|
|
63302
|
+
stopRefreshTimer() {
|
|
63303
|
+
if (this.refreshTimer === null)
|
|
63304
|
+
return;
|
|
63305
|
+
clearInterval(this.refreshTimer);
|
|
63306
|
+
this.refreshTimer = null;
|
|
63140
63307
|
}
|
|
63141
63308
|
// Evaluation
|
|
63142
63309
|
evaluate(isInstallCandidate) {
|
|
@@ -63239,6 +63406,15 @@ class NamiRefs {
|
|
|
63239
63406
|
this.startupFetch = Promise.resolve();
|
|
63240
63407
|
// Register this instance to avoid circular dependency issues
|
|
63241
63408
|
setNamiRefsInstance(this);
|
|
63409
|
+
registerJourneyRefreshHost(this);
|
|
63410
|
+
}
|
|
63411
|
+
/** Whether a startup or resume pass is running; journey refresh waits for it (NAM-3236). */
|
|
63412
|
+
isStartupInFlight() {
|
|
63413
|
+
return this.startupInFlight;
|
|
63414
|
+
}
|
|
63415
|
+
/** Whether configure has run with a real app platform id and startup is not disabled. */
|
|
63416
|
+
canRefreshJourneys() {
|
|
63417
|
+
return !!this.lastConfig?.appPlatformID?.trim() && !this.startupDisabled;
|
|
63242
63418
|
}
|
|
63243
63419
|
setInMemoryAnonymousMode(enabled) {
|
|
63244
63420
|
this.inMemoryAnonymousMode = enabled;
|
|
@@ -63287,6 +63463,7 @@ class NamiRefs {
|
|
|
63287
63463
|
}
|
|
63288
63464
|
finally {
|
|
63289
63465
|
this.startupInFlight = false;
|
|
63466
|
+
JourneyCoordinator.shared.startupPassFinished();
|
|
63290
63467
|
}
|
|
63291
63468
|
}
|
|
63292
63469
|
/**
|
|
@@ -63349,6 +63526,7 @@ class NamiRefs {
|
|
|
63349
63526
|
}
|
|
63350
63527
|
finally {
|
|
63351
63528
|
this.startupInFlight = false;
|
|
63529
|
+
JourneyCoordinator.shared.startupPassFinished();
|
|
63352
63530
|
}
|
|
63353
63531
|
}
|
|
63354
63532
|
/** Everything startup runs once the platform-config step has found a usable config. */
|
|
@@ -64996,6 +65174,15 @@ let NamiPaywallManager$2 = class NamiPaywallManager {
|
|
|
64996
65174
|
* @returns {Promise<void>} A Promise that resolves when buying SKU will be complete.
|
|
64997
65175
|
*/
|
|
64998
65176
|
static async buySkuComplete(purchase) {
|
|
65177
|
+
try {
|
|
65178
|
+
await this.reportPurchaseComplete(purchase);
|
|
65179
|
+
}
|
|
65180
|
+
finally {
|
|
65181
|
+
// A host-managed purchase changes journey eligibility too: re-fetch the rules (NAM-3236).
|
|
65182
|
+
JourneyCoordinator.shared.requestRefresh(true);
|
|
65183
|
+
}
|
|
65184
|
+
}
|
|
65185
|
+
static async reportPurchaseComplete(purchase) {
|
|
64999
65186
|
// clear loading indicator
|
|
65000
65187
|
PaywallState.setPurchaseInProgress(false);
|
|
65001
65188
|
if (NamiFlowManager$2.instance.flowOpen) {
|
|
@@ -65920,6 +66107,7 @@ let NamiCustomerManager$2 = class NamiCustomerManager {
|
|
|
65920
66107
|
if (!this.instance.isSDKInitialized) {
|
|
65921
66108
|
throw new SDKNotInitializedError();
|
|
65922
66109
|
}
|
|
66110
|
+
const changed = storageService.getCustomerAttribute(key) !== value;
|
|
65923
66111
|
storageService.setCustomerAttribute(key, value);
|
|
65924
66112
|
if (!hasCapability(exports.Capabilities.PAYWALL_PERSONALIZATION)) {
|
|
65925
66113
|
logger.warn(`The customer attribute ${key} is not available for using on a paywall, because your account's plan does not include paywall personalization. Contact support@nami.ml for details.`);
|
|
@@ -65931,12 +66119,19 @@ let NamiCustomerManager$2 = class NamiCustomerManager {
|
|
|
65931
66119
|
if (NamiFlowManager$2.instance.flowOpen) {
|
|
65932
66120
|
const currentFlow = NamiFlowManager$2.instance.currentFlow;
|
|
65933
66121
|
if (currentFlow) {
|
|
66122
|
+
// An open journey flow whose `user_context_set` goal watches this key reports the goal
|
|
66123
|
+
// through its completion step (NAM-3236), before the lifecycle can move it off the page.
|
|
66124
|
+
const goal = currentFlow.campaign ? journeyMetadataOf(currentFlow.campaign)?.goal : null;
|
|
66125
|
+
if (goal?.type === "user_context_set" && goal.context_key === key) {
|
|
66126
|
+
currentFlow.completionSignal("context_set");
|
|
66127
|
+
}
|
|
65934
66128
|
const currentStep = currentFlow.currentFlowStep;
|
|
65935
66129
|
if (currentStep) {
|
|
65936
66130
|
currentFlow.executeLifecycle(currentStep, NamiReservedActions.TAG_UPDATE);
|
|
65937
66131
|
}
|
|
65938
66132
|
}
|
|
65939
66133
|
}
|
|
66134
|
+
JourneyCoordinator.shared.customerAttributeSet(key, changed);
|
|
65940
66135
|
}
|
|
65941
66136
|
/**
|
|
65942
66137
|
* Retrieves the value for a given key in the on-device customer attribute key/value store.
|
|
@@ -66398,6 +66593,9 @@ class NamiFlow extends BasicNamiFlow {
|
|
|
66398
66593
|
}
|
|
66399
66594
|
if (ctx.resultSent)
|
|
66400
66595
|
return; // R4: one completion per impression
|
|
66596
|
+
// A journey's `flow_completed` goal is met here, whether or not an impression is bound yet
|
|
66597
|
+
// (anonymous mode never binds one); the PATCH below is what reports it (NAM-3236).
|
|
66598
|
+
JourneyCoordinator.shared.flowCompletionSignaled(this.campaign);
|
|
66401
66599
|
if (!ctx.impressionId) {
|
|
66402
66600
|
if (!ctx.pending.includes(condition))
|
|
66403
66601
|
ctx.pending.push(condition);
|
package/dist/index.d.ts
CHANGED
|
@@ -1030,7 +1030,9 @@ interface NamiFlowStep {
|
|
|
1030
1030
|
* plain string on `completion_step.condition`; this union is the SDK-side
|
|
1031
1031
|
* vocabulary the engine acts on.
|
|
1032
1032
|
*/
|
|
1033
|
-
type NamiFlowCompletionCondition = 'page_view' | 'purchase_success' | 'login_success' | 'push_authorized' | 'location_authorized' | 'tracking_authorized' | 'form_submitted'
|
|
1033
|
+
type NamiFlowCompletionCondition = 'page_view' | 'purchase_success' | 'login_success' | 'push_authorized' | 'location_authorized' | 'tracking_authorized' | 'form_submitted'
|
|
1034
|
+
/** A journey's `user_context_set` goal: its `goal.context_key` attribute was set (NAM-3236). */
|
|
1035
|
+
| 'context_set';
|
|
1034
1036
|
/**
|
|
1035
1037
|
* Every value the SDK may PATCH as `completion_result`: the positive
|
|
1036
1038
|
* conditions plus the two non-completion outcomes recorded when a flow
|
|
@@ -3681,6 +3683,10 @@ declare class NamiRefs {
|
|
|
3681
3683
|
private startupInFlight;
|
|
3682
3684
|
private startupFetch;
|
|
3683
3685
|
constructor();
|
|
3686
|
+
/** Whether a startup or resume pass is running; journey refresh waits for it (NAM-3236). */
|
|
3687
|
+
isStartupInFlight(): boolean;
|
|
3688
|
+
/** Whether configure has run with a real app platform id and startup is not disabled. */
|
|
3689
|
+
canRefreshJourneys(): boolean;
|
|
3684
3690
|
setInMemoryAnonymousMode(enabled: boolean): void;
|
|
3685
3691
|
isInMemoryAnonymousMode(): boolean;
|
|
3686
3692
|
isAnonymousMode(): boolean;
|
|
@@ -4543,6 +4549,7 @@ declare class NamiPaywallManager$1 {
|
|
|
4543
4549
|
* @returns {Promise<void>} A Promise that resolves when buying SKU will be complete.
|
|
4544
4550
|
*/
|
|
4545
4551
|
static buySkuComplete(purchase: NamiPurchaseSuccess): Promise<void>;
|
|
4552
|
+
private static reportPurchaseComplete;
|
|
4546
4553
|
/**
|
|
4547
4554
|
* Notify the NamiPaywallManager that purchase flow handled by you is cancelled.
|
|
4548
4555
|
* Used to disable product purchase-in-progress loading indicators
|
package/dist/index.mjs
CHANGED
|
@@ -104,7 +104,7 @@ const {
|
|
|
104
104
|
// version — stamped by scripts/version.sh
|
|
105
105
|
NAMI_SDK_VERSION = "3.5.0",
|
|
106
106
|
// full package version including dev suffix — stamped by scripts/version.sh
|
|
107
|
-
NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.
|
|
107
|
+
NAMI_SDK_PACKAGE_VERSION = "3.5.0-dev.202611022057",
|
|
108
108
|
// environments
|
|
109
109
|
PRODUCTION = "production", DEVELOPMENT = "development",
|
|
110
110
|
// error messages
|
|
@@ -57119,6 +57119,8 @@ function isSupportedJourney(meta) {
|
|
|
57119
57119
|
return true;
|
|
57120
57120
|
}
|
|
57121
57121
|
const isJourneyCampaign = (rule) => rule.type === NamiCampaignRuleType.JOURNEY;
|
|
57122
|
+
/** The parsed journey metadata of a journey rule; null for a placement rule or no metadata. */
|
|
57123
|
+
const journeyMetadataOf = (rule) => isJourneyCampaign(rule) ? parseJourneyMetadata(rule.journey) : null;
|
|
57122
57124
|
/**
|
|
57123
57125
|
* Whether this SDK can use the rule. Every placement rule qualifies. A journey also needs
|
|
57124
57126
|
* supported metadata and a flow, because journeys are flow-only. Callers skip an unsupported
|
|
@@ -62878,6 +62880,16 @@ class JourneyEngine {
|
|
|
62878
62880
|
recordDismiss(s, journeyId, now) {
|
|
62879
62881
|
return this.withRecord(s, journeyId, { ...(s.journeys[journeyId] ?? emptyJourneyRecord()), lastDismissedAt: now.getTime() });
|
|
62880
62882
|
}
|
|
62883
|
+
/**
|
|
62884
|
+
* Whether a goal signal counts now (NAM-3236): the journey was entered (an impression since
|
|
62885
|
+
* any earlier goal) and its `timeout_days` window, measured from entry, is still open.
|
|
62886
|
+
*/
|
|
62887
|
+
isGoalOpen(record, journey, now) {
|
|
62888
|
+
if (record?.enteredAt == null)
|
|
62889
|
+
return false;
|
|
62890
|
+
const timeout = journey.goal?.timeout_days;
|
|
62891
|
+
return timeout == null || now.getTime() < record.enteredAt + timeout * DAY_MS;
|
|
62892
|
+
}
|
|
62881
62893
|
/** Records goal completion and clears entry so a re-entry journey starts a fresh timeout. */
|
|
62882
62894
|
recordGoal(s, journeyId, now) {
|
|
62883
62895
|
return this.withRecord(s, journeyId, {
|
|
@@ -63040,6 +63052,11 @@ function presentJourneyCampaign(campaign, stillValid) {
|
|
|
63040
63052
|
return ui.presentCampaign(campaign, { customAttributes: {} });
|
|
63041
63053
|
}
|
|
63042
63054
|
|
|
63055
|
+
let refreshHost = null;
|
|
63056
|
+
/** Called by `NamiRefs` when it is constructed. */
|
|
63057
|
+
const registerJourneyRefreshHost = (host) => {
|
|
63058
|
+
refreshHost = host;
|
|
63059
|
+
};
|
|
63043
63060
|
const monotonicNow = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
63044
63061
|
const liveJourneyEnvironment = {
|
|
63045
63062
|
now: () => new Date(),
|
|
@@ -63054,14 +63071,21 @@ const liveJourneyEnvironment = {
|
|
|
63054
63071
|
get: () => storageService.getJourneyInstallEvaluated(),
|
|
63055
63072
|
set: () => storageService.setJourneyInstallEvaluated(),
|
|
63056
63073
|
},
|
|
63074
|
+
refresh: () => refreshHost?.fetchCampaignsAndPaywalls() ?? Promise.resolve(),
|
|
63075
|
+
refreshAllowed: () => !(refreshHost?.isStartupInFlight() ?? false),
|
|
63076
|
+
// Only while the platform has journey rules: an org with none sees no refresh traffic, and picks
|
|
63077
|
+
// up newly added journeys on the next session's startup fetch. `journey_settings` does not
|
|
63078
|
+
// count: the backend sends it in every platform config, journeys or not.
|
|
63079
|
+
refreshEnabled: () => (refreshHost?.canRefreshJourneys() ?? false) && journeyCampaigns().length > 0,
|
|
63057
63080
|
};
|
|
63058
|
-
const journeyIdOf = (rule) =>
|
|
63081
|
+
const journeyIdOf = (rule) => journeyMetadataOf(rule)?.journey_id;
|
|
63059
63082
|
/**
|
|
63060
63083
|
* Drives journey delivery for the `app_install` and `session_start` triggers (NAM-3233):
|
|
63061
|
-
* evaluation, the single delay slot, the fire-time re-check, delivery
|
|
63062
|
-
* accounting. A port of Apple's `JourneyCoordinator`
|
|
63063
|
-
*
|
|
63064
|
-
* generation alone keeps work from before a
|
|
63084
|
+
* evaluation, the single delay slot, the fire-time re-check, delivery, local impression
|
|
63085
|
+
* accounting, and campaign-rule refresh coalescing. A port of Apple's `JourneyCoordinator`
|
|
63086
|
+
* (NAM-3234), plus goal recording (NAM-3236), which Apple gains in NAM-3237. JS is
|
|
63087
|
+
* single-threaded, so there is no queue: the reset generation alone keeps work from before a
|
|
63088
|
+
* `Nami.reset()` off the fresh state.
|
|
63065
63089
|
*
|
|
63066
63090
|
* Guards on the way to a raise:
|
|
63067
63091
|
* - A winner chosen while the app is hidden starts its delay paused, a 0-second delay included,
|
|
@@ -63078,6 +63102,11 @@ class JourneyCoordinator {
|
|
|
63078
63102
|
this.engine = new JourneyEngine();
|
|
63079
63103
|
this.resetGeneration = 0;
|
|
63080
63104
|
this.backgroundedAt = null;
|
|
63105
|
+
this.refreshInFlight = false;
|
|
63106
|
+
this.refreshPending = false;
|
|
63107
|
+
/** A purchase asked for a refresh while a startup pass was running; flushed once when it ends. */
|
|
63108
|
+
this.refreshDeferred = false;
|
|
63109
|
+
this.refreshTimer = null;
|
|
63081
63110
|
this.delay = new JourneyDelayTimer(env.monotonicNow);
|
|
63082
63111
|
}
|
|
63083
63112
|
/**
|
|
@@ -63107,14 +63136,17 @@ class JourneyCoordinator {
|
|
|
63107
63136
|
}
|
|
63108
63137
|
journeyStateStore.startSession();
|
|
63109
63138
|
this.evaluate(isInstallCandidate);
|
|
63139
|
+
this.startRefreshTimer();
|
|
63110
63140
|
}
|
|
63111
|
-
/** Pauses the delay, keeping the remainder, and starts measuring the session gap. */
|
|
63141
|
+
/** Pauses the delay, keeping the remainder, stops the refresh timer and starts measuring the session gap. */
|
|
63112
63142
|
appDidEnterBackground() {
|
|
63113
63143
|
this.delay.pause();
|
|
63144
|
+
this.stopRefreshTimer();
|
|
63114
63145
|
this.backgroundedAt ??= this.env.monotonicNow();
|
|
63115
63146
|
}
|
|
63116
63147
|
/**
|
|
63117
|
-
* Resumes the delay with the remaining time, including one held since a hidden start
|
|
63148
|
+
* Resumes the delay with the remaining time, including one held since a hidden start, and
|
|
63149
|
+
* restarts the refresh timer.
|
|
63118
63150
|
*
|
|
63119
63151
|
* @returns whether this return begins a new session: it came at least {@link SESSION_GAP_MS}
|
|
63120
63152
|
* after the background (the session boundary Apple and Android share). A foreground with no
|
|
@@ -63124,17 +63156,152 @@ class JourneyCoordinator {
|
|
|
63124
63156
|
const hiddenAt = this.backgroundedAt;
|
|
63125
63157
|
this.backgroundedAt = null;
|
|
63126
63158
|
this.delay.resume();
|
|
63159
|
+
this.startRefreshTimer();
|
|
63127
63160
|
return hiddenAt !== null && this.env.monotonicNow() - hiddenAt >= SESSION_GAP_MS;
|
|
63128
63161
|
}
|
|
63129
63162
|
/** A placement campaign is about to be raised: a journey still waiting must not fire on top of it. */
|
|
63130
63163
|
placementWillRaise() {
|
|
63131
63164
|
this.delay.cancel();
|
|
63132
63165
|
}
|
|
63133
|
-
/**
|
|
63166
|
+
/**
|
|
63167
|
+
* Cancels the delay and the refresh timer, clears refresh state and invalidates in-flight work
|
|
63168
|
+
* (`Nami.reset()`), before the storage wipe.
|
|
63169
|
+
*/
|
|
63134
63170
|
reset() {
|
|
63135
63171
|
this.delay.cancel();
|
|
63172
|
+
this.stopRefreshTimer();
|
|
63136
63173
|
this.backgroundedAt = null;
|
|
63137
63174
|
this.resetGeneration += 1;
|
|
63175
|
+
this.refreshInFlight = false;
|
|
63176
|
+
this.refreshPending = false;
|
|
63177
|
+
this.refreshDeferred = false;
|
|
63178
|
+
}
|
|
63179
|
+
// Goals (NAM-3236)
|
|
63180
|
+
/**
|
|
63181
|
+
* A flow's completion step matched its condition. For a journey whose goal is
|
|
63182
|
+
* `flow_completed`, that is the goal; the completion PATCH that reports it is the flow's own.
|
|
63183
|
+
*
|
|
63184
|
+
* @returns whether a goal was recorded.
|
|
63185
|
+
*/
|
|
63186
|
+
flowCompletionSignaled(campaign) {
|
|
63187
|
+
const journey = campaign ? journeyMetadataOf(campaign) : null;
|
|
63188
|
+
if (journey?.goal?.type !== "flow_completed" || !this.goalReached(journey))
|
|
63189
|
+
return false;
|
|
63190
|
+
this.requestRefresh();
|
|
63191
|
+
return true;
|
|
63192
|
+
}
|
|
63193
|
+
/**
|
|
63194
|
+
* A customer attribute was set. Records the goal of every journey whose `user_context_set` goal
|
|
63195
|
+
* watches `key`, then refreshes the rules when the value changed (journey eligibility can depend
|
|
63196
|
+
* on attributes) or a goal was recorded (it can unlock the next journey).
|
|
63197
|
+
*/
|
|
63198
|
+
customerAttributeSet(key, changed) {
|
|
63199
|
+
let goal = false;
|
|
63200
|
+
for (const rule of this.env.journeys()) {
|
|
63201
|
+
const journey = journeyMetadataOf(rule);
|
|
63202
|
+
if (journey?.goal?.type === "user_context_set" && journey.goal.context_key === key) {
|
|
63203
|
+
goal = this.goalReached(journey) || goal;
|
|
63204
|
+
}
|
|
63205
|
+
}
|
|
63206
|
+
if (changed || goal)
|
|
63207
|
+
this.requestRefresh();
|
|
63208
|
+
}
|
|
63209
|
+
/**
|
|
63210
|
+
* Records the goal and exits the journey: `goalAchievedAt` is stamped and entry cleared, so the
|
|
63211
|
+
* engine rejects it (`goalAchieved`) unless it allows re-entry. Counts only while the journey is
|
|
63212
|
+
* entered and inside its `timeout_days` window; anything else (never shown, already achieved,
|
|
63213
|
+
* timed out) is ignored.
|
|
63214
|
+
*/
|
|
63215
|
+
goalReached(journey) {
|
|
63216
|
+
const id = journey.journey_id;
|
|
63217
|
+
const now = this.env.now();
|
|
63218
|
+
if (!this.engine.isGoalOpen(journeyStateStore.snapshot().journeys[id], journey, now)) {
|
|
63219
|
+
logger.debug(`Journey ${id} goal ignored: not entered, already achieved or timed out`);
|
|
63220
|
+
return false;
|
|
63221
|
+
}
|
|
63222
|
+
journeyStateStore.update((s) => this.engine.recordGoal(s, id, now));
|
|
63223
|
+
logger.debug(`Journey ${id} goal achieved`);
|
|
63224
|
+
return true;
|
|
63225
|
+
}
|
|
63226
|
+
// Refresh
|
|
63227
|
+
/**
|
|
63228
|
+
* Re-fetches campaign rules. At most one fetch is in flight, plus one trailing fetch if more
|
|
63229
|
+
* requests arrive meanwhile. Never evaluates journeys by itself: a newly eligible journey fires on
|
|
63230
|
+
* its own next trigger.
|
|
63231
|
+
*
|
|
63232
|
+
* While a startup pass runs the request is dropped, because the pass fetches the rules itself.
|
|
63233
|
+
* A purchase passes `deferIfBusy` so it is not lost: it refreshes once when the pass ends.
|
|
63234
|
+
*/
|
|
63235
|
+
requestRefresh(deferIfBusy = false) {
|
|
63236
|
+
if (!this.env.refreshEnabled())
|
|
63237
|
+
return;
|
|
63238
|
+
if (this.refreshInFlight) {
|
|
63239
|
+
this.refreshPending = true;
|
|
63240
|
+
}
|
|
63241
|
+
else if (this.env.refreshAllowed()) {
|
|
63242
|
+
this.startRefresh();
|
|
63243
|
+
}
|
|
63244
|
+
else if (deferIfBusy) {
|
|
63245
|
+
this.refreshDeferred = true;
|
|
63246
|
+
}
|
|
63247
|
+
}
|
|
63248
|
+
/** A startup or resume pass ended, either way. Flushes a refresh a purchase deferred during it. */
|
|
63249
|
+
startupPassFinished() {
|
|
63250
|
+
if (!this.refreshDeferred)
|
|
63251
|
+
return;
|
|
63252
|
+
this.refreshDeferred = false;
|
|
63253
|
+
if (!this.env.refreshEnabled())
|
|
63254
|
+
return;
|
|
63255
|
+
if (this.refreshInFlight) {
|
|
63256
|
+
this.refreshPending = true;
|
|
63257
|
+
}
|
|
63258
|
+
else {
|
|
63259
|
+
this.startRefresh();
|
|
63260
|
+
}
|
|
63261
|
+
}
|
|
63262
|
+
startRefresh() {
|
|
63263
|
+
this.refreshInFlight = true;
|
|
63264
|
+
const generation = this.resetGeneration;
|
|
63265
|
+
const settle = () => {
|
|
63266
|
+
if (this.resetGeneration !== generation)
|
|
63267
|
+
return;
|
|
63268
|
+
this.refreshInFlight = false;
|
|
63269
|
+
if (this.refreshPending) {
|
|
63270
|
+
this.refreshPending = false;
|
|
63271
|
+
this.requestRefresh(true);
|
|
63272
|
+
}
|
|
63273
|
+
};
|
|
63274
|
+
let fetch;
|
|
63275
|
+
try {
|
|
63276
|
+
fetch = this.env.refresh();
|
|
63277
|
+
}
|
|
63278
|
+
catch (error) {
|
|
63279
|
+
fetch = Promise.reject(error);
|
|
63280
|
+
}
|
|
63281
|
+
fetch.then(settle, (error) => {
|
|
63282
|
+
logger.warn(`Journey refresh failed: ${error}`);
|
|
63283
|
+
settle();
|
|
63284
|
+
});
|
|
63285
|
+
}
|
|
63286
|
+
/**
|
|
63287
|
+
* Starts the repeating `refresh_interval_seconds` timer, but only while the app is visible: it
|
|
63288
|
+
* never runs in the background. Unreferenced in Node, so it never keeps a CLI process alive.
|
|
63289
|
+
*/
|
|
63290
|
+
startRefreshTimer() {
|
|
63291
|
+
if (this.refreshTimer !== null || !this.env.isAppVisible())
|
|
63292
|
+
return;
|
|
63293
|
+
const ms = this.env.settings().refresh_interval_seconds * 1000;
|
|
63294
|
+
if (!(ms > 0))
|
|
63295
|
+
return;
|
|
63296
|
+
const timer = setInterval(() => this.requestRefresh(), ms);
|
|
63297
|
+
timer.unref?.();
|
|
63298
|
+
this.refreshTimer = timer;
|
|
63299
|
+
}
|
|
63300
|
+
stopRefreshTimer() {
|
|
63301
|
+
if (this.refreshTimer === null)
|
|
63302
|
+
return;
|
|
63303
|
+
clearInterval(this.refreshTimer);
|
|
63304
|
+
this.refreshTimer = null;
|
|
63138
63305
|
}
|
|
63139
63306
|
// Evaluation
|
|
63140
63307
|
evaluate(isInstallCandidate) {
|
|
@@ -63237,6 +63404,15 @@ class NamiRefs {
|
|
|
63237
63404
|
this.startupFetch = Promise.resolve();
|
|
63238
63405
|
// Register this instance to avoid circular dependency issues
|
|
63239
63406
|
setNamiRefsInstance(this);
|
|
63407
|
+
registerJourneyRefreshHost(this);
|
|
63408
|
+
}
|
|
63409
|
+
/** Whether a startup or resume pass is running; journey refresh waits for it (NAM-3236). */
|
|
63410
|
+
isStartupInFlight() {
|
|
63411
|
+
return this.startupInFlight;
|
|
63412
|
+
}
|
|
63413
|
+
/** Whether configure has run with a real app platform id and startup is not disabled. */
|
|
63414
|
+
canRefreshJourneys() {
|
|
63415
|
+
return !!this.lastConfig?.appPlatformID?.trim() && !this.startupDisabled;
|
|
63240
63416
|
}
|
|
63241
63417
|
setInMemoryAnonymousMode(enabled) {
|
|
63242
63418
|
this.inMemoryAnonymousMode = enabled;
|
|
@@ -63285,6 +63461,7 @@ class NamiRefs {
|
|
|
63285
63461
|
}
|
|
63286
63462
|
finally {
|
|
63287
63463
|
this.startupInFlight = false;
|
|
63464
|
+
JourneyCoordinator.shared.startupPassFinished();
|
|
63288
63465
|
}
|
|
63289
63466
|
}
|
|
63290
63467
|
/**
|
|
@@ -63347,6 +63524,7 @@ class NamiRefs {
|
|
|
63347
63524
|
}
|
|
63348
63525
|
finally {
|
|
63349
63526
|
this.startupInFlight = false;
|
|
63527
|
+
JourneyCoordinator.shared.startupPassFinished();
|
|
63350
63528
|
}
|
|
63351
63529
|
}
|
|
63352
63530
|
/** Everything startup runs once the platform-config step has found a usable config. */
|
|
@@ -64994,6 +65172,15 @@ let NamiPaywallManager$2 = class NamiPaywallManager {
|
|
|
64994
65172
|
* @returns {Promise<void>} A Promise that resolves when buying SKU will be complete.
|
|
64995
65173
|
*/
|
|
64996
65174
|
static async buySkuComplete(purchase) {
|
|
65175
|
+
try {
|
|
65176
|
+
await this.reportPurchaseComplete(purchase);
|
|
65177
|
+
}
|
|
65178
|
+
finally {
|
|
65179
|
+
// A host-managed purchase changes journey eligibility too: re-fetch the rules (NAM-3236).
|
|
65180
|
+
JourneyCoordinator.shared.requestRefresh(true);
|
|
65181
|
+
}
|
|
65182
|
+
}
|
|
65183
|
+
static async reportPurchaseComplete(purchase) {
|
|
64997
65184
|
// clear loading indicator
|
|
64998
65185
|
PaywallState.setPurchaseInProgress(false);
|
|
64999
65186
|
if (NamiFlowManager$2.instance.flowOpen) {
|
|
@@ -65918,6 +66105,7 @@ let NamiCustomerManager$2 = class NamiCustomerManager {
|
|
|
65918
66105
|
if (!this.instance.isSDKInitialized) {
|
|
65919
66106
|
throw new SDKNotInitializedError();
|
|
65920
66107
|
}
|
|
66108
|
+
const changed = storageService.getCustomerAttribute(key) !== value;
|
|
65921
66109
|
storageService.setCustomerAttribute(key, value);
|
|
65922
66110
|
if (!hasCapability(Capabilities.PAYWALL_PERSONALIZATION)) {
|
|
65923
66111
|
logger.warn(`The customer attribute ${key} is not available for using on a paywall, because your account's plan does not include paywall personalization. Contact support@nami.ml for details.`);
|
|
@@ -65929,12 +66117,19 @@ let NamiCustomerManager$2 = class NamiCustomerManager {
|
|
|
65929
66117
|
if (NamiFlowManager$2.instance.flowOpen) {
|
|
65930
66118
|
const currentFlow = NamiFlowManager$2.instance.currentFlow;
|
|
65931
66119
|
if (currentFlow) {
|
|
66120
|
+
// An open journey flow whose `user_context_set` goal watches this key reports the goal
|
|
66121
|
+
// through its completion step (NAM-3236), before the lifecycle can move it off the page.
|
|
66122
|
+
const goal = currentFlow.campaign ? journeyMetadataOf(currentFlow.campaign)?.goal : null;
|
|
66123
|
+
if (goal?.type === "user_context_set" && goal.context_key === key) {
|
|
66124
|
+
currentFlow.completionSignal("context_set");
|
|
66125
|
+
}
|
|
65932
66126
|
const currentStep = currentFlow.currentFlowStep;
|
|
65933
66127
|
if (currentStep) {
|
|
65934
66128
|
currentFlow.executeLifecycle(currentStep, NamiReservedActions.TAG_UPDATE);
|
|
65935
66129
|
}
|
|
65936
66130
|
}
|
|
65937
66131
|
}
|
|
66132
|
+
JourneyCoordinator.shared.customerAttributeSet(key, changed);
|
|
65938
66133
|
}
|
|
65939
66134
|
/**
|
|
65940
66135
|
* Retrieves the value for a given key in the on-device customer attribute key/value store.
|
|
@@ -66396,6 +66591,9 @@ class NamiFlow extends BasicNamiFlow {
|
|
|
66396
66591
|
}
|
|
66397
66592
|
if (ctx.resultSent)
|
|
66398
66593
|
return; // R4: one completion per impression
|
|
66594
|
+
// A journey's `flow_completed` goal is met here, whether or not an impression is bound yet
|
|
66595
|
+
// (anonymous mode never binds one); the PATCH below is what reports it (NAM-3236).
|
|
66596
|
+
JourneyCoordinator.shared.flowCompletionSignaled(this.campaign);
|
|
66399
66597
|
if (!ctx.impressionId) {
|
|
66400
66598
|
if (!ctx.pending.includes(condition))
|
|
66401
66599
|
ctx.pending.push(condition);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@namiml/sdk-core",
|
|
3
|
-
"version": "3.5.0-dev.
|
|
3
|
+
"version": "3.5.0-dev.202611022057",
|
|
4
4
|
"description": "Platform-agnostic core for the Nami SDK — business logic, API, types, and state management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|