@wireai/activation 0.9.1 → 0.10.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.
- package/AGENTS.md +7 -0
- package/CHANGELOG.md +24 -0
- package/INTEGRATION_PROMPT.md +11 -1
- package/README.md +62 -0
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +34 -11
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +33 -12
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-61dcm3V-.d.ts → currentSession-D0Vq7_VE.d.ts} +49 -4
- package/dist/{currentSession-Bs2JfTJ8.d.mts → currentSession-DdDkprpM.d.mts} +49 -4
- package/dist/index.d.mts +116 -3
- package/dist/index.d.ts +116 -3
- package/dist/index.js +154 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +147 -15
- package/dist/index.mjs.map +1 -1
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +15 -4
- package/src/activation/index.ts +24 -0
- package/src/activation/revalidation.ts +88 -0
- package/src/activation/useWireActivation.ts +70 -0
- package/src/activation/wireActivation.ts +156 -0
- package/src/analytics/currentSession.ts +41 -7
- package/src/analytics/index.ts +7 -1
- package/src/analytics/reportClientEvent.ts +56 -10
- package/src/index.ts +18 -0
- package/src/session/persistedSession.ts +17 -0
- package/src/types.ts +16 -0
package/llms.txt
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
|
|
24
24
|
- `reportClientEvent(target, event)` / `reportClientEvents` / `makeSessionId` (ROOT-exported, not a subpath) → report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`; `target = { serverUrl, apiKey }` from the config. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, `client_fallback` on degrade-to-static. Hosts must not double-report fallback.
|
|
25
25
|
- `deriveAnswers(messages)`, `themeFromBrand({ primary })`, `defaultIllustrations`, `DemoOnboarding` (dev/QA, no account).
|
|
26
|
+
- `useWireActivation({ serverUrl, apiKey, deviceKey? })` → `{ track, sessionId, revalidation }` (ROOT-exported; React-free factory `createWireActivation(config)`). `await track(name, meta?)` POSTs an `app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps `revalidation`; list `revalidation` in a `fetchReviewDecision` / `fetchQuestionnaireDecision` effect's deps so a review/questionnaire gate re-fetches and fires off an in-app action instead of the host hand-rolling session-id + await-POST + revalidate.
|
|
26
27
|
|
|
27
28
|
## Files
|
|
28
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
clearPersistedSession,
|
|
28
28
|
loadPersistedSession,
|
|
29
29
|
sessionStorageKey,
|
|
30
|
+
shouldClearOnComplete,
|
|
30
31
|
DEFAULT_SESSION_TTL_MS,
|
|
31
32
|
type LoadedSession,
|
|
32
33
|
} from "./session/persistedSession";
|
|
@@ -52,6 +53,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
52
53
|
storage,
|
|
53
54
|
sessionTtlMs = DEFAULT_SESSION_TTL_MS,
|
|
54
55
|
persistKey,
|
|
56
|
+
retainSessionOnComplete,
|
|
55
57
|
userContext,
|
|
56
58
|
userId,
|
|
57
59
|
}) => {
|
|
@@ -123,15 +125,24 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
123
125
|
};
|
|
124
126
|
}, [storage, storageKey, sessionTtlMs, session]);
|
|
125
127
|
|
|
126
|
-
//
|
|
127
|
-
// device starts a fresh session. Dropped/degraded paths deliberately do NOT clear —
|
|
128
|
+
// By DEFAULT, clear the cached id the moment the flow completes, so the next onboarding on
|
|
129
|
+
// this device starts a fresh session. Dropped/degraded paths deliberately do NOT clear —
|
|
128
130
|
// a dropped session must stay resumable within the TTL.
|
|
131
|
+
//
|
|
132
|
+
// OPT-IN (`retainSessionOnComplete`): a multi-stage / replay-after-complete signup re-enters
|
|
133
|
+
// (often via a `key=` remount) WITHIN one signup. If we cleared, that re-entry would find no
|
|
134
|
+
// seed and mint a fresh `metadata.sessionId` → a phantom second `session_started` on the same
|
|
135
|
+
// funnel. When the host opts in, we LEAVE the seed in place so the re-entry resumes the SAME
|
|
136
|
+
// session (one funnel start); freshness for a genuinely new run then rides the TTL (past
|
|
137
|
+
// `sessionTtlMs` → fresh mint) and an explicit new-run signal (a changed `persistKey`).
|
|
138
|
+
// `shouldClearOnComplete` is the pure, unit-tested seam; the `storage` guard is unchanged, so a
|
|
139
|
+
// host that does not opt in lands on the byte-for-byte legacy clear-on-complete path.
|
|
129
140
|
const handleComplete = useCallback(
|
|
130
141
|
(result: OnboardingResult) => {
|
|
131
|
-
if (storage) clearPersistedSession(storage, storageKey);
|
|
142
|
+
if (storage && shouldClearOnComplete(retainSessionOnComplete)) clearPersistedSession(storage, storageKey);
|
|
132
143
|
onComplete(result);
|
|
133
144
|
},
|
|
134
|
-
[storage, storageKey, onComplete],
|
|
145
|
+
[storage, storageKey, retainSessionOnComplete, onComplete],
|
|
135
146
|
);
|
|
136
147
|
|
|
137
148
|
const sessionId = session?.id ?? "";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* activation — the kit-owned `wire.track()` + `useWireActivation()` consolidation surface.
|
|
3
|
+
*
|
|
4
|
+
* The thin, reusable API that lets a consumer report a trigger action and re-fire the review /
|
|
5
|
+
* questionnaire gates WITHOUT hand-rolling an awaitable POST, its own session id, or an
|
|
6
|
+
* await-then-bump revalidation dance. Built on the existing events transport + the canonical
|
|
7
|
+
* `getCurrentSessionId()`. Re-exported from the main `@wireai/activation` barrel.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─── The pure factory (React-free) ─────────────────────────────────────────────
|
|
11
|
+
export { createWireActivation } from "./wireActivation";
|
|
12
|
+
export type { WireActivation, WireActivationConfig } from "./wireActivation";
|
|
13
|
+
|
|
14
|
+
// ─── The thin optional React hook (+ the standalone revalidation hook) ─────────
|
|
15
|
+
export { useWireActivation, useActivationRevalidation } from "./useWireActivation";
|
|
16
|
+
export type { UseWireActivation } from "./useWireActivation";
|
|
17
|
+
|
|
18
|
+
// ─── The revalidation pub/sub (cross-bundle-safe; the gates subscribe to it) ───
|
|
19
|
+
export {
|
|
20
|
+
bumpActivationRevalidation,
|
|
21
|
+
subscribeActivationRevalidation,
|
|
22
|
+
getActivationRevalidationVersion,
|
|
23
|
+
resetActivationRevalidation,
|
|
24
|
+
} from "./revalidation";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* activation revalidation — a tiny kit-owned pub/sub the review / questionnaire decision fetches
|
|
3
|
+
* subscribe to, so they RE-FETCH their server decision AFTER a trigger action, not only on mount.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (subsumes Morrow's hand-rolled `activation-revalidation.ts`): a host mounts the
|
|
6
|
+
* review / questionnaire gate on the home screen and fetches its `/decision` verdict once, in a
|
|
7
|
+
* mount-scoped effect. But the screen stays mounted while the user goes elsewhere to perform the
|
|
8
|
+
* triggering action (journal a win, complete a task); the server's firing rule is trigger-based AND
|
|
9
|
+
* session-scoped, so the mount fetch ran BEFORE the trigger event existed and never re-runs. Bumping
|
|
10
|
+
* this store after the action POSTs re-runs the gate's decision fetch (it lists the version in its
|
|
11
|
+
* deps) → the server now sees the trigger → `{fire:true}`.
|
|
12
|
+
*
|
|
13
|
+
* `wire.track()` bumps this automatically on a successful POST, so a consumer no longer hand-rolls
|
|
14
|
+
* an await-then-bump: it subscribes (via {@link useActivationRevalidation} in `useWireActivation`)
|
|
15
|
+
* and re-fetches when the version changes.
|
|
16
|
+
*
|
|
17
|
+
* ── WHY A globalThis SLOT, NOT MODULE-LOCAL STATE (same reasoning as currentSession) ─────────────
|
|
18
|
+
* This module can be reached from more than one package entry (the main `.` barrel exposes the
|
|
19
|
+
* revalidation surface; the gates live under the `./reviews` / `./questionnaire` subpaths). Under
|
|
20
|
+
* `dist` resolution tsup inlines a SEPARATE copy of a module into each bundle, so plain module-local
|
|
21
|
+
* `version` + `listeners` would give the BUMPER (main entry, via `wire.track`) and a SUBSCRIBER a
|
|
22
|
+
* different store — the bump would never reach the listener, exactly the defect-B failure mode. So
|
|
23
|
+
* the ONE store lives in a `globalThis` slot keyed by `Symbol.for(...)`: every inlined copy resolves
|
|
24
|
+
* the same symbol and shares one store, on Hermes/RN, Node and SSR alike.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Well-known key into the runtime-global symbol registry — one shared store across every bundle. */
|
|
28
|
+
const REVALIDATION_SLOT: unique symbol = Symbol.for(
|
|
29
|
+
"@wireai/activation:activationRevalidation",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** The shared store: a monotonic version + the set of subscriber callbacks. */
|
|
33
|
+
type RevalidationStore = { version: number; listeners: Set<() => void> };
|
|
34
|
+
|
|
35
|
+
type GlobalWithSlot = typeof globalThis & {
|
|
36
|
+
[REVALIDATION_SLOT]?: RevalidationStore;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const globalSlot = globalThis as GlobalWithSlot;
|
|
40
|
+
|
|
41
|
+
/** The one shared store, lazily created on the runtime global (never a second copy per bundle). */
|
|
42
|
+
const store = (): RevalidationStore => {
|
|
43
|
+
const existing = globalSlot[REVALIDATION_SLOT];
|
|
44
|
+
if (existing) return existing;
|
|
45
|
+
const created: RevalidationStore = { version: 0, listeners: new Set() };
|
|
46
|
+
globalSlot[REVALIDATION_SLOT] = created;
|
|
47
|
+
return created;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Signal every activation subscriber to re-fetch its server decision. `wire.track()` calls this on a
|
|
52
|
+
* successful POST (await the post first so the re-fetch sees the event). A throwing subscriber never
|
|
53
|
+
* breaks the notify loop — each listener is isolated.
|
|
54
|
+
*/
|
|
55
|
+
export const bumpActivationRevalidation = (): void => {
|
|
56
|
+
const s = store();
|
|
57
|
+
s.version += 1;
|
|
58
|
+
// Iterate a snapshot so a listener that (un)subscribes during notify never corrupts the walk.
|
|
59
|
+
for (const listener of Array.from(s.listeners)) {
|
|
60
|
+
try {
|
|
61
|
+
listener();
|
|
62
|
+
} catch {
|
|
63
|
+
// A subscriber that throws must not stop the others (fail-safe).
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Subscribe to revalidation. Returns an unsubscribe fn. Shaped for `useSyncExternalStore`
|
|
70
|
+
* (see {@link useActivationRevalidation} in `useWireActivation`).
|
|
71
|
+
*/
|
|
72
|
+
export const subscribeActivationRevalidation = (listener: () => void): (() => void) => {
|
|
73
|
+
const s = store();
|
|
74
|
+
s.listeners.add(listener);
|
|
75
|
+
return () => {
|
|
76
|
+
s.listeners.delete(listener);
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The current revalidation version — include it in a decision-fetch effect's deps to re-fetch on bump. */
|
|
81
|
+
export const getActivationRevalidationVersion = (): number => store().version;
|
|
82
|
+
|
|
83
|
+
/** Test-only: reset the shared store between cases (clears version + listeners). */
|
|
84
|
+
export const resetActivationRevalidation = (): void => {
|
|
85
|
+
const s = store();
|
|
86
|
+
s.version = 0;
|
|
87
|
+
s.listeners.clear();
|
|
88
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWireActivation — a THIN optional React hook over {@link createWireActivation}.
|
|
3
|
+
*
|
|
4
|
+
* One import gives a consumer everything the activation consolidation owns: `track` (awaitable POST
|
|
5
|
+
* that auto-revalidates), the CURRENT `sessionId`, and a `revalidation` counter that ticks whenever
|
|
6
|
+
* a `track()` (from anywhere) succeeds — subscribe to re-render, then re-fetch your gate decision.
|
|
7
|
+
*
|
|
8
|
+
* const { track, sessionId, revalidation } = useWireActivation({ serverUrl, apiKey });
|
|
9
|
+
* // fetch the gate decision on mount AND whenever revalidation ticks:
|
|
10
|
+
* useEffect(() => { fetchReviewDecision(target, { sessionId, deviceKey }).then(setDecision); },
|
|
11
|
+
* [revalidation]);
|
|
12
|
+
* // on the trigger action:
|
|
13
|
+
* await track("journal_done");
|
|
14
|
+
*
|
|
15
|
+
* Mirrors the existing `createAnalytics` / `useAnalytics` (and `createScreenTracker` /
|
|
16
|
+
* `useScreenTracking`) split: the factory stays React-free; this is the glue. The instance is built
|
|
17
|
+
* once per mount and held in a ref, so re-renders never rebuild it or re-resolve the device key.
|
|
18
|
+
*/
|
|
19
|
+
import { useRef, useSyncExternalStore } from "react";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
getActivationRevalidationVersion,
|
|
23
|
+
subscribeActivationRevalidation,
|
|
24
|
+
} from "./revalidation";
|
|
25
|
+
import {
|
|
26
|
+
createWireActivation,
|
|
27
|
+
type WireActivation,
|
|
28
|
+
type WireActivationConfig,
|
|
29
|
+
} from "./wireActivation";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Subscribe a component to decision revalidation. Returns the current version; list it in a
|
|
33
|
+
* decision-fetch effect's deps so a `bumpActivationRevalidation()` (which a successful `track` does)
|
|
34
|
+
* re-runs the fetch. The kit-owned replacement for a host's hand-rolled `useActivationRevalidation`.
|
|
35
|
+
*/
|
|
36
|
+
export const useActivationRevalidation = (): number =>
|
|
37
|
+
useSyncExternalStore(
|
|
38
|
+
subscribeActivationRevalidation,
|
|
39
|
+
getActivationRevalidationVersion,
|
|
40
|
+
getActivationRevalidationVersion,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** What {@link useWireActivation} returns: the awaitable `track`, the live `sessionId`, and the tick. */
|
|
44
|
+
export type UseWireActivation = {
|
|
45
|
+
/** Awaitable action report that auto-revalidates on success (see {@link WireActivation.track}). */
|
|
46
|
+
track: WireActivation["track"];
|
|
47
|
+
/** The CURRENT per-open session id (read fresh each render), or `undefined`. */
|
|
48
|
+
sessionId: string | undefined;
|
|
49
|
+
/** Monotonic counter that increments on every successful `track()` — a re-fetch trigger. */
|
|
50
|
+
revalidation: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build a per-mount activation instance. `config` is read once at first render (the instance is
|
|
55
|
+
* stable for the component's lifetime, keyed on the transport creds + device key); the returned
|
|
56
|
+
* `revalidation` re-renders the component whenever any `track()` succeeds.
|
|
57
|
+
*/
|
|
58
|
+
export const useWireActivation = (config: WireActivationConfig): UseWireActivation => {
|
|
59
|
+
const ref = useRef<WireActivation | undefined>(undefined);
|
|
60
|
+
const prevKeys = useRef<string>("");
|
|
61
|
+
|
|
62
|
+
const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}|${config.deviceKey}`;
|
|
63
|
+
if (!ref.current || prevKeys.current !== currentKeys) {
|
|
64
|
+
prevKeys.current = currentKeys;
|
|
65
|
+
ref.current = createWireActivation(config);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const revalidation = useActivationRevalidation();
|
|
69
|
+
return { track: ref.current.track, sessionId: ref.current.sessionId, revalidation };
|
|
70
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireActivation — the kit-owned `wire.track()` + session accessor + revalidation surface.
|
|
3
|
+
*
|
|
4
|
+
* WHAT IT CONSOLIDATES (subsumes Morrow's workarounds): a consumer used to hand-roll three things
|
|
5
|
+
* because the kit didn't own them — an awaitable action POST (`wireTrackActionAwait`), its OWN
|
|
6
|
+
* per-open session id (`getWireSessionId`, minted because the kit's `getCurrentSessionId` desynced
|
|
7
|
+
* cross-bundle — defect B), and an await-then-bump revalidation dance (`bumpActivationDecision`).
|
|
8
|
+
* Defect B is fixed (PR #48: `getCurrentSessionId` is a `globalThis` singleton, reliable under
|
|
9
|
+
* `dist`), so the kit can now own all three behind one thin surface:
|
|
10
|
+
*
|
|
11
|
+
* const wire = createWireActivation({ serverUrl, apiKey }); // or useWireActivation(...)
|
|
12
|
+
* await wire.track("journal_done"); // awaitable POST + auto-revalidate
|
|
13
|
+
*
|
|
14
|
+
* `track` POSTs `event_type='app_event'`, `question_key=<name>` (the EXACT string a review /
|
|
15
|
+
* questionnaire firing TRIGGER matches on) under the CURRENT `getCurrentSessionId()` — the same id
|
|
16
|
+
* the gates pass to their `/decision` fetch, so the server's session-scoped trigger rule agrees —
|
|
17
|
+
* with `user_context.device_key` for the min-sessions / arm-assignment lookups. On a successful POST
|
|
18
|
+
* it bumps decision revalidation so a subscribed gate re-fetches and can fire.
|
|
19
|
+
*
|
|
20
|
+
* ADDITIVE + built on existing primitives: it reuses the events transport
|
|
21
|
+
* (`reportClientEventAwait`, the awaitable sibling of `reportClientEvents` — ONE `/v1/events` path)
|
|
22
|
+
* and `getCurrentSessionId` — it introduces NO second session concept and duplicates no POST path.
|
|
23
|
+
* React-free (the optional React glue is the thin `useWireActivation` hook).
|
|
24
|
+
*/
|
|
25
|
+
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
26
|
+
import {
|
|
27
|
+
reportClientEventAwait,
|
|
28
|
+
type ClientEvent,
|
|
29
|
+
type ClientEventTarget,
|
|
30
|
+
} from "../analytics/reportClientEvent";
|
|
31
|
+
import { deviceIdStorageKey, mintDeviceId } from "../context/deviceId";
|
|
32
|
+
import { resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
33
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
34
|
+
import {
|
|
35
|
+
bumpActivationRevalidation,
|
|
36
|
+
getActivationRevalidationVersion,
|
|
37
|
+
subscribeActivationRevalidation,
|
|
38
|
+
} from "./revalidation";
|
|
39
|
+
|
|
40
|
+
/** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate. */
|
|
41
|
+
const clean = (value: unknown): string | undefined => {
|
|
42
|
+
if (typeof value !== "string") return undefined;
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tenant transport + context inputs for {@link createWireActivation}. `serverUrl`/`apiKey` are the
|
|
49
|
+
* SAME creds as onboarding (never a second key); everything else is optional.
|
|
50
|
+
*/
|
|
51
|
+
export type WireActivationConfig = {
|
|
52
|
+
/** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/v1/events` is appended. */
|
|
53
|
+
serverUrl: string;
|
|
54
|
+
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
55
|
+
apiKey: string;
|
|
56
|
+
/**
|
|
57
|
+
* A stable, non-PII device id → `user_context.device_key` (the server's review/questionnaire
|
|
58
|
+
* gating + A/B stickiness key on it). When omitted, the kit auto-mints ONE per-install id, persists
|
|
59
|
+
* it via `storage` when given, and reuses it — so `device_key` is ALWAYS present. Host-supplied wins.
|
|
60
|
+
*/
|
|
61
|
+
deviceKey?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Optional rich context stamped onto every tracked event's `user_context` (opaque `userId` →
|
|
64
|
+
* top-level `user_id`, opt-in `userEmail`, namespaced `extra`). `deviceKey` here is equivalent to
|
|
65
|
+
* the top-level one (top-level wins). Same shape the analytics façade accepts.
|
|
66
|
+
*/
|
|
67
|
+
userContext?: WireUserContext;
|
|
68
|
+
/** Tenant/app id — namespaces the auto-minted device-key storage slot. */
|
|
69
|
+
appId?: string;
|
|
70
|
+
/** Host app version → `user_context.app_version` when no explicit `userContext.appVersion` is set. */
|
|
71
|
+
appVersion?: string;
|
|
72
|
+
/** Host persistence (AsyncStorage-compatible subset) so the auto-minted device key survives launches. */
|
|
73
|
+
storage?: WireOnboardingStorage;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The kit-owned activation surface. `sessionId` is a live getter (reads `getCurrentSessionId()`). */
|
|
77
|
+
export type WireActivation = {
|
|
78
|
+
/**
|
|
79
|
+
* Awaitable action report: POST `event_type='app_event'`, `question_key=<name>`, optional `meta`,
|
|
80
|
+
* under the CURRENT session id + `user_context.device_key`. Resolves `true` once the server has
|
|
81
|
+
* stored it (2xx) and THEN bumps decision revalidation; resolves `false` (no bump) when there is no
|
|
82
|
+
* current session, a blank name, or the POST fails. Never throws.
|
|
83
|
+
*/
|
|
84
|
+
track(name: string, meta?: Record<string, unknown>): Promise<boolean>;
|
|
85
|
+
/** The CURRENT per-open session id (the kit's canonical `getCurrentSessionId()`), or `undefined`. */
|
|
86
|
+
readonly sessionId: string | undefined;
|
|
87
|
+
/** Subscribe to decision revalidation (bumped by a successful `track`). Returns an unsubscribe fn. */
|
|
88
|
+
subscribeRevalidation(listener: () => void): () => void;
|
|
89
|
+
/** The current revalidation version — include in a decision-fetch effect's deps to re-fetch on bump. */
|
|
90
|
+
getRevalidationVersion(): number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a bound activation instance for a tenant transport. Resolves the device key once (explicit >
|
|
95
|
+
* `userContext.deviceKey` > auto-minted + persisted). Pure + React-free.
|
|
96
|
+
*/
|
|
97
|
+
export const createWireActivation = (config: WireActivationConfig): WireActivation => {
|
|
98
|
+
const target: ClientEventTarget = { serverUrl: config.serverUrl, apiKey: config.apiKey };
|
|
99
|
+
|
|
100
|
+
// Device key: an explicit id (top-level or in userContext) wins and is never overwritten; otherwise
|
|
101
|
+
// auto-mint ONE and persist via storage (reused every open) so `device_key` is always present.
|
|
102
|
+
const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
|
|
103
|
+
let autoDeviceKey = explicitDeviceKey ?? mintDeviceId();
|
|
104
|
+
if (config.storage && !explicitDeviceKey) {
|
|
105
|
+
const storage = config.storage;
|
|
106
|
+
const key = deviceIdStorageKey(config.appId);
|
|
107
|
+
void storage
|
|
108
|
+
.getItem(key)
|
|
109
|
+
.then((saved) => {
|
|
110
|
+
const persisted = clean(saved ?? undefined);
|
|
111
|
+
if (persisted) autoDeviceKey = persisted;
|
|
112
|
+
else void storage.setItem(key, autoDeviceKey).catch(() => {});
|
|
113
|
+
})
|
|
114
|
+
.catch(() => {});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Stamp the resolved rich context onto the event: `user_context` bucket (device_key always, plus any
|
|
118
|
+
// app_version / opt-in user_email / namespaced extra) and the top-level opaque `user_id`.
|
|
119
|
+
const applyContext = (event: ClientEvent): void => {
|
|
120
|
+
const resolved = resolveUserContext(
|
|
121
|
+
{ ...(config.userContext ?? {}), deviceKey: explicitDeviceKey ?? autoDeviceKey },
|
|
122
|
+
{ autoAppVersion: config.appVersion },
|
|
123
|
+
);
|
|
124
|
+
if (resolved.userContext) {
|
|
125
|
+
event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
|
|
126
|
+
}
|
|
127
|
+
if (resolved.userId && !event.user_id) event.user_id = resolved.userId;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const track = async (name: string, meta?: Record<string, unknown>): Promise<boolean> => {
|
|
131
|
+
const sessionId = getCurrentSessionId();
|
|
132
|
+
// No current session (no app-open registered yet) or a blank name → nothing to correlate; bail
|
|
133
|
+
// WITHOUT bumping (a bump with no posted event would only make the gate re-fetch for nothing).
|
|
134
|
+
if (!clean(name) || !sessionId) return false;
|
|
135
|
+
const event: ClientEvent = {
|
|
136
|
+
event_type: "app_event",
|
|
137
|
+
session_id: sessionId,
|
|
138
|
+
question_key: name,
|
|
139
|
+
};
|
|
140
|
+
if (meta && Object.keys(meta).length > 0) event.meta = JSON.stringify(meta);
|
|
141
|
+
applyContext(event);
|
|
142
|
+
const ok = await reportClientEventAwait(target, event);
|
|
143
|
+
// Only revalidate once the event is actually in the stream — a failed POST leaves the gate as-is.
|
|
144
|
+
if (ok) bumpActivationRevalidation();
|
|
145
|
+
return ok;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
track,
|
|
150
|
+
get sessionId() {
|
|
151
|
+
return getCurrentSessionId();
|
|
152
|
+
},
|
|
153
|
+
subscribeRevalidation: subscribeActivationRevalidation,
|
|
154
|
+
getRevalidationVersion: getActivationRevalidationVersion,
|
|
155
|
+
};
|
|
156
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* currentSession — a tiny
|
|
2
|
+
* currentSession — a tiny registry of the CURRENT per-open `session_id`.
|
|
3
3
|
*
|
|
4
4
|
* WHY it exists (kills the phantom-session): the per-open emitters (`reportSessionStart` and the
|
|
5
5
|
* `useSessionStart` / `useLifecycleEvents` hooks) mint a fresh `session_id` for each app-open and
|
|
@@ -12,24 +12,58 @@
|
|
|
12
12
|
* server already ingested. `reportSessionStart` writes the current id here on every open; the façade
|
|
13
13
|
* reads it so `identify`/app-events correlate to the real session instead of minting a phantom.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* ── WHY A globalThis SLOT, NOT A PLAIN MODULE VARIABLE ────────────────────────────────────────
|
|
16
|
+
* This module is exported from TWO package entry points — the main `.` bundle (`src/index.ts`) and
|
|
17
|
+
* the `./analytics` subpath (`src/analytics/index.ts`). Under `dist` resolution (node `import`/
|
|
18
|
+
* `require`, which is how tests, SSR and some tooling load the kit) tsup inlines a SEPARATE copy of
|
|
19
|
+
* this module into each bundle, so a plain `let` would give the SETTER (reached via `.` →
|
|
20
|
+
* `reportSessionStart`) and the READER (reached via `./analytics` → façade / `userIdentity`) TWO
|
|
21
|
+
* different variables: the reader would see `undefined` even after an open set the id, and gating
|
|
22
|
+
* would fire under a null session id. On-device this was masked only because Metro's `react-native`
|
|
23
|
+
* export condition resolves both subpaths back to this one `src/` file (a single instance) — a
|
|
24
|
+
* bundler accident, not a guarantee.
|
|
25
|
+
*
|
|
26
|
+
* The bundler-agnostic fix: keep the ONE live value in a well-known `globalThis` slot keyed by a
|
|
27
|
+
* `Symbol.for(...)`. `Symbol.for` uses the runtime-global symbol registry, so every inlined copy of
|
|
28
|
+
* this module resolves the SAME symbol and reads/writes the SAME slot — one identity no matter how
|
|
29
|
+
* many times the module is duplicated across bundles. `globalThis` is present and identical in
|
|
30
|
+
* Hermes/React Native, Node and SSR (we never touch `window`), so this is safe on every host.
|
|
31
|
+
*
|
|
32
|
+
* PROCESS-LOCAL, NOT PERSISTED: the slot lives on the runtime global, so it tracks the CURRENT
|
|
33
|
+
* process's open and a fresh open overwrites it. There is no cross-launch state.
|
|
34
|
+
* `resetCurrentSessionId` clears the slot so a unit test starts from a clean registry.
|
|
17
35
|
*/
|
|
18
36
|
|
|
19
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
|
|
39
|
+
* what makes this cross-bundle: it returns the SAME symbol for the same string across every copy of
|
|
40
|
+
* this module, so duplicated inlined copies all address one slot.
|
|
41
|
+
*/
|
|
42
|
+
const CURRENT_SESSION_ID_SLOT: unique symbol = Symbol.for(
|
|
43
|
+
"@wireai/activation:currentSessionId",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
type GlobalWithSlot = typeof globalThis & {
|
|
47
|
+
[CURRENT_SESSION_ID_SLOT]?: string | undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const globalSlot = globalThis as GlobalWithSlot;
|
|
20
51
|
|
|
21
52
|
/**
|
|
22
53
|
* Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
|
|
23
54
|
* app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
|
|
24
55
|
*/
|
|
25
56
|
export const setCurrentSessionId = (id: string | undefined): void => {
|
|
26
|
-
if (typeof id === "string" && id.length > 0)
|
|
57
|
+
if (typeof id === "string" && id.length > 0) {
|
|
58
|
+
globalSlot[CURRENT_SESSION_ID_SLOT] = id;
|
|
59
|
+
}
|
|
27
60
|
};
|
|
28
61
|
|
|
29
62
|
/** The current per-open `session_id`, or `undefined` when no app-open has been registered yet. */
|
|
30
|
-
export const getCurrentSessionId = (): string | undefined =>
|
|
63
|
+
export const getCurrentSessionId = (): string | undefined =>
|
|
64
|
+
globalSlot[CURRENT_SESSION_ID_SLOT];
|
|
31
65
|
|
|
32
66
|
/** Test-only: forget the current session id so a unit test starts from a clean registry. */
|
|
33
67
|
export const resetCurrentSessionId = (): void => {
|
|
34
|
-
|
|
68
|
+
globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
|
|
35
69
|
};
|
package/src/analytics/index.ts
CHANGED
|
@@ -31,7 +31,13 @@ export { reportAppEvent } from "../reviews/transport";
|
|
|
31
31
|
export type { ReportAppEventOptions } from "../reviews/transport";
|
|
32
32
|
|
|
33
33
|
// ─── Device-only onboarding event reporters + session id seed ─────────────────
|
|
34
|
-
export {
|
|
34
|
+
export {
|
|
35
|
+
reportClientEvent,
|
|
36
|
+
reportClientEvents,
|
|
37
|
+
reportClientEventAwait,
|
|
38
|
+
reportClientEventsAwait,
|
|
39
|
+
makeSessionId,
|
|
40
|
+
} from "./reportClientEvent";
|
|
35
41
|
export type { ClientEvent, ClientEventType, ClientEventTarget } from "./reportClientEvent";
|
|
36
42
|
|
|
37
43
|
// ─── Canonical onboarding funnel names + kit-event mapper ──────────────────────
|
|
@@ -95,6 +95,29 @@ export type ClientEventTarget = {
|
|
|
95
95
|
export const makeSessionId = (): string =>
|
|
96
96
|
`wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The ONE place the `/v1/events` POST is described (url + method + headers + body). Both the
|
|
100
|
+
* fire-and-forget {@link reportClientEvents} and the awaitable {@link reportClientEventsAwait}
|
|
101
|
+
* build their request here so there is a SINGLE definition of the events transport — no second
|
|
102
|
+
* copy of the endpoint path, headers, or envelope shape to drift. Returns `null` when there is
|
|
103
|
+
* nothing to send (no target / no events) or serialization throws, so callers just bail.
|
|
104
|
+
*/
|
|
105
|
+
const buildEventsRequest = (
|
|
106
|
+
target: ClientEventTarget | undefined,
|
|
107
|
+
events: ClientEvent[],
|
|
108
|
+
): { url: string; init: RequestInit } | null => {
|
|
109
|
+
if (!target?.serverUrl || events.length === 0) return null;
|
|
110
|
+
try {
|
|
111
|
+
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
112
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
113
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
114
|
+
return { url, init: { method: "POST", headers, body: JSON.stringify({ events }) } };
|
|
115
|
+
} catch {
|
|
116
|
+
// URL construction or JSON serialization failed — nothing to send.
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
98
121
|
/**
|
|
99
122
|
* POST one or more client events, fire-and-forget. A missing/invalid target, a build error,
|
|
100
123
|
* a missing `fetch`, or a network failure is swallowed — the call returns immediately and the
|
|
@@ -104,20 +127,14 @@ export const reportClientEvents = (
|
|
|
104
127
|
target: ClientEventTarget | undefined,
|
|
105
128
|
events: ClientEvent[],
|
|
106
129
|
): void => {
|
|
107
|
-
if (!target?.serverUrl || events.length === 0) return;
|
|
108
130
|
try {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
void fetch(url, {
|
|
113
|
-
method: "POST",
|
|
114
|
-
headers,
|
|
115
|
-
body: JSON.stringify({ events }),
|
|
116
|
-
}).catch(() => {
|
|
131
|
+
const req = buildEventsRequest(target, events);
|
|
132
|
+
if (!req) return;
|
|
133
|
+
void fetch(req.url, req.init).catch(() => {
|
|
117
134
|
// Network/transport error — analytics is best-effort, swallow.
|
|
118
135
|
});
|
|
119
136
|
} catch {
|
|
120
|
-
//
|
|
137
|
+
// A missing `fetch` — swallow.
|
|
121
138
|
}
|
|
122
139
|
};
|
|
123
140
|
|
|
@@ -126,3 +143,32 @@ export const reportClientEvent = (
|
|
|
126
143
|
target: ClientEventTarget | undefined,
|
|
127
144
|
event: ClientEvent,
|
|
128
145
|
): void => reportClientEvents(target, [event]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* AWAITABLE sibling of {@link reportClientEvents}: POST one or more client events through the SAME
|
|
149
|
+
* `/v1/events` path, but resolve only once the server has RESPONDED — so a decision re-fetch fired
|
|
150
|
+
* immediately after is guaranteed to see the event in the session stream (this is the guarantee
|
|
151
|
+
* `wire.track` needs before it triggers decision revalidation). Never throws: a missing/invalid
|
|
152
|
+
* target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`. Resolves
|
|
153
|
+
* `true` only on a 2xx response.
|
|
154
|
+
*/
|
|
155
|
+
export const reportClientEventsAwait = async (
|
|
156
|
+
target: ClientEventTarget | undefined,
|
|
157
|
+
events: ClientEvent[],
|
|
158
|
+
): Promise<boolean> => {
|
|
159
|
+
try {
|
|
160
|
+
const req = buildEventsRequest(target, events);
|
|
161
|
+
if (!req) return false;
|
|
162
|
+
const res = await fetch(req.url, req.init);
|
|
163
|
+
return Boolean(res && res.ok);
|
|
164
|
+
} catch {
|
|
165
|
+
// Unreachable / missing-fetch / network — best-effort, report failure.
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** Convenience single-event wrapper around {@link reportClientEventsAwait}. */
|
|
171
|
+
export const reportClientEventAwait = (
|
|
172
|
+
target: ClientEventTarget | undefined,
|
|
173
|
+
event: ClientEvent,
|
|
174
|
+
): Promise<boolean> => reportClientEventsAwait(target, [event]);
|
package/src/index.ts
CHANGED
|
@@ -167,6 +167,24 @@ export {
|
|
|
167
167
|
resetCurrentSessionId,
|
|
168
168
|
} from "./analytics/currentSession";
|
|
169
169
|
|
|
170
|
+
// ─── Awaitable client-event report (the transport `wire.track` posts through) ─
|
|
171
|
+
export { reportClientEventAwait, reportClientEventsAwait } from "./analytics/reportClientEvent";
|
|
172
|
+
|
|
173
|
+
// ─── Activation consolidation: wire.track() + useWireActivation() + revalidation ──
|
|
174
|
+
export { createWireActivation, useWireActivation } from "./activation";
|
|
175
|
+
export type {
|
|
176
|
+
WireActivation,
|
|
177
|
+
WireActivationConfig,
|
|
178
|
+
UseWireActivation,
|
|
179
|
+
} from "./activation";
|
|
180
|
+
export {
|
|
181
|
+
useActivationRevalidation,
|
|
182
|
+
bumpActivationRevalidation,
|
|
183
|
+
subscribeActivationRevalidation,
|
|
184
|
+
getActivationRevalidationVersion,
|
|
185
|
+
resetActivationRevalidation,
|
|
186
|
+
} from "./activation";
|
|
187
|
+
|
|
170
188
|
// ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
|
|
171
189
|
export {
|
|
172
190
|
reportSessionStart,
|
|
@@ -141,3 +141,20 @@ export const clearPersistedSession = (storage: WireOnboardingStorage, key: strin
|
|
|
141
141
|
// Best-effort.
|
|
142
142
|
}
|
|
143
143
|
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The opt-in decision for `WireOnboarding.handleComplete`: given the host's
|
|
147
|
+
* `retainSessionOnComplete` opt-in, whether a completion should CLEAR the persisted seed.
|
|
148
|
+
* (The caller still gates on `storage` being present — this only encodes the opt-in axis, so the
|
|
149
|
+
* legacy `if (storage) clearPersistedSession(...)` guard stays byte-for-byte for a host that does
|
|
150
|
+
* not opt in.)
|
|
151
|
+
*
|
|
152
|
+
* - undefined / false (the default) → `true`: clear on completion, so the NEXT onboarding on this
|
|
153
|
+
* device mints a fresh session. This is the legacy single-stage behavior (e.g. Myelino).
|
|
154
|
+
* - true → `false`: LEAVE the seed in place so a same-signup re-entry/remount within the TTL
|
|
155
|
+
* resumes the SAME session (one funnel start) instead of minting a phantom second `started`.
|
|
156
|
+
* Freshness for a genuinely new run is then governed by the TTL (past `sessionTtlMs` → fresh
|
|
157
|
+
* mint) and an explicit new-run signal (a changed `persistKey`).
|
|
158
|
+
*/
|
|
159
|
+
export const shouldClearOnComplete = (retainSessionOnComplete: boolean | undefined): boolean =>
|
|
160
|
+
!retainSessionOnComplete;
|
package/src/types.ts
CHANGED
|
@@ -211,6 +211,22 @@ export type WireOnboardingProps = {
|
|
|
211
211
|
* accounts mid-flow. Only meaningful with `storage`.
|
|
212
212
|
*/
|
|
213
213
|
persistKey?: string;
|
|
214
|
+
/**
|
|
215
|
+
* OPT-IN: keep the persisted session seed alive ACROSS completion, so a multi-stage or
|
|
216
|
+
* replay-after-complete re-entry WITHIN one signup (typically a `key=` remount) resumes the
|
|
217
|
+
* SAME session instead of minting a fresh `metadata.sessionId` — which the backend would adopt
|
|
218
|
+
* as a second `contextId`, double-counting a `session_started` on the funnel.
|
|
219
|
+
*
|
|
220
|
+
* Default (omitted / `false`): a completion CLEARS the seed, so the next onboarding on this
|
|
221
|
+
* device starts fresh — the legacy single-stage behavior. Leave it unset and NOTHING changes.
|
|
222
|
+
*
|
|
223
|
+
* When `true`: the seed survives completion, and freshness for a genuinely new run is governed
|
|
224
|
+
* by the TTL (past `sessionTtlMs` → a fresh seed is minted) and an explicit new-run signal
|
|
225
|
+
* (scope a new `persistKey`, e.g. a per-signup id, to force a fresh seed within the TTL).
|
|
226
|
+
*
|
|
227
|
+
* Only meaningful with `storage`. Dropped/degraded sessions are unaffected (they never clear).
|
|
228
|
+
*/
|
|
229
|
+
retainSessionOnComplete?: boolean;
|
|
214
230
|
};
|
|
215
231
|
|
|
216
232
|
/** Backend-supplied progress, read off `response.props.progress` when present. */
|