@wireai/activation 0.9.2 → 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 +39 -20
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +38 -21
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-Dj0cN3Rg.d.ts → currentSession-D0Vq7_VE.d.ts} +33 -4
- package/dist/{currentSession-CRRQLbAq.d.mts → currentSession-DdDkprpM.d.mts} +33 -4
- package/dist/index.d.mts +116 -3
- package/dist/index.d.ts +116 -3
- package/dist/index.js +155 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +148 -22
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js +1 -2
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +1 -2
- package/dist/questionnaire/index.mjs.map +1 -1
- package/llms.txt +1 -0
- package/package.json +1 -1
- 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/eventQueue.ts +9 -28
- package/src/analytics/index.ts +7 -1
- package/src/analytics/reportClientEvent.ts +56 -10
- package/src/index.ts +18 -0
- package/src/questionnaire/transport.ts +1 -9
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>",
|
|
@@ -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
|
};
|
|
@@ -224,25 +224,10 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
224
224
|
}
|
|
225
225
|
})();
|
|
226
226
|
|
|
227
|
-
// The
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// it (head-of-line). See DROP_STATUSES.
|
|
232
|
-
// "retry" → transient or tenant-wide (network/5xx/429/auth) → keep the batch and back off.
|
|
233
|
-
type PostResult = "ok" | "drop" | "retry";
|
|
234
|
-
|
|
235
|
-
// Statuses where retrying THIS batch is futile because the batch itself is the problem — a
|
|
236
|
-
// malformed body (400/422), a too-large body (413), or a wrong route (404). Dropping a poison
|
|
237
|
-
// batch is what stops it from stalling the whole backlog. Deliberately NOT here: 401/403 (auth is
|
|
238
|
-
// tenant-wide, not batch-specific — dropping would silently lose EVERY event on a recoverable
|
|
239
|
-
// credential blip, so we keep retrying/pausing instead) and 429/5xx (transient).
|
|
240
|
-
const DROP_STATUSES = new Set([400, 404, 413, 422]);
|
|
241
|
-
|
|
242
|
-
// The queue's OWN awaited POST. Classifies the response to drive dequeue/drop/retry. NEVER throws —
|
|
243
|
-
// a missing fetch, a rejecting network, or an abort resolves to "retry" (batch stays, retry schedules).
|
|
244
|
-
const postBatch = async (events: ClientEvent[]): Promise<PostResult> => {
|
|
245
|
-
if (!target?.serverUrl || events.length === 0) return "retry";
|
|
227
|
+
// The queue's OWN awaited POST. Reads `res.ok` to drive retry/dequeue. NEVER throws — a missing
|
|
228
|
+
// fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
|
|
229
|
+
const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
|
|
230
|
+
if (!target?.serverUrl || events.length === 0) return false;
|
|
246
231
|
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
247
232
|
const timer = setTimeout(() => controller?.abort(), 15_000);
|
|
248
233
|
try {
|
|
@@ -255,12 +240,9 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
255
240
|
body: JSON.stringify({ events }),
|
|
256
241
|
signal: controller?.signal,
|
|
257
242
|
});
|
|
258
|
-
|
|
259
|
-
const status = (res as { status?: number } | undefined)?.status;
|
|
260
|
-
if (typeof status === "number" && DROP_STATUSES.has(status)) return "drop";
|
|
261
|
-
return "retry";
|
|
243
|
+
return !!(res && (res as { ok?: boolean }).ok);
|
|
262
244
|
} catch {
|
|
263
|
-
return
|
|
245
|
+
return false;
|
|
264
246
|
} finally {
|
|
265
247
|
clearTimeout(timer);
|
|
266
248
|
}
|
|
@@ -298,13 +280,12 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
298
280
|
try {
|
|
299
281
|
while (pending.length > 0) {
|
|
300
282
|
const batch = pending.slice(0, batchSize);
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
283
|
+
const ok = await postBatch(batch.map((item) => item.event));
|
|
284
|
+
if (!ok) {
|
|
303
285
|
scheduleRetry();
|
|
304
286
|
return;
|
|
305
287
|
}
|
|
306
|
-
//
|
|
307
|
-
// exactly this batch by id (pending may have grown while in flight) and keep draining.
|
|
288
|
+
// Dequeue exactly the acked batch by id (pending may have grown while in flight).
|
|
308
289
|
const acked = new Set(batch.map((item) => item.id));
|
|
309
290
|
pending = pending.filter((item) => !acked.has(item.id));
|
|
310
291
|
persist();
|
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,
|
|
@@ -75,15 +75,7 @@ export const fetchQuestionnaireDecision = async (
|
|
|
75
75
|
const res = await fetch(url, { headers });
|
|
76
76
|
if (!res || !res.ok) return null;
|
|
77
77
|
const json = (await res.json()) as QuestionnaireDecisionResponse | null;
|
|
78
|
-
|
|
79
|
-
// `fetchReviewDecision` does. Without this, a malformed 2xx body flows straight through:
|
|
80
|
-
// `{}` reads as a truthy verdict whose `fire` is undefined (silently "never fire", killing
|
|
81
|
-
// a questionnaire the local rules would have shown), and a stringy `{fire:"yes"}` reads as
|
|
82
|
-
// FIRE while carrying no `questionnaire`, so the host renders a gate with an undefined
|
|
83
|
-
// definition. Only a genuine boolean-`fire` body is a decision; everything else → null (the
|
|
84
|
-
// server has no opinion → the local rules stand).
|
|
85
|
-
if (!json || typeof json.fire !== "boolean") return null;
|
|
86
|
-
return json;
|
|
78
|
+
return json ?? null;
|
|
87
79
|
} catch {
|
|
88
80
|
/* unreachable / non-2xx / bad JSON / missing-fetch - never show */
|
|
89
81
|
return null;
|