import { moduleRuntime, resolveJourneyEngine } from './runtime'; import { adoptJourneyStepStamp, JourneyStepStamp } from './step-tag'; import type { JourneyDef, JourneyHandle, JourneyStepOptions, JourneyStepTarget, SerializedJourneyState, StepHandle, StepRecord, TagValue, } from './types'; let ambientStamp: JourneyStepStamp | null = null; let ambientStep: StepRecord | null = null; /** * Unique in-flight step as a stamp-ready {@link JourneyStepStamp}, or null when * auto-attribute is disabled, or when 0 or 2+ steps are in flight app-wide. * Ambient stamps never carry `score` or `ignore`. Untagged traffic is attributed * to that lone step even if unrelated — prefer `step.stamp()` when the page has * background instrumented requests. Disable via * `configureJourney({ autoAttributeRequests: false })`. */ export function singleInFlightJourneyStep(): JourneyStepStamp | null { if (ambientStep?.journey.closed) { ambientStep = null; ambientStamp = null; } const activeStep = moduleRuntime.activeJourneyStep(); if (!activeStep) { ambientStamp = null; ambientStep = null; return null; } if (ambientStep === activeStep && ambientStamp) { return ambientStamp; } ambientStep = activeStep; ambientStamp = new JourneyStepStamp(activeStep); return ambientStamp; } /** @internal Clear cached ambient stamp when runtime state changes. */ export function invalidateAmbientStamp(step?: StepRecord): void { if (!step || step === ambientStep) { ambientStamp = null; ambientStep = null; } } /** * Prefer an explicit `step.stamp()` value; else the ambient in-flight stamp. * Same-realm stamps are returned as-is (identity + score + ignore). Branded * foreign copies are adopted into a local stamp. Raw StepRecords are not stamps. */ export function resolveStamp(explicit?: unknown): JourneyStepStamp | undefined { const fromExplicit = adoptJourneyStepStamp(explicit); if (fromExplicit) { return fromExplicit; } return singleInFlightJourneyStep() ?? undefined; } /** * Record a finished backend request against a captured step. * 5xx / network / slow (over threshold) mark Bad; 4xx is not counted. Fail-fast on breach. * @internal Prefer {@link onHttpComplete} from adapters. */ export function reportBackendRequest( step: StepRecord | undefined, status: number | undefined, durationMs: number, requestKey: string ): void { if (!step?.journey) { return; } resolveJourneyEngine(step.journey).reportBackendRequest(step, status, durationMs, requestKey); } /** * Run one step of a named journey. Runs untraced (noop handle) when no matching journey is open. * `journey` may be a name, null, an ordered candidate list, a def, or a handle. * A def or handle auto-starts when that journey is not already open; a bare name does not. * Prefer `defineJourney(...).step` when you own the journey config. */ export async function journeyStep( journey: JourneyStepTarget, stepName: string, fn: (step: StepHandle) => T | Promise, opts?: JourneyStepOptions ): Promise { return moduleRuntime.journeyStep(journey, stepName, fn, opts); } /** Merge custom tags onto an open journey. No-op if not open. Prefer `handle.setTags`. */ export function setJourneyTags(name: string | null, tags: Record): void { moduleRuntime.setJourneyTags(name, tags); } /** Mark a named journey Bad for a custom reason (fail-fast). Prefer `handle.fail`. */ export function failJourney(name: string, reason: string): void { const journey = moduleRuntime.getOpenJourney(name); if (journey) { moduleRuntime.failJourney(journey, reason); } } /** Successful end. Outcome stays Bad if a breach already occurred. No-op when name is null. Prefer `handle.complete`. */ export function completeJourney(name: string | null, tags?: Record): void { moduleRuntime.completeJourney(name, tags); } /** User left before finishing. Stays Bad if a breach already occurred (breach-anchored). Prefer `handle.exclude`. */ export function excludeJourney(name: string): void { const journey = moduleRuntime.getOpenJourney(name); if (journey) { moduleRuntime.excludeJourney(journey); } } /** Turn a journey config into a handle with bound lifecycle methods. Preferred public API. */ export function defineJourney(config: JourneyDef): JourneyHandle { return { config, start(tags) { moduleRuntime.startJourney(config); if (tags) { moduleRuntime.setJourneyTags(config.name, tags); } }, step(stepName, fn, opts) { if (!moduleRuntime.getOpenJourney(config.name)) { moduleRuntime.startJourney(config); } return journeyStep(config.name, stepName, fn, opts); }, async mountStep(stepName, fn, opts) { // Defer so a parent opens first (child effects run before parent). await Promise.resolve(); if (!moduleRuntime.getOpenJourney(config.name)) { moduleRuntime.startJourney(config); } return journeyStep(config.name, stepName, fn, opts); }, complete(tags) { completeJourney(config.name, tags); }, fail(reason) { failJourney(config.name, reason); }, exclude() { excludeJourney(config.name); }, setTags(tags) { setJourneyTags(config.name, tags); }, }; } /** * Snapshot an open journey for cross-page transfer (e.g. into a cookie). Does * NOT close the journey. Returns `null` when no journey is open under `name`. */ export function serializeJourneyState(name: string): SerializedJourneyState | null { return moduleRuntime.serializeJourneyState(name); } /** * Rehydrate a serialized journey onto this runtime. Returns `true` when the * journey is now open; `false` when it exhausted its budget in transit (a * `bad` `journey-timeout` event is emitted) or when a journey under the * same name is already open. */ export function restoreJourney(serialized: SerializedJourneyState, config: JourneyDef): boolean { return moduleRuntime.restoreJourney(serialized, config) !== null; }