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
80 changes: 71 additions & 9 deletions src/Rokt-Kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ import {
import { isLocalStorageAvailable } from './storage';

import { isObject, isString, isEmpty, isFunction, sanitizeUrl } from './utils';
import {
createLauncherAttachState,
markLauncherAttached,
markLauncherAttachFailed,
markLauncherTerminated,
rememberAttachContext,
recreateIfTerminated,
resetLauncherAttachState,
type LauncherAttachState,
type RoktLauncherOptions,
} from './launcherAttachState';

interface RoktKitSettings {
accountId: string;
Expand Down Expand Up @@ -86,11 +97,12 @@ interface RoktLauncher {
selectPlacements(options: Record<string, unknown>): RoktSelection | Promise<RoktSelection>;
hashAttributes(attributes: Record<string, unknown>): Promise<Record<string, unknown>>;
use(extensionName: string): Promise<unknown>;
terminate(): Promise<void>;
}

interface RoktGlobal {
createLauncher(options: Record<string, unknown>): Promise<RoktLauncher>;
createLocalLauncher(options: Record<string, unknown>): RoktLauncher;
createLauncher(options: RoktLauncherOptions): Promise<RoktLauncher>;
createLocalLauncher(options: RoktLauncherOptions): RoktLauncher;
currentLauncher?: RoktLauncher;
setExtensionData(data: Record<string, unknown>): void;
}
Expand Down Expand Up @@ -191,6 +203,7 @@ interface TestHelpers {
RateLimiter: typeof RateLimiter;
ErrorCodes: typeof ErrorCodes;
WSDKErrorSeverity: typeof WSDKErrorSeverity;
resetLauncherAttachState: () => void;
}

interface ForwarderRegistration {
Expand Down Expand Up @@ -781,6 +794,8 @@ class RoktKit implements KitInterface {
// so a re-login re-evaluates fresh.
private _workspaceLastSearchedIdentitiesKey?: string;

private _launcherAttachState: LauncherAttachState = createLauncherAttachState();

// ---- Private helpers ----

private getEventAttributeValue(event: SDKEvent, eventAttributeKey: string): unknown {
Expand Down Expand Up @@ -981,10 +996,16 @@ class RoktKit implements KitInterface {

private attachLauncher(
accountId: string,
launcherOptions: Record<string, unknown>,
legacyRoktExtensions: string[] = [],
): void {
const options: Record<string, unknown> = {
launcherOptions: RoktLauncherOptions,
legacyRoktExtensions: readonly string[] = [],
): Promise<void> {
rememberAttachContext(this._launcherAttachState, {
accountId,
launcherOptions: launcherOptions || {},
legacyRoktExtensions,
});

const options: RoktLauncherOptions = {
accountId,
...(launcherOptions || {}),
};
Expand All @@ -996,23 +1017,31 @@ class RoktKit implements KitInterface {
launcherPromise = window.Rokt!.createLauncher(options);
}

launcherPromise
return launcherPromise
.then(async (launcher) => {
await registerLegacyExtensions(legacyRoktExtensions, launcher);
await registerLegacyExtensions([...legacyRoktExtensions], launcher);
this.initRoktLauncher(launcher);
})
.catch((err: unknown) => {
markLauncherAttachFailed(this._launcherAttachState);
console.error('Error creating Rokt launcher:', err);
});
}

private recreateLauncherIfTerminated(): Promise<void> | undefined {
return recreateIfTerminated(this._launcherAttachState, this.isLauncherReadyToAttach(), (context) =>
this.attachLauncher(context.accountId, context.launcherOptions, context.legacyRoktExtensions),
);
}

private initRoktLauncher(launcher: RoktLauncher): void {
// Assign the launcher to a global variable for later access
if (window.Rokt) {
window.Rokt.currentLauncher = launcher;
}
// Locally cache the launcher and filters
this.launcher = launcher;
markLauncherAttached(this._launcherAttachState);

const roktFilters = mp().Rokt?.filters;

Expand Down Expand Up @@ -1196,6 +1225,7 @@ class RoktKit implements KitInterface {
RateLimiter: RateLimiter,
ErrorCodes: ErrorCodes,
WSDKErrorSeverity: WSDKErrorSeverity,
resetLauncherAttachState: () => resetLauncherAttachState(this._launcherAttachState),
};
this.attachLauncher(accountId, launcherOptions);
return 'Successfully initialized: ' + name;
Expand Down Expand Up @@ -1424,9 +1454,23 @@ class RoktKit implements KitInterface {
* rejects it as the awaited return of an async function (TS1058) —
* working around that would require a cast or wrapping every return in
* `Promise.resolve(...)`. The inner work runs in `_dispatchPlacements`;
* this wrapper just gates it on the in-flight search via `Promise.race`.
* this wrapper just gates it on the in-flight search via `Promise.race`,
* and on a post-terminate createLauncher when the SPA needs a new instance.
*/
public selectPlacements(options: Record<string, unknown>): RoktSelection | Promise<RoktSelection> | undefined {
const recreate = this.recreateLauncherIfTerminated();
if (recreate) {
const inFlight = this._workspaceSearchInFlightPromise;
const waitForSearch = inFlight
? Promise.race([
inFlight,
new Promise<void>((resolve) => setTimeout(resolve, WORKSPACE_SEARCH_SELECT_TIMEOUT_MS)),
])
: Promise.resolve();
return Promise.all([recreate, waitForSearch]).then(() =>
this._dispatchPlacements(options),
) as Promise<RoktSelection>;
}
if (this._workspaceSearchInFlightPromise) {
const inFlight = this._workspaceSearchInFlightPromise;
return Promise.race([
Expand Down Expand Up @@ -1521,6 +1565,24 @@ class RoktKit implements KitInterface {
return this.launcher!.use(extensionName);
}

/**
* Tears down the Rokt launcher and the placements it rendered.
*
* The kit's launcher reference is left in place so isKitReady() stays true.
* The Web SDK clears its memoized launcher on terminate, so a later
* createLauncher (SPA navigation) produces a new instance. The next
* selectPlacements call re-attaches that instance. Nulling the reference
* here would flip the kit to not-ready with no drain path for queued calls.
*/
public terminate(): Promise<void> {
if (!this.isKitReady()) {
console.error('Rokt Kit: Not initialized');
return Promise.resolve();
}
markLauncherTerminated(this._launcherAttachState);
return this.launcher!.terminate();
}

/**
* Registers a callback to be invoked once rokt-thank-you-element.js becomes available.
*/
Expand Down
74 changes: 74 additions & 0 deletions src/launcherAttachState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export type RoktLauncherOptions = Record<string, unknown>;

export interface LauncherAttachContext {
accountId: string;
launcherOptions: RoktLauncherOptions;
legacyRoktExtensions: readonly string[];
}

export type LauncherLifecycle = 'idle' | 'attached' | 'terminated' | 'recreating';

export interface LauncherAttachState {
context: LauncherAttachContext | null;
lifecycle: LauncherLifecycle;
recreateInFlight: Promise<void> | null;
}

export type AttachLauncher = (context: LauncherAttachContext) => Promise<void>;

export function createLauncherAttachState(): LauncherAttachState {
return {
context: null,
lifecycle: 'idle',
recreateInFlight: null,
};
}

export function rememberAttachContext(state: LauncherAttachState, context: LauncherAttachContext): void {
state.context = {
accountId: context.accountId,
launcherOptions: { ...context.launcherOptions },
legacyRoktExtensions: [...context.legacyRoktExtensions],
};
}

export function markLauncherAttached(state: LauncherAttachState): void {
state.lifecycle = 'attached';
}

export function markLauncherTerminated(state: LauncherAttachState): void {
if (state.lifecycle === 'idle') {
return;
}
state.lifecycle = 'terminated';
}

export function markLauncherAttachFailed(state: LauncherAttachState): void {
state.lifecycle = 'terminated';
}

export function resetLauncherAttachState(state: LauncherAttachState): void {
state.context = null;
state.lifecycle = 'idle';
state.recreateInFlight = null;
}

export function recreateIfTerminated(
state: LauncherAttachState,
canAttach: boolean,
attach: AttachLauncher,
): Promise<void> | undefined {
if (state.recreateInFlight) {
return state.recreateInFlight;
}
if (state.lifecycle !== 'terminated' || !state.context || !canAttach) {
return undefined;
}

state.lifecycle = 'recreating';
const context = state.context;
state.recreateInFlight = attach(context).finally(() => {
state.recreateInFlight = null;
});
return state.recreateInFlight;
}
133 changes: 133 additions & 0 deletions test/src/launcherAttachState.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, vi } from 'vitest';
import {
createLauncherAttachState,
rememberAttachContext,
markLauncherAttached,
markLauncherTerminated,
markLauncherAttachFailed,
resetLauncherAttachState,
recreateIfTerminated,
type LauncherAttachContext,
} from '../../src/launcherAttachState';

function context(overrides: Partial<LauncherAttachContext> = {}): LauncherAttachContext {
return {
accountId: 'acct-1',
launcherOptions: { sandbox: true },
legacyRoktExtensions: ['ext-a'],
...overrides,
};
}

describe('launcherAttachState', () => {
it('starts idle with no context', () => {
const state = createLauncherAttachState();

expect(state.lifecycle).toBe('idle');
expect(state.context).toBeNull();
expect(state.recreateInFlight).toBeNull();
});

it('copies attach context so later mutations do not leak', () => {
const state = createLauncherAttachState();
const launcherOptions = { sandbox: true };
const legacyRoktExtensions = ['ext-a'];

rememberAttachContext(state, { accountId: 'acct-1', launcherOptions, legacyRoktExtensions });
launcherOptions.sandbox = false;
legacyRoktExtensions.push('ext-b');

expect(state.context).toEqual({
accountId: 'acct-1',
launcherOptions: { sandbox: true },
legacyRoktExtensions: ['ext-a'],
});
});

it('does not treat idle as terminated', () => {
const state = createLauncherAttachState();

markLauncherTerminated(state);

expect(state.lifecycle).toBe('idle');
expect(recreateIfTerminated(state, true, vi.fn())).toBeUndefined();
});

it('recreates once after terminate and coalesces concurrent callers', async () => {
const state = createLauncherAttachState();
rememberAttachContext(state, context());
markLauncherAttached(state);
markLauncherTerminated(state);

let resolveAttach: () => void = () => undefined;
const attach = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveAttach = resolve;
}),
);

const first = recreateIfTerminated(state, true, attach);
const second = recreateIfTerminated(state, true, attach);

expect(first).toBeInstanceOf(Promise);
expect(second).toBe(first);
expect(attach).toHaveBeenCalledTimes(1);
expect(attach).toHaveBeenCalledWith({
accountId: 'acct-1',
launcherOptions: { sandbox: true },
legacyRoktExtensions: ['ext-a'],
});
expect(state.lifecycle).toBe('recreating');

resolveAttach();
await first;

expect(state.recreateInFlight).toBeNull();
});

it('does not recreate without context or when attach is unavailable', () => {
const attach = vi.fn();
const noContext = createLauncherAttachState();
noContext.lifecycle = 'terminated';

expect(recreateIfTerminated(noContext, true, attach)).toBeUndefined();

const blocked = createLauncherAttachState();
rememberAttachContext(blocked, context());
markLauncherAttached(blocked);
markLauncherTerminated(blocked);

expect(recreateIfTerminated(blocked, false, attach)).toBeUndefined();
expect(attach).not.toHaveBeenCalled();
});

it('can retry after a failed attach', async () => {
const state = createLauncherAttachState();
rememberAttachContext(state, context());
markLauncherAttached(state);
markLauncherTerminated(state);

const firstAttach = vi.fn().mockRejectedValue(new Error('createLauncher failed'));
const failed = recreateIfTerminated(state, true, firstAttach);
await expect(failed).rejects.toThrow('createLauncher failed');
markLauncherAttachFailed(state);

const secondAttach = vi.fn().mockResolvedValue(undefined);
const retried = recreateIfTerminated(state, true, secondAttach);
await retried;

expect(secondAttach).toHaveBeenCalledTimes(1);
});

it('resets to idle', () => {
const state = createLauncherAttachState();
rememberAttachContext(state, context());
markLauncherAttached(state);
markLauncherTerminated(state);

resetLauncherAttachState(state);

expect(state).toEqual(createLauncherAttachState());
});
});
Loading
Loading