Skip to content
26 changes: 0 additions & 26 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,6 @@ The Rokt web kit (`@mparticle/web-rokt-kit`) is an mParticle integration kit (fo
- **Code Quality**: ESLint v9 flat config + `@typescript-eslint/recommended`
- **Formatting**: Prettier (120 chars, single quotes, trailing commas)

## Project Structure

```
/
src/
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 # 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
vite.config.ts # Build + test configuration
tsconfig.json # TypeScript config (src only)
tsconfig.test.json # TypeScript config (src + test)
eslint.config.mjs # ESLint v9 flat config
package.json
```

## Key Commands

```bash
Expand Down
99 changes: 13 additions & 86 deletions src/Rokt-Kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ import {
removeSelectPlacementsAttributePersistenceDeniedAttributes,
} from './selectPlacementsAttributePersistence';

import { readJSON, removeKey, readNamespacedField, writeNamespacedField, removeNamespacedField } from './storage';
import {
PageEvent,
migrateLegacyPageViewStorage,
loadPageViews,
writePageViews,
clearPageViews,
} from './pageViewStorage';

import { isObject, isString, isEmpty } from './utils';

interface RoktKitSettings {
accountId: string;
Expand Down Expand Up @@ -63,17 +71,6 @@ interface RoktExtensionEntry {
value: string;
}

interface PageEvent {
pageUrl: string;
sourceMessageId: string;
timestamp: number;
activeTimeOnSite?: number;
// Derived at transmission not at capture based
// on the next page view's activeTimeOnSite,
// so it is absent on stored records.
activeTimeOnPage?: number;
}

interface RoktSelection {
context?: {
sessionId?: Promise<string>;
Expand Down Expand Up @@ -259,11 +256,6 @@ 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
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';

// Bound on how long selectPlacements will wait for an in-flight Workspace
Expand Down Expand Up @@ -309,49 +301,6 @@ function mp(): MParticleExtended {
// Module-level utility functions
// ============================================================

// 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;
}
}

removeKey(LEGACY_PAGE_VIEWS_KEY);
}

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 {
removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD);
}

function generateLauncherScript(domain: string | undefined, extensions: string[]): string {
const launcherPath = '/wsdk/integrations/launcher.js';
const baseUrl = [generateBaseUrl(domain), launcherPath].join('');
Expand Down Expand Up @@ -405,10 +354,6 @@ function loadRoktScript(
target.appendChild(script);
}

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

function parseSettingsString<T>(settingsString?: string): T[] {
if (!settingsString) {
return [];
Expand Down Expand Up @@ -500,21 +445,6 @@ function hashEventMessage(messageType: number, eventType: number, eventName: str
return mp().generateHash([messageType, eventType, eventName].join(''));
}

function isEmpty(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'object') {
return Object.keys(value as object).length === 0;
}
if (Array.isArray(value)) {
return (value as unknown[]).length === 0;
}
return false;
}

function isString(value: unknown): value is string {
return typeof value === 'string';
}

// Strips the query string from a page-view URL before it is persisted and sent
// to Rokt, since query params commonly carry PII (emails, tokens, order refs).
// Returns the input unchanged if it can't be parsed as a URL.
Expand Down Expand Up @@ -943,11 +873,7 @@ class RoktKit implements KitInterface {

pageViews.push(pageView);

while (pageViews.length > PAGE_VIEWS_MAX_COUNT) {
pageViews.shift();
}

if (!writePageViewsStorage(pageViews)) {
if (!writePageViews(pageViews)) {
this.loggingService?.log({
message: `Rokt Kit: Failed to persist page view for ${pageUrl}`,
code: 'PAGE_VIEW_CAPTURE_FAILED',
Expand Down Expand Up @@ -1242,7 +1168,7 @@ class RoktKit implements KitInterface {

if (this.isTargetingDisabled()) {
try {
clearPageViewsStorage();
clearPageViews();
} catch (err) {
this.errorReportingService?.report({
message: 'Rokt Kit: Failed to clear page views when targeting is disabled',
Expand Down Expand Up @@ -1331,7 +1257,7 @@ class RoktKit implements KitInterface {

if (event.EventDataType === MESSAGE_TYPE_SESSION_END) {
migrateLegacyPageViewStorage(this.loggingService);
clearPageViewsStorage();
clearPageViews();
}
}

Expand Down Expand Up @@ -1656,3 +1582,4 @@ if (typeof window !== 'undefined' && window.mParticle && mp().addForwarder) {
}

export { register };
export type { LoggingService };
59 changes: 59 additions & 0 deletions src/pageViewStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { LoggingService } from './Rokt-Kit';
import {
readJSON,
removeKey,
readNamespacedField,
writeNamespacedField,
removeNamespacedField,
writeNamespacedFieldWithinBudget,
} from './storage';

const LS_NAMESPACE_KEY = 'mp-rokt-kit';
const LS_PAGE_VIEWS_FIELD = 'pageViews';
const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews';
const PAGE_VIEWS_MAX_LENGTH = 100 * 1024;

export interface PageEvent {
pageUrl: string;
sourceMessageId: string;
timestamp: number;
activeTimeOnSite?: number;
activeTimeOnPage?: number;
}

export 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;
}
}

removeKey(LEGACY_PAGE_VIEWS_KEY);
}

export 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[]) : [];
}

export function writePageViews(pageViews: PageEvent[]): boolean {
return writeNamespacedFieldWithinBudget(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews, PAGE_VIEWS_MAX_LENGTH);
}

export function clearPageViews(): void {
removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD);
}
44 changes: 37 additions & 7 deletions src/storage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isObject } from './utils';

export function readJSON(key: string): unknown {
try {
const stored = window.localStorage.getItem(key);
Expand All @@ -24,25 +26,21 @@ export function removeKey(key: string): void {
}
}

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;
return isObject(blob) ? blob[field] : undefined;
}

export function writeNamespacedField(namespaceKey: string, field: string, value: unknown): boolean {
const blob = readJSON(namespaceKey);
const next = isPlainObject(blob) ? { ...blob } : {};
const next = isObject(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)) {
if (!isObject(blob) || !(field in blob)) {
return;
}
const next = { ...blob };
Expand All @@ -53,3 +51,35 @@ export function removeNamespacedField(namespaceKey: string, field: string): void
writeJSON(namespaceKey, next);
}
}

export function writeNamespacedFieldWithinBudget(
namespaceKey: string,
field: string,
records: unknown[],
maxLength: number,
): boolean {
// Operate on a copy so the caller's array isn't trimmed as a side effect.
const remaining = records.slice();

const evictOldest = (): boolean => {
if (remaining.length <= 1) {
return false;
}
remaining.shift();
return true;
};

// Two limits: our own soft cap (maxLength), then the browser's hard quota,
// which is shared across the origin and only surfaces when setItem throws.
let overBudget = JSON.stringify(remaining).length > maxLength;
while (overBudget && evictOldest()) {
overBudget = JSON.stringify(remaining).length > maxLength;
}

let written = writeNamespacedField(namespaceKey, field, remaining);
while (!written && evictOldest()) {
written = writeNamespacedField(namespaceKey, field, remaining);
}

return written;
Comment thread
rmi22186 marked this conversation as resolved.
}
15 changes: 15 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

export function isString(value: unknown): value is string {
return typeof value === 'string';
}

export function isEmpty(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'object') {
return Object.keys(value as object).length === 0;
}
return false;
}
Loading
Loading