Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@ The Rokt web kit (`@mparticle/web-rokt-kit`) is an mParticle integration kit (fo
```
/
src/
Rokt-Kit.ts # Single monolithic source file
Rokt-Kit.ts # Main kit source (forwarder class + registration)
storage.ts # Key-agnostic localStorage helpers (readJSON/writeJSON/removeKey)
selectPlacementsAttributePersistence.ts # Attribute persistence deny-list
dist/
Rokt-Kit.iife.js # Browser bundle (IIFE)
Rokt-Kit.common.js # npm bundle (CommonJS)
Rokt-Kit.d.ts # Type definitions
test/
src/
tests.spec.ts # Vitest test suite
tests.spec.ts # Main Vitest test suite (Rokt-Kit.ts)
storage.spec.ts # Unit tests for storage.ts helpers
vitest.setup.ts # Global test setup / mParticle mock
lib/ # Test utilities
end-to-end-testapp/ # E2E test app
Expand Down Expand Up @@ -58,11 +61,12 @@ The `dist/` folder, `CHANGELOG.md`, and version bumps in `package.json`/`package

## Code Conventions

- **Single source file**: All kit logic lives in `src/Rokt-Kit.ts`
- **Prefer small, focused modules**: `src/Rokt-Kit.ts` is the entry point (forwarder class + registration), but favor extracting cohesive concerns into sibling modules (as with `storage.ts`, `selectPlacementsAttributePersistence.ts`) rather than growing `Rokt-Kit.ts`. Vite/Rollup bundles all source files into the single `dist/` output, so extraction is free — it doesn't change the shipped bundle shape. When you add or touch a self-contained concern (storage, serialization, a deny-list, event mapping, etc.), pull it into its own module with a clear name and a co-located `*.spec.ts`. Keep only orchestration and kit lifecycle in `Rokt-Kit.ts`.
- **TypeScript class pattern**: `class RoktKit { ... }` with typed public/private members
- **const/let**: Use `const` for values that don't change, `let` for reassignable variables
- **Strict TypeScript**: `strict: true` — all values must be typed, no implicit `any`
- **Module registration**: Kit self-registers via `window.mParticle.addForwarder()` at load time
- **No unnecessary comments**: Don't restate what the code already says. Reserve comments for non-obvious *why* — rationale, invariants, gotchas (e.g. why storage writes swallow errors, why a migration is byte-for-byte). Delete comments that a reader could infer from the code itself.

## Architecture

Expand All @@ -81,7 +85,7 @@ The `dist/` folder, `CHANGELOG.md`, and version bumps in `package.json`/`package

## Common Gotchas

1. **Single file**: All changes go in `src/Rokt-Kit.ts` — there are no imports/modules
1. **Favor modular extraction**: `src/Rokt-Kit.ts` is the entry point, but prefer splitting self-contained concerns into sibling modules (e.g. `storage.ts`, `selectPlacementsAttributePersistence.ts`) rather than growing the entry file. Co-located `*.spec.ts` under `src/` are picked up by Vitest (see `vite.config.ts` `test.include`), so each extracted module can carry its own unit tests
2. **Browser-only**: Code runs in browser context, `window` is always available
3. **Async launcher**: Rokt launcher loads asynchronously — events must be queued until ready
4. **Window extensions**: `window.Rokt` and `window.mParticle.Rokt` are typed via `declare global`
Expand Down
91 changes: 54 additions & 37 deletions src/Rokt-Kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
removeSelectPlacementsAttributePersistenceDeniedAttributes,
} from './selectPlacementsAttributePersistence';

import { readJSON, removeKey, readNamespacedField, writeNamespacedField, removeNamespacedField } from './storage';

interface RoktKitSettings {
accountId: string;
roktExtensions?: string;
Expand Down Expand Up @@ -257,14 +259,10 @@ const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace';

const MESSAGE_TYPE_PAGE_VIEW = 3; // mParticle MessageType.PageView
const MESSAGE_TYPE_SESSION_END = 2; // mParticle MessageType.SessionEnd
// localStorage key under which captured page views are persisted (as a JSON
// string). The kit owns this storage directly — separate from mParticle's
// cookie/localStorage — so page-view capture does not affect mParticle
// persistence or cookie sync. Distinct from PAGE_EVENTS_KEY, which is the
// flattened wire shape sent to Rokt on selectPlacements.
const LS_PAGE_VIEWS_KEY = 'mpPageViews';
// Fixed cap on the number of persisted page views (oldest evicted first). Code
// constant, not a kit setting — change it here.
const LS_NAMESPACE_KEY = 'mp-rokt-kit';
const LS_PAGE_VIEWS_FIELD = 'pageViews';
// TODO: remove after 2027-02-11 — one-time migration of the legacy key.
const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews';
const PAGE_VIEWS_MAX_COUNT = 25;
const PAGE_EVENTS_KEY = 'page_events';

Expand Down Expand Up @@ -311,25 +309,47 @@ function mp(): MParticleExtended {
// Module-level utility functions
// ============================================================

function readPageViewsStorage(): PageEvent[] {
try {
const stored = window.localStorage.getItem(LS_PAGE_VIEWS_KEY);
if (stored === null) {
return [];
// TODO: remove after 2027-02-11 — one-time migration of the legacy 'mpPageViews'
// key into the namespaced storage object's pageViews field. Everything
// migration-related is confined to this function + LEGACY_PAGE_VIEWS_KEY so it
// can be deleted as a single unit.
// Unconditional (no freshness gate): staleness is mParticle's job — a timed-out
// prior session fires SessionEnd (→ clear) before selectPlacements runs.
function migrateLegacyPageViewStorage(loggingService: LoggingService | null): void {
const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY);
if (legacyViews === null) {
return;
}

const alreadyMigrated = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) !== undefined;
const needsMigration = !alreadyMigrated && Array.isArray(legacyViews);

if (needsMigration) {
const migrated = writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews);
if (!migrated) {
loggingService?.log({
message: 'Rokt Kit: Failed to migrate legacy page-view storage; retaining legacy key for retry',
code: 'PAGE_VIEW_CAPTURE_FAILED',
});
return;
}
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? (parsed as PageEvent[]) : [];
} catch {
return [];
}

removeKey(LEGACY_PAGE_VIEWS_KEY);
}

function writePageViewsStorage(pageViews: PageEvent[]): void {
window.localStorage.setItem(LS_PAGE_VIEWS_KEY, JSON.stringify(pageViews));
function loadPageViews(loggingService: LoggingService | null): PageEvent[] {
migrateLegacyPageViewStorage(loggingService);
const stored = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD);
return Array.isArray(stored) ? (stored as PageEvent[]) : [];
}

function writePageViewsStorage(pageViews: PageEvent[]): boolean {
return writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews);
}

function clearPageViewsStorage(): void {
window.localStorage.removeItem(LS_PAGE_VIEWS_KEY);
removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD);
}

function generateLauncherScript(domain: string | undefined, extensions: string[]): string {
Expand Down Expand Up @@ -909,7 +929,7 @@ class RoktKit implements KitInterface {
try {
pageUrl = sanitizeUrl(window.location.href);

const pageViews = readPageViewsStorage();
const pageViews = loadPageViews(this.loggingService);

const pageView: PageEvent = {
pageUrl,
Expand All @@ -927,13 +947,18 @@ class RoktKit implements KitInterface {
pageViews.shift();
}

writePageViewsStorage(pageViews);
if (!writePageViewsStorage(pageViews)) {
this.loggingService?.log({
message: `Rokt Kit: Failed to persist page view for ${pageUrl}`,
code: 'PAGE_VIEW_CAPTURE_FAILED',
});
}
} catch (err) {
this.errorReportingService?.report({
message: `Rokt Kit: Failed to capture page view for ${pageUrl}`,
this.loggingService?.log({
message: `Rokt Kit: Failed to capture page view for ${pageUrl}: ${
err instanceof Error ? err.message : String(err)
}`,
code: 'PAGE_VIEW_CAPTURE_FAILED',
severity: WSDKErrorSeverity.INFO,
stackTrace: err instanceof Error ? err.stack : undefined,
});
}
}
Expand Down Expand Up @@ -1305,16 +1330,8 @@ class RoktKit implements KitInterface {
}

if (event.EventDataType === MESSAGE_TYPE_SESSION_END) {
try {
clearPageViewsStorage();
} catch (err) {
this.errorReportingService?.report({
message: 'Rokt Kit: Failed to clear page views on session end',
code: 'PAGE_VIEW_CAPTURE_FAILED',
severity: WSDKErrorSeverity.INFO,
stackTrace: err instanceof Error ? err.stack : undefined,
});
}
migrateLegacyPageViewStorage(this.loggingService);
clearPageViewsStorage();
}
}

Expand Down Expand Up @@ -1533,7 +1550,7 @@ class RoktKit implements KitInterface {
const filteredUserIdentities = this.returnUserIdentities(filteredUser);

const sessionAttributes = this.returnLocalSessionAttributes();
const pageEvents = this.buildPageEvents(readPageViewsStorage());
const pageEvents = this.buildPageEvents(loadPageViews(this.loggingService));

const selectPlacementsAttributes: Record<string, unknown> = {
...(filteredUserIdentities as Record<string, unknown>),
Expand Down
55 changes: 55 additions & 0 deletions src/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export function readJSON(key: string): unknown {
try {
const stored = window.localStorage.getItem(key);
return stored === null ? null : JSON.parse(stored);
} catch {
return null;
}
}

export function writeJSON(key: string, value: unknown): boolean {
Comment thread
crisryantan marked this conversation as resolved.
try {
window.localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}

export function removeKey(key: string): void {
try {
window.localStorage.removeItem(key);
} catch {
/* empty */
}
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

export function readNamespacedField(namespaceKey: string, field: string): unknown {
const blob = readJSON(namespaceKey);
return isPlainObject(blob) ? blob[field] : undefined;
}

export function writeNamespacedField(namespaceKey: string, field: string, value: unknown): boolean {
const blob = readJSON(namespaceKey);
const next = isPlainObject(blob) ? { ...blob } : {};
next[field] = value;
return writeJSON(namespaceKey, next);
}

export function removeNamespacedField(namespaceKey: string, field: string): void {
const blob = readJSON(namespaceKey);
if (!isPlainObject(blob) || !(field in blob)) {
return;
}
const next = { ...blob };
delete next[field];
if (Object.keys(next).length === 0) {
removeKey(namespaceKey);
} else {
writeJSON(namespaceKey, next);
}
}
Loading
Loading