import type { CandidateIdentity, EventKind, IngestBatchRequest, MainToWorkerMessage, PartialEvent, ProctoringConfig, SessionEvent, WorkerToMainMessage, } from "@a4anthony/proctorkit-types"; import { DeliveryReadinessError } from "./delivery-readiness.js"; import { verifyRecordingDelivery } from "./recording-delivery-readiness.js"; import { ChunkUploader, type ChunkDropDetail, type ChunkDropReason, } from "./chunk-uploader/chunk-uploader.js"; import { MultipartUploader, type MultipartDropDetail, type MultipartDropReason, } from "./chunk-uploader/multipart-uploader.js"; import { SegmentUploader } from "./chunk-uploader/segment-uploader.js"; import { DurableSegmentStore } from "./chunk-uploader/durable-segment-store.js"; import { DurableBlobStore, recordingDbName, sweepOrphanRecordingStores, } from "./chunk-uploader/durable-blob-store.js"; import { DomObservers, type DomObserversConfig } from "./observers/dom-observers.js"; import { requestScreenShare, ScreenShareDeclinedError, ScreenShareObserver, type ScreenShareErrorKind, type ScreenShareObserverConfig, } from "./observers/screen-share-observer.js"; import { WebcamObserver, WebcamUnavailableError, type WebcamErrorKind, type WebcamObserverConfig, } from "./observers/webcam-observer.js"; import { wrapWebcamCfgWithUploader } from "./observers/webcam-upload-config.js"; import { VideoClipRecorder, type AudioClipVolumeAnalysis, type VideoClipHandle, } from "./video-clips/video-clip-recorder.js"; import type { ClipDropCode } from "./video-clips/clip-error-codes.js"; import { createTextAnswerRecorder, type TextAnswerHandle, } from "./text-answer/text-answer-recorder.js"; import { AudioFilePlayer, type AudioFilePlaybackHandle, type AudioFilePlaybackOptions, } from "./audio-playback/audio-file-player.js"; import { VideoFilePlayer, type VideoFilePlaybackHandle, type VideoFilePlaybackOptions, } from "./video-playback/video-file-player.js"; import type { MediaFilePlaybackHandle } from "./media-playback/media-file-player.js"; import { uploadVideoClip as uploadVideoClipImpl } from "./video-clips/upload-clip.js"; import { computeDeviceFingerprint, type FingerprintResult } from "./fingerprint/index.js"; import { newId } from "./internal/ids.js"; import { enumerateMediaDevices, type MediaDeviceInfoLite } from "./media-devices.js"; import { captureBaseline, createBrowserProbes, pickLiveTrack, queryPermission, runCheckpoint, type CheckpointBaseline, type CheckpointConfig, type CheckpointDeviceKind, type CheckpointOptions, type CheckpointResult, } from "./checkpoint/index.js"; import { SDK_BUILD_INFO } from "./build-info.js"; import { EventEvidenceCapture } from "./event-evidence/event-evidence-capture.js"; import { EventEvidenceUploader } from "./event-evidence/event-evidence-uploader.js"; import { systemCaptureClock } from "./internal/capture-clock.js"; import type { PhotoCaptureTiming, RecordingChunkTiming } from "./observers/media-capture-timing.js"; import { AssessmentReplayController } from "./assessment-replay/assessment-replay-controller.js"; export { AssessmentReplayController, type AssessmentReplayControllerOptions, type AssessmentReplayEndReason, } from "./assessment-replay/assessment-replay-controller.js"; export type { AssessmentReplayPhase } from "./assessment-replay/assessment-replay-recorder.js"; export type { CandidateIdentity, DeliveryReadinessErrorCode, EventKind, ProctoringConfig, SessionEvent, WorkerToMainMessage, } from "@a4anthony/proctorkit-types"; export { DeliveryReadinessError } from "./delivery-readiness.js"; export { measureRecordingDeliveryReadiness } from "./recording-delivery-readiness.js"; export type { RecordingDeliveryMeasurement, RecordingDeliveryMeasurementConfig, RecordingReadinessConfig, } from "./recording-delivery-readiness.js"; export type { DomObserversConfig } from "./observers/dom-observers.js"; export type { RequestScreenShareOptions, ScreenShareErrorKind, ScreenShareObserverConfig, } from "./observers/screen-share-observer.js"; export type { WebcamObserverConfig, WebcamErrorKind } from "./observers/webcam-observer.js"; export { requestScreenShare, ScreenShareDeclinedError } from "./observers/screen-share-observer.js"; export { WebcamUnavailableError } from "./observers/webcam-observer.js"; export { enumerateMediaDevices } from "./media-devices.js"; export type { MediaDeviceInfoLite, MediaDeviceList, EnumerateOptions } from "./media-devices.js"; export type { CheckpointResult, CheckpointChange, CheckpointChangeKind, CheckpointDeviceKind, CheckpointOptions, CheckpointConfig, CheckpointTrigger, } from "./checkpoint/index.js"; export { isDevicePresentLoose } from "./checkpoint/index.js"; export type { DeviceRef, EnumeratedDevice } from "./checkpoint/index.js"; // The shared verdict authority — consumed by the resume probe in // packages/vue as well as the checkpoint below. export { deviceVerdict, displayVerdict, fullscreenVerdict, screenShareVerdict, connectionVerdict, } from "./verification-core/index.js"; export type { DeviceFacts, DeviceVerdict, PermissionFact, PresenceFact, TrackFact, DisplayFacts, DisplayVerdict, DisplayFact, FullscreenFacts, FullscreenVerdict, ScreenShareVerdict, ConnectionFacts, ConnectionVerdict, } from "./verification-core/index.js"; export { computeDeviceFingerprint, hashFingerprint, getTabLockToken } from "./fingerprint/index.js"; export type { DeviceFingerprint, FingerprintResult } from "./fingerprint/index.js"; export type { AudioFilePlaybackHandle, AudioFilePlaybackOptions, } from "./audio-playback/audio-file-player.js"; export type { VideoFilePlaybackHandle, VideoFilePlaybackOptions, } from "./video-playback/video-file-player.js"; export type { MediaFilePlaybackHandle, MediaFilePlaybackOptions, MediaPlaybackKindPrefix, } from "./media-playback/media-file-player.js"; export type { AudioClipVolumeAnalysis, AudioVolumeClassification, } from "./video-clips/video-clip-recorder.js"; const HEARTBEAT_INTERVAL_MS = 30_000; const SESSION_END_DRAIN_DEADLINE_MS = 30_000; /** * Debounce for the checkpoint `devicechange` trigger. Browsers fire * `devicechange` in bursts — often twice for a single plug/unplug, and * repeatedly while a Bluetooth device negotiates — so we coalesce a burst * into one checkpoint run. Long enough to swallow the burst, short enough * that the recovery gate still feels immediate. */ const CHECKPOINT_DEVICE_CHANGE_DEBOUNCE_MS = 400; export type SdkTelemetryEventKind = | "sdk.initialized" | "sdk.ready" | "sdk.error" | "sdk.upload.failed" | "sdk.media.failed" | "sdk.permission.denied" | "sdk.worker.failed" | "sdk.storage.fallback" | "sdk.recording.buffer-degraded" | "sdk.recording.buffer-evicted" | "sdk.stop.drain-timeout"; export type SdkTelemetryCode = | "sdk.initialized" | "sdk.ready" | "sdk.init.failed" | "sdk.worker.init_failed" | "sdk.worker.ready_timeout" | "sdk.storage.memory_fallback" | "sdk.worker.message_upload_failed" | "runtime.screen_share.permission_denied" | "runtime.screen_share.activation_required" | "runtime.screen_share.unsupported" | "runtime.screen_share.track_ended" | "runtime.screen_share.wrong_surface" | "runtime.webcam.permission_denied" | "runtime.webcam.no_device" | "runtime.webcam.in_use" | "runtime.webcam.unavailable" | "runtime.media_recorder.unavailable" | "runtime.video_clip.permission_denied" | "runtime.video_clip.media_failed" | "runtime.audio_clip.permission_denied" | "runtime.audio_clip.media_failed" | "runtime.assessment_replay.unavailable" | "upload.events.failed" | "upload.media.chunk_dropped" | "upload.media.request_timeout" | "upload.media.complete_deferred" | "upload.media.clip_dropped" | "runtime.event_evidence.capture_failed" | "upload.event_evidence.failed" | "recording.durable-buffer.degraded" | "recording.durable-buffer.evicted" | "session.stop.drain_timeout"; export interface SdkTelemetryOptions { /** * Structured SDK health events are enabled by default. Set false * for customers that need to suppress all SDK diagnostics. */ enabled?: boolean; /** * Off by default. When true, the SDK may include a truncated stack * trace in sdk.* payloads. Keep false in production candidate flows * unless the customer explicitly opts into debug diagnostics. */ debug?: boolean; /** * @deprecated Ignored. Full page URLs are never collected because they can * contain credentials, candidate identifiers, and customer routing data. * Retained temporarily so existing integrations continue to typecheck. */ includePageUrl?: boolean; } interface ResolvedSdkTelemetryOptions { enabled: boolean; debug: boolean; } interface TelemetryInput { code: SdkTelemetryCode; phase: string; recoverable: boolean; message?: string; error?: unknown; details?: Record; } type SessionEndReason = "stopped" | "abandoned"; type SessionEndDrainStatus = "complete" | "timeout"; interface DrainUploaderResult { stream: "screen-share" | "webcam-photo" | "webcam-recording"; status: SessionEndDrainStatus; } /** * The structural surface a continuous-recording uploader must expose, so the * observer wiring + teardown drain are agnostic to which one is in use: * {@link ChunkUploader} (mode "post") or {@link MultipartUploader} (mode * "direct", S3). Both implement these three methods with * compatible signatures. */ interface RecordingUploader { upload(seq: number, blob: Blob, timingHeaders?: Record): void; drain(timeoutMs?: number): Promise; stop(): void; } /** * Options for constructing a {@link ProctoringClient}. * * The constructor auto-starts. There is no `start()` to await — the * client begins running on the same tick the constructor returns and * continues until `endSignal.abort()` is called. Page unloads only * flush pending events; the backend infers abandonment from missing * heartbeat/activity. * * Provide exactly one of `workerUrl` or `workerFactory`. In production * the `workerUrl` form is the only sane choice; `workerFactory` exists * so tests can drive the worker in-process. */ export interface ProctoringClientOptions extends ProctoringConfig { /** * Always-on DOM replay is enabled by default. Set this to `false` only when * a wrapper owns an AssessmentReplayController spanning preflight and runtime. */ assessmentReplay?: boolean; /** * URL of the bundled worker entry. Resolve at the host app build time * (eg. `new URL("@a4anthony/proctorkit-sdk/worker", import.meta.url)`) * so the bundler emits the worker as a separate asset. */ workerUrl?: string | URL; /** * Test seam: construct the worker yourself. Mutually exclusive with * `workerUrl`. */ workerFactory?: () => WorkerLike; /** * Identity of the candidate this session belongs to. Equivalent to * LogRocket's `identify()` but passed at construction so the very * first batch carries the right identity. Without this, the server * records anonymous events. */ candidate?: CandidateIdentity; /** * Complete resolved assessment policy applied to this SDK instance. Emitted * once as `session.policy` so post-session workers can enforce analysis * opt-outs even when the host uses runtime-only mode without preflight. */ policySnapshot?: Record; /** * AbortSignal that ends the session when aborted. The customer * controls the lifecycle externally — typically via an AbortController * stored alongside other "this exam is in progress" state. * * If omitted, there is no programmatic clean end. `pagehide` only * flushes queued events; abandonment is inferred by the backend from * missing heartbeat/activity after the stale timeout. */ endSignal?: AbortSignal; /** * Called when screen sharing cannot start. Distinguishes missing user * activation, denied permission, unsupported browsers, wrong-surface * selection, and an already-ended track. The session does not start. * * Without this callback, the failure is logged and the session * silently doesn't start. Set this to drive your retry UI. */ onScreenShareError?: (kind: ScreenShareErrorKind) => void; /** * Called when the webcam can't be acquired: candidate denied * permission (`"declined"`), no camera attached (`"device-not-found"`), * camera in use by another app (`"in-use"`), or otherwise unavailable. * The session does not start when this fires. * * Without this callback the failure is logged and the session * silently doesn't start. Set this to drive your retry UI. */ onWebcamError?: (kind: WebcamErrorKind) => void; /** * Called when init fails for reasons other than screen-share or * webcam: worker bundle fails to load, IndexedDB is denied (some * private-browsing modes), ready signal times out, etc. * * Without this callback, the failure surfaces as an unhandled promise * rejection in the browser console. */ onError?: (err: Error) => void; /** * Worker→main message stream. Use for upload health observability * (`queued`, `uploaded`, `upload-failed`, `dropped`, `init-failed`). */ onEvent?: (msg: WorkerToMainMessage) => void; /** * Main-thread session event stream before events are handed to the * worker queue. Use this for immediate UI reactions such as locking * an assessment when `screen-share.stopped` fires. */ onSessionEvent?: (event: PartialEvent) => void; /** * Privacy-safe SDK health telemetry. Enabled by default and emitted * as bounded `sdk.*` events through the normal ingest pipeline. It * never includes DOM, cookies, localStorage, media, screenshots, or * candidate page text. */ telemetry?: boolean | SdkTelemetryOptions; /** * Configure which built-in DOM/media observers run. The default DOM * baseline enables focus, visibility, fullscreen, network, pointer and * clipboard metadata; keyboard, screenshot, screen share, webcam and media * capture require explicit configuration. Pass `false` to disable all * observers, or a config object to override individual observers. */ observers?: DomObserversConfig | false; /** * Emit periodic `session.heartbeat` activity events. Default: true. * Set false only when the host's backend-owned policy explicitly * disables activity pings. */ heartbeat?: boolean; /** * Preferred audio output device id for URL-backed media prompts. * `playAudioFile()` and `playVideoFile()` pass this through to * HTMLMediaElement.setSinkId() when the browser supports output * routing. Per-call `sinkId` wins. */ audioOutputDeviceId?: string; /** * Preferred camera device id for runtime video capture. The webcam * observer takes its own `deviceId`; this covers the standalone * video-clip path (no webcam observer owns the stream) so the * recorded camera matches the candidate's preflight selection rather * than the OS default. */ videoDeviceId?: string; /** * Preferred microphone device id for runtime audio capture (video + * audio clips). When set, the SDK acquires this exact input instead * of the OS default, so recordings match the candidate's preflight * selection. */ audioInputDeviceId?: string; /** * Runtime integrity checkpoint. Re-verifies the live environment against * the state that passed preflight (permission status, selected-device * availability, external monitor, and — with `liveness` — camera/mic * capture liveness) and reports what drifted. * * Omit it and the feature is dormant except that `client.checkpoint()` * still works on demand. Provide `on: ["visibility", "interval"]` to have * the SDK run it on tab-return / a heartbeat and hand each result to * `onCheckpoint` / `onDrift`. It never pauses or fails the session — you * decide what to do with a result. */ checkpoint?: CheckpointConfig; } /** * Minimal Worker contract the SDK depends on. Subset of the DOM * `Worker` interface, so a real `new Worker(...)` satisfies it; tests * provide in-process shims that do too. */ export interface WorkerLike { postMessage(msg: MainToWorkerMessage): void; terminate(): void; addEventListener( type: "message", listener: (event: MessageEvent) => void, ): void; removeEventListener( type: "message", listener: (event: MessageEvent) => void, ): void; } /** * Host-app entry point. Auto-starts on construction: synchronously opens the * screen picker when configured without a supplied stream, spawns the * proctoring Worker, starts DOM observers, and registers a `pagehide` flush so * a closing tab or refresh still asks the worker to drain queued events. * * The customer never calls `.start()` or `.identify()` — both are folded * into the constructor options. Lifecycle ends cleanly via `end()` or * `endSignal.abort()`. Page unloads are not terminal events; stale * sessions are detected by missing heartbeat/activity. Use `.emit()` for * custom event signals. * * Construct inside a user-activation event handler (typically the * "Start session" button click) when `observers.screenShare` is on — * browsers require user activation for `getDisplayMedia`. */ /** One device selected during preflight, resolved to a human label. */ export interface SelectedDevice { /** deviceId, or null when the candidate never overrode the browser default. */ id: string | null; /** * Human label ("FaceTime HD Camera"), or "System default" when `id` is null, * or the raw id as a fallback when the device is no longer enumerable (e.g. * unplugged since preflight). */ label: string; } /** The camera/mic/speaker a session is capturing with. See `getSelectedDevices`. */ export interface SelectedDevices { camera: SelectedDevice; mic: SelectedDevice; speaker: SelectedDevice; } /** A device category that can be changed while a session is running. */ export type RuntimeDeviceKind = "microphone" | "camera" | "speaker"; /** Stable reason codes for candidate-facing device recovery UI. */ export type RuntimeDeviceSwitchErrorCode = | "recording-active" | "switch-in-progress" | "session-unavailable" | "camera-external-stream" | "camera-unavailable" | "camera-rollback-failed"; /** Result of applying a new session device selection. */ export interface RuntimeDeviceSwitchResult { kind: RuntimeDeviceKind; previousDeviceId: string | null; device: SelectedDevice; /** Speakers can be remembered even when the browser cannot enforce routing. */ routing?: "applied" | "remembered"; } /** Typed failure returned by the runtime device-switching API. */ export class RuntimeDeviceSwitchError extends Error { readonly kind: RuntimeDeviceKind; readonly code: RuntimeDeviceSwitchErrorCode; constructor( kind: RuntimeDeviceKind, code: RuntimeDeviceSwitchErrorCode, message: string, options?: { cause?: unknown }, ) { super(message, options); this.name = "RuntimeDeviceSwitchError"; this.kind = kind; this.code = code; } } function normalizeRuntimeDeviceId(deviceId?: string | null): string | undefined { const normalized = deviceId?.trim(); return normalized && normalized !== "default" ? normalized : undefined; } export class ProctoringClient { private worker: WorkerLike | null = null; private readonly config: ProctoringConfig; private readonly workerUrl: string | URL | undefined; private readonly workerFactory: (() => WorkerLike) | undefined; private readonly candidate: CandidateIdentity | undefined; private readonly endSignal: AbortSignal | undefined; private readonly onScreenShareError: ((kind: ScreenShareErrorKind) => void) | undefined; private readonly onWebcamError: ((kind: WebcamErrorKind) => void) | undefined; private readonly onError: ((err: Error) => void) | undefined; private readonly onEvent: ((msg: WorkerToMainMessage) => void) | undefined; private readonly onSessionEvent: ((event: PartialEvent) => void) | undefined; private readonly telemetry: ResolvedSdkTelemetryOptions; private readonly heartbeatEnabled: boolean; private audioOutputDeviceId: string | undefined; private videoDeviceId: string | undefined; private audioInputDeviceId: string | undefined; private readonly telemetryDedupe = new Set(); private readonly observersConfig: DomObserversConfig | false; private observers: DomObservers | null = null; private screenShare: ScreenShareObserver | null = null; private readonly eventEvidenceCapture: EventEvidenceCapture | null; private initialScreenShareGrant: Promise<{ stream: MediaStream } | { error: unknown }> | null = null; private initialScreenShareStream: MediaStream | null = null; private screenShareChunkCount = 0; /** * How recordings upload, decided by the SERVER, not the host. Fetched from * GET /public/upload-config during boot(), before any recording starts. * "direct" = browser → S3 multipart (so the completed object is one valid, * stitchable WebM); "post" = per-chunk relay to the server. Defaults to * "post" until the probe resolves; recordings don't begin until the host * calls startScreenShareRecording(), which is after boot completes, so the * resolved mode is always in place first. */ private recordingUploadMode: "post" | "direct" | "segments" = "post"; /** In-flight (or settled) upload-mode probe; awaited before recording. */ private uploadModeProbe: Promise | null = null; // Recording uploaders are RecordingUploader so either the POST // (ChunkUploader) or direct-to-S3 (MultipartUploader) path can back them. private chunkUploader: RecordingUploader | null = null; private webcam: WebcamObserver | null = null; private webcamPhotoUploader: ChunkUploader | null = null; private webcamRecordingUploader: RecordingUploader | null = null; /** Server-facing identities must survive webcam observer replacement. */ private webcamPhotoCount = 0; private webcamRecordingChunkCount = 0; // Durable buffers backing the direct-to-S3 recording uploaders, so a page // refresh doesn't discard the un-flushed sub-part-size tail. One per kind; // the client owns their lifecycle (close on teardown, orphan sweep on init). private screenDurableStore: DurableBlobStore | null = null; private webcamDurableStore: DurableBlobStore | null = null; private screenSegmentStore: DurableSegmentStore | null = null; private webcamSegmentStore: DurableSegmentStore | null = null; /** Always-on, privacy-minimised assessment DOM replay. Independent of screen share. */ private assessmentReplay: AssessmentReplayController | null = null; private assessmentReplayFailureEmitted = false; private readonly assessmentReplayEnabled: boolean; private assessmentReplayStartTimer: ReturnType | null = null; /** * Sequence allocator for ad-hoc video clips recorded via * `recordVideoClip()`. 1-based, increments per call. */ private videoClipCounter = 0; /** * Sequence allocator for ad-hoc audio clips recorded via * `recordAudioClip()`. Independent of `videoClipCounter` -- * the two streams have separate per-session counters because * they land in separate tables / dashboard tabs. */ private audioClipCounter = 0; /** * Active clip recorders (video + audio). Allows stop() to * flush them all on session end so half-recorded clips still * upload. The recorder's `stop()` is kind-agnostic so both * kinds share the same Set. */ private videoClipRecorders = new Set(); /** * Active URL-backed playback handles (audio + video prompts). * Disposed on stop()/teardown so detached players can't keep * playing after the session ends. */ private mediaFilePlayers = new Set>(); private readonly checkpointConfig: CheckpointConfig | undefined; private readonly policySnapshot: Record | undefined; /** Environment snapshot at session start; the point later checkpoints drift from. */ private checkpointBaseline: CheckpointBaseline | null = null; /** Initial baseline capture, awaited before an intentional device rebase. */ private checkpointBaselineReady: Promise | null = null; /** Prevent overlapping device mutations and let checkpoints wait for rollback. */ private deviceSwitchPromise: Promise | null = null; private checkpointTimer: ReturnType | null = null; private checkpointVisibilityListener: (() => void) | null = null; private checkpointDeviceChangeListener: (() => void) | null = null; /** Debounce timer for the `devicechange` trigger (browsers fire it in bursts). */ private checkpointDeviceChangeDebounce: ReturnType | null = null; /** Stored, uploader-wrapped webcam config so restartWebcam() can re-acquire in place. */ private webcamObserverCfg: WebcamObserverConfig | undefined; private ready = false; private stopped = false; private endRequestedEmitted = false; private endedEmitted = false; private stopPromise: Promise | null = null; private pagehideListener: (() => void) | null = null; private recordingVisibilityListener: (() => void) | null = null; private abortListener: (() => void) | null = null; private heartbeatTimer: ReturnType | null = null; /** * Per-device fingerprint computed once at construction. Same * value flows through every event (via the worker's init * config), every chunk upload (via ChunkUploader config), and * every clip upload (via VideoClipRecorder + uploadClip). The * Vue wizard reads the matching `fingerprintId` from * `computeDeviceFingerprint()` so the server sees the same id * across preflight + session. */ readonly fingerprint: FingerprintResult; constructor(options: ProctoringClientOptions) { const { workerUrl, workerFactory, candidate, endSignal, onScreenShareError, onWebcamError, onError, onEvent, onSessionEvent, telemetry, observers, heartbeat, audioOutputDeviceId, videoDeviceId, audioInputDeviceId, checkpoint, policySnapshot, assessmentReplay, ...config } = options; // Compute the fingerprint synchronously. If the caller passed // an explicit fingerprintId on options (eg. the Vue wizard // hands the same value across the preflight/session boundary) // we honour theirs; otherwise we derive one. Either way the // resolved id lives on this.config so the worker init message // carries it. this.fingerprint = computeDeviceFingerprint(); if (!config.fingerprintId) { config.fingerprintId = this.fingerprint.fingerprintId; } this.config = config; this.workerUrl = workerUrl; this.workerFactory = workerFactory; this.candidate = candidate; this.endSignal = endSignal; this.onScreenShareError = onScreenShareError; this.onWebcamError = onWebcamError; this.onError = onError; this.onEvent = onEvent; this.onSessionEvent = onSessionEvent; this.telemetry = resolveTelemetryOptions(telemetry); this.heartbeatEnabled = heartbeat !== false; this.audioOutputDeviceId = audioOutputDeviceId || undefined; this.videoDeviceId = videoDeviceId || undefined; this.audioInputDeviceId = audioInputDeviceId || undefined; this.checkpointConfig = checkpoint; this.policySnapshot = policySnapshot; this.assessmentReplayEnabled = assessmentReplay !== false; this.observersConfig = observers ?? {}; if ( this.observersConfig !== false && this.observersConfig.screenshot !== undefined && this.observersConfig.screenshot !== false ) { const evidenceUploader = new EventEvidenceUploader({ sessionId: this.config.sessionId, ingestUrl: this.config.ingestUrl, appId: this.config.appId, fingerprintId: this.config.fingerprintId, }); this.eventEvidenceCapture = new EventEvidenceCapture({ captureScreen: () => this.screenShare?.captureEvidenceFrame() ?? Promise.resolve(null), uploadAvailable: (input) => evidenceUploader.uploadAvailable(input), uploadUnavailable: (input) => evidenceUploader.uploadUnavailable(input), onFailure: (failure) => { this.emitTelemetry("sdk.upload.failed", { code: "upload.event_evidence.failed", phase: `event-evidence.${failure.stage}`, recoverable: true, error: failure.error, details: { evidenceEventId: failure.eventId, evidenceSource: failure.source, failureCode: failure.code, ...failure.details, }, }); }, }); } else { this.eventEvidenceCapture = null; } // `getDisplayMedia()` must be invoked before boot crosses the worker-ready // task boundary. Calling the request here preserves the candidate's click; // boot can safely await the already-open picker later. this.initialScreenShareGrant = this.requestInitialScreenShare(); void this.boot(); if (endSignal) { if (endSignal.aborted) { // Already aborted before we got here — schedule teardown on // microtask so we don't tear down mid-boot. queueMicrotask(() => void this.end()); } else { this.abortListener = () => { void this.end(); }; endSignal.addEventListener("abort", this.abortListener, { once: true }); } } } /** * Microphone constraint for runtime clip capture — the candidate's * selected input when one was chosen in preflight, else the default. */ private audioInputConstraint(): MediaTrackConstraints | true { return this.audioInputDeviceId ? { deviceId: { exact: this.audioInputDeviceId } } : true; } /** * Camera constraint for the standalone video-clip path (Path 3 — when * no webcam observer owns the stream). The observer applies its own * deviceId; this keeps the no-observer path on the same camera. */ private videoInputConstraint(): MediaTrackConstraints | true { return this.videoDeviceId ? { deviceId: { exact: this.videoDeviceId } } : true; } /** * Cleanly end the proctoring session. * * This stops observers, drains queued media chunks, emits the terminal * `session.ended` event, flushes the worker queue, exits fullscreen, and * then terminates the worker. It is safe to call more than once; concurrent * callers receive the same in-flight promise. */ end(): Promise { this.stopPromise ??= this.stop(); return this.stopPromise; } /** * Fire-and-forget. The event is structured-cloned into the worker * and persisted to IndexedDB before being uploaded. Throws if the * client isn't ready yet — rare, only happens if `emit()` is called * synchronously in the same tick as construction. * * Use this for custom signals from your exam app (eg. * `client.emit("exam.submitted", { score: 87 })`). Observer-captured * events flow through the same pipeline automatically. */ emit(kind: EventKind, payload?: SessionEvent["payload"]): void { this.emitCaptured(kind, payload); } private emitCaptured( kind: EventKind, payload?: SessionEvent["payload"], timestamp = systemCaptureClock.now(), ): void { if (!this.worker || !this.ready) { throw new Error( "ProctoringClient: emit() called before the client was ready. " + "Wait for the worker to send a `ready` message via onEvent, " + "or call emit() in response to a user interaction (by which point boot is done).", ); } const event: PartialEvent = { id: newId("evt"), kind, timestamp, ...(payload !== undefined ? { payload } : {}), }; this.notifySessionEvent(event); this.post({ type: "emit", event, }); this.eventEvidenceCapture?.capture({ id: event.id!, kind, timestamp, }); } /** * The camera, mic, and speaker this session is capturing with — the * candidate's preflight picks resolved to human labels. Async because it * enumerates the browser's device list to map each id → label; safe to call * anytime after construction. * * A kind whose `id` is null ("System default") means the candidate never * explicitly overrode the browser default — we can't resolve the default * device's id without opening a stream, so it's reported as such. A pick * whose device is no longer enumerable falls back to its raw id as the label. * * No probe stream is opened: a live session already holds device permission, * so labels are populated. Speakers depend on `enumerateDevices` exposing * `audiooutput` (Safari does not), so `speaker` may be System default there. */ async getSelectedDevices(): Promise { const { cameras, mics, speakers } = await enumerateMediaDevices({ probe: false }); const resolve = (id: string | undefined, list: MediaDeviceInfoLite[]): SelectedDevice => id ? { id, label: list.find((d) => d.deviceId === id)?.label || id } : { id: null, label: "System default" }; return { camera: resolve(this.videoDeviceId, cameras), mic: resolve(this.audioInputDeviceId, mics), speaker: resolve(this.audioOutputDeviceId, speakers), }; } /** Change the microphone used by future clip recordings. */ switchMicrophone(deviceId?: string | null): Promise { return this.runDeviceSwitch("microphone", async () => { this.assertInputSwitchAvailable("microphone"); const previousDeviceId = this.audioInputDeviceId ?? null; this.audioInputDeviceId = normalizeRuntimeDeviceId(deviceId); await this.rebaseCheckpointDevice("microphone"); return { kind: "microphone", previousDeviceId, device: await this.resolveSelectedDevice("microphone"), }; }); } /** * Change the output used by active and future SDK-owned media playback. * Browsers without setSinkId remember the choice but keep routing under OS control. */ switchSpeaker(deviceId?: string | null): Promise { return this.runDeviceSwitch("speaker", async () => { const previousDeviceId = this.audioOutputDeviceId ?? null; const nextDeviceId = normalizeRuntimeDeviceId(deviceId); this.audioOutputDeviceId = nextDeviceId; const routing = typeof HTMLMediaElement !== "undefined" && typeof ( HTMLMediaElement.prototype as HTMLMediaElement & { setSinkId?: (id: string) => Promise; } ).setSinkId === "function" ? "applied" : "remembered"; try { await Promise.all( [...this.mediaFilePlayers].map((player) => player.setSinkId(nextDeviceId)), ); } catch (cause) { this.audioOutputDeviceId = previousDeviceId ?? undefined; await Promise.allSettled( [...this.mediaFilePlayers].map((player) => player.setSinkId(previousDeviceId)), ); throw cause; } await this.rebaseCheckpointDevice("speaker"); return { kind: "speaker", previousDeviceId, device: await this.resolveSelectedDevice("speaker"), routing, }; }); } /** Change the camera used by webcam capture and future standalone clips. */ switchCamera(deviceId?: string | null): Promise { return this.runDeviceSwitch("camera", async () => { this.assertInputSwitchAvailable("camera"); const previousDeviceId = this.videoDeviceId ?? null; const nextDeviceId = normalizeRuntimeDeviceId(deviceId); const previousCfg = this.webcamObserverCfg; if (previousCfg?.stream) { throw new RuntimeDeviceSwitchError( "camera", "camera-external-stream", "The webcam stream is owned by the host application and cannot be switched by ProctorKit.", ); } if (!previousCfg || !this.webcam) { this.videoDeviceId = nextDeviceId; if (previousCfg) { this.webcamObserverCfg = { ...previousCfg, deviceId: nextDeviceId }; } await this.rebaseCheckpointDevice("camera"); return { kind: "camera", previousDeviceId, device: await this.resolveSelectedDevice("camera"), }; } await this.webcam.stop("manual"); this.webcam = null; this.videoDeviceId = nextDeviceId; this.webcamObserverCfg = { ...previousCfg, deviceId: nextDeviceId }; try { const nextObserver = new WebcamObserver(this.webcamEmitter(), this.webcamObserverCfg); await nextObserver.start(); this.webcam = nextObserver; } catch (cause) { this.videoDeviceId = previousDeviceId ?? undefined; this.webcamObserverCfg = previousCfg; try { const restoredObserver = new WebcamObserver(this.webcamEmitter(), previousCfg); await restoredObserver.start(); this.webcam = restoredObserver; } catch (rollbackCause) { this.webcam = null; throw new RuntimeDeviceSwitchError( "camera", "camera-rollback-failed", "The selected camera could not be started and the previous camera could not be restored.", { cause: rollbackCause }, ); } throw new RuntimeDeviceSwitchError( "camera", "camera-unavailable", "The selected camera could not be started. The previous camera is still in use.", { cause }, ); } await this.rebaseCheckpointDevice("camera"); return { kind: "camera", previousDeviceId, device: await this.resolveSelectedDevice("camera"), }; }); } private runDeviceSwitch(kind: RuntimeDeviceKind, task: () => Promise): Promise { if (this.stopped || !this.worker || !this.ready) { return Promise.reject( new RuntimeDeviceSwitchError( kind, "session-unavailable", "Devices can only be changed during an active proctoring session.", ), ); } if (this.deviceSwitchPromise) { return Promise.reject( new RuntimeDeviceSwitchError( kind, "switch-in-progress", "Another device change is still in progress.", ), ); } const operation = task(); this.deviceSwitchPromise = operation; void operation .finally(() => { if (this.deviceSwitchPromise === operation) this.deviceSwitchPromise = null; }) .catch(() => undefined); return operation; } private assertInputSwitchAvailable(kind: "microphone" | "camera"): void { if (this.videoClipRecorders.size === 0) return; throw new RuntimeDeviceSwitchError( kind, "recording-active", `Finish the current recording before changing the ${kind}.`, ); } private async resolveSelectedDevice(kind: RuntimeDeviceKind): Promise { const devices = await this.getSelectedDevices().catch(() => null); const id = kind === "camera" ? this.videoDeviceId : kind === "microphone" ? this.audioInputDeviceId : this.audioOutputDeviceId; if (!devices) return id ? { id, label: id } : { id: null, label: "System default" }; return kind === "camera" ? devices.camera : kind === "microphone" ? devices.mic : devices.speaker; } /** Accept only the intentionally changed device without masking other drift. */ private async rebaseCheckpointDevice(kind: CheckpointDeviceKind): Promise { await this.checkpointBaselineReady; if (!this.checkpointBaseline) return; const device = await this.resolveSelectedDevice(kind); if (device.id) { this.checkpointBaseline.devices[kind] = { id: device.id, ...(device.label ? { label: device.label } : {}), }; } else { delete this.checkpointBaseline.devices[kind]; } } /** * Runtime integrity checkpoint. Re-verifies the live environment against * the state that passed preflight and returns what has drifted — permission * revoked, selected device gone, external monitor connected, and (unless * `options.liveness === false`) camera/mic capture liveness. Read-only and * idempotent: it never prompts, opens a device, or changes session state, * and it never pauses the session — the returned {@link CheckpointResult} * is yours to act on. * * Reads the streams the session already holds (no second getUserMedia, no * second camera light). Every run also emits one `session.checkpoint` event * as an audit record. */ async checkpoint(options?: CheckpointOptions): Promise { await this.deviceSwitchPromise?.catch(() => undefined); const baseline = (this.checkpointBaseline ??= await this.captureCheckpointBaseline()); const liveness = options?.liveness ?? this.checkpointConfig?.liveness ?? true; // Optional active mic-liveness probe: only when liveness is on, no live // audio track already exists (piggyback preferred), the CURRENT mic // permission is granted (never prompts), and the host opted in. Do not use // the granted baseline here: the candidate may have reset site permission // since preflight, in which case getUserMedia would open native browser UI // before the recovery gate can render. Released after the run. let probeStream: MediaStream | null = null; if ( liveness && this.checkpointConfig?.micProbe === true && baseline.permissions.microphone === "granted" && !this.liveCaptureTrack("audio") && (await queryPermission("microphone")) === "granted" ) { probeStream = await navigator.mediaDevices?.getUserMedia({ audio: true }).catch(() => null); } const probes = createBrowserProbes({ // Camera: prefer the webcam observer's track (report its state, incl. // "ended"); else piggyback a live video-clip track. getVideoTrack: () => this.webcam?.getStream()?.getVideoTracks()[0] ?? this.liveCaptureTrack("video"), // Mic: no continuous source (webcam is video-only) — read a live clip // audio track, or the probe track if one was acquired. getAudioTrack: () => this.liveCaptureTrack("audio") ?? probeStream?.getAudioTracks()[0] ?? null, }); try { const result = await runCheckpoint(baseline, probes, { liveness }); this.emitCheckpoint(result); // Opt-in gating: a manual run only ACTS when it FAILS, and only via // `onDrift` — never `onCheckpoint` (the scheduled auto-recovery hook, // which would clear a hold on a passing run). Lets a host-fired // checkpoint raise the integrity hold on drift. if (options?.notifyDrift && !result.ok) { this.checkpointConfig?.onDrift?.(result); } return result; } finally { probeStream?.getTracks().forEach((t) => t.stop()); } } /** * A live capture track the session already holds — from the webcam observer * (video) or an in-flight clip recording (audio/video). Lets mic/camera * liveness run without opening a new device. Null when nothing is capturing. */ private liveCaptureTrack(kind: "audio" | "video"): MediaStreamTrack | null { const streams: Array = [this.webcam?.getStream() ?? null]; for (const recorder of this.videoClipRecorders) { streams.push(recorder.activeStream()); } return pickLiveTrack(kind, streams); } /** * Snapshot the environment as it is now — the point every later checkpoint * drifts from. Only devices the candidate explicitly selected in preflight * (a non-default id) are compared; a "System default" pick has no id to * match, so it's not tracked for availability. */ private async captureCheckpointBaseline(): Promise { const selected: Partial> = {}; const labels = await this.getSelectedDevices().catch(() => null); const add = (kind: CheckpointDeviceKind, id: string | undefined, label?: string): void => { if (id) selected[kind] = { id, ...(label ? { label } : {}) }; }; // Camera capture means one thing: a configured webcam observer. A bare // `videoDeviceId` does NOT — device picks are persisted per-origin, so an // earlier camera-using assessment leaves an id behind that has nothing to // do with this session. (The standalone video-clip path reads the id too, // but a policy that records clips enables the webcam observer anyway, so // there is no real "id without observer" camera use.) Treating the id as // intent leaked camera drift into camera-less sessions. const usesCamera = this.observersConfig !== false && Boolean(this.observersConfig.webcam); // A mic is used for capture; a speaker needs a mic GRANT to enumerate and // route outputs, so a lost mic permission breaks the speaker too. const usesMic = Boolean(this.audioInputDeviceId) || Boolean(this.audioOutputDeviceId); add("microphone", this.audioInputDeviceId, labels?.mic.label ?? undefined); // Gate device-availability on the same rule as permission tracking — // otherwise a stale id would still be baselined and flagged "unplugged". if (usesCamera) add("camera", this.videoDeviceId, labels?.camera.label ?? undefined); add("speaker", this.audioOutputDeviceId, labels?.speaker.label ?? undefined); return captureBaseline(selected, { microphone: usesMic, camera: usesCamera }); } /** Emit the audit record for a checkpoint run (best-effort; never throws). */ private emitCheckpoint(result: CheckpointResult): void { if (!this.worker || !this.ready || this.stopped) return; const event: PartialEvent = { kind: "session.checkpoint", timestamp: systemCaptureClock.now(), payload: { ok: result.ok, changes: result.changes }, }; this.notifySessionEvent(event); this.post({ type: "emit", event }); } /** * Start the SDK-owned checkpoint schedule (tab-return / heartbeat), if the * host configured `checkpoint.on`. No triggers → purely manual. Scheduled * runs hand each result to `onCheckpoint`, and drifting runs also to * `onDrift`. Wired at the end of boot; torn down in {@link stopCheckpoint}. */ private startCheckpoint(): void { // Always baseline at session start — so a later checkpoint(), manual or // scheduled, compares against the environment as it passed preflight, not // against whenever the first call happens to fire. `checkpoint()` still // has a lazy fallback if it's called before this resolves. this.checkpointBaselineReady = this.captureCheckpointBaseline() .then((b) => { this.checkpointBaseline ??= b; }) .catch(() => undefined); const cfg = this.checkpointConfig; if (!cfg?.on?.length) return; // no scheduled triggers → manual only const run = (): void => void this.runScheduledCheckpoint(); if (cfg.on.includes("visibility") && typeof document !== "undefined") { this.checkpointVisibilityListener = () => { if (document.visibilityState === "visible") run(); }; document.addEventListener("visibilitychange", this.checkpointVisibilityListener); } if (cfg.on.includes("interval")) { this.checkpointTimer = setInterval(run, cfg.intervalMs ?? 60_000); } if ( cfg.on.includes("devicechange") && typeof navigator !== "undefined" && navigator.mediaDevices?.addEventListener ) { // A media device was added/removed. This is the event-driven catch for // a mic/speaker (e.g. a Bluetooth headset) dropping mid-exam — those // have no continuous track, so no track-`ended` fires. Debounce because // browsers emit `devicechange` in bursts (and twice for one plug event). this.checkpointDeviceChangeListener = () => { if (this.checkpointDeviceChangeDebounce !== null) { clearTimeout(this.checkpointDeviceChangeDebounce); } this.checkpointDeviceChangeDebounce = setTimeout(() => { this.checkpointDeviceChangeDebounce = null; run(); }, CHECKPOINT_DEVICE_CHANGE_DEBOUNCE_MS); }; navigator.mediaDevices.addEventListener("devicechange", this.checkpointDeviceChangeListener); } } private async runScheduledCheckpoint(): Promise { if (this.stopped || !this.ready) return; try { const result = await this.checkpoint(); this.checkpointConfig?.onCheckpoint?.(result); if (!result.ok) this.checkpointConfig?.onDrift?.(result); } catch { // Checkpoint is best-effort observability; a failure never breaks the session. } } private stopCheckpoint(): void { if (this.checkpointTimer !== null) { clearInterval(this.checkpointTimer); this.checkpointTimer = null; } if (this.checkpointVisibilityListener && typeof document !== "undefined") { document.removeEventListener("visibilitychange", this.checkpointVisibilityListener); this.checkpointVisibilityListener = null; } if (this.checkpointDeviceChangeDebounce !== null) { clearTimeout(this.checkpointDeviceChangeDebounce); this.checkpointDeviceChangeDebounce = null; } if ( this.checkpointDeviceChangeListener && typeof navigator !== "undefined" && navigator.mediaDevices?.removeEventListener ) { navigator.mediaDevices.removeEventListener( "devicechange", this.checkpointDeviceChangeListener, ); this.checkpointDeviceChangeListener = null; } } /** * Re-open the browser screen-share picker for an already-running * session. Intended for the candidate-clicked-Stop-Sharing recovery * path: the host UI can lock the assessment, then call this from a * fresh user click to restore capture without ending the session or * creating a new attempt. * * Returns true when a valid share is active again. Returns false when * the candidate cancels the picker or selects a non-monitor surface. */ async restartScreenShare(): Promise { if (this.stopped || !this.worker || !this.ready) { throw new Error("ProctoringClient: cannot restart screen share after stop."); } if (!this.hasScreenShareObserver()) { throw new Error("ProctoringClient: screen share observer is not enabled."); } // Open the picker before the first await so the candidate's recovery click // still carries transient user activation. const grant = requestScreenShare({ enforceEntireScreen: false }); await this.screenShare?.stop("track-ended").catch(() => undefined); this.screenShare = null; try { const stream = await grant; await this.startScreenShare({ freshStream: true, stream }); return true; } catch (err) { if (err instanceof ScreenShareDeclinedError) { const telemetry = screenShareTelemetry(err); this.emitTelemetry(telemetry.kind, telemetry.input, { direct: true, dedupeKey: `${telemetry.input.code}:restart`, }); this.onScreenShareError?.(err.kind); return false; } throw err; } } /** * Start MediaRecorder on an already-active screen-share stream. * Used by staged flows that acquire/validate screen-share first, * then begin actual recording only when the assessment truly starts. */ startScreenShareRecording(): boolean { if (this.stopped || !this.worker || !this.ready) { throw new Error("ProctoringClient: cannot start screen recording after stop."); } if (!this.hasScreenShareObserver()) { throw new Error("ProctoringClient: screen share observer is not enabled."); } return this.screenShare?.startRecording() ?? false; } private emitTelemetry( kind: SdkTelemetryEventKind, input: TelemetryInput, options: { direct?: boolean; dedupeKey?: string } = {}, ): void { if (!this.telemetry.enabled) return; if (options.dedupeKey) { if (this.telemetryDedupe.has(options.dedupeKey)) return; this.telemetryDedupe.add(options.dedupeKey); } const payload = buildTelemetryPayload(input, { telemetry: this.telemetry, fingerprint: this.fingerprint, }); if (!options.direct && this.worker && this.ready && !this.stopped) { const event: PartialEvent = { kind, timestamp: systemCaptureClock.now(), payload, }; this.notifySessionEvent(event); this.post({ type: "emit", event, }); return; } this.sendTelemetryDirect(kind, payload); } private sendTelemetryDirect(kind: SdkTelemetryEventKind, payload: Record): void { this.sendEventDirect(kind, payload); } private sendEventDirect( kind: EventKind, payload: Record | undefined, options: { keepalive?: boolean; notify?: boolean } = {}, ): void { if (typeof fetch === "undefined") return; const batchId = newId("batch"); const event: SessionEvent = { id: newId("evt"), sessionId: this.config.sessionId, kind, timestamp: Date.now(), ...(payload !== undefined ? { payload } : {}), ...(this.config.fingerprintId !== undefined ? { fingerprintId: this.config.fingerprintId } : {}), }; const body: IngestBatchRequest = { batchId, sessionId: this.config.sessionId, events: [event], ...(this.candidate !== undefined ? { candidate: this.candidate } : {}), }; if (options.notify) { this.notifySessionEvent({ kind, ...(payload !== undefined ? { payload } : {}), }); } try { void fetch(this.config.ingestUrl, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": batchId, ...(this.config.appId ? { "x-app-id": this.config.appId } : {}), }, body: JSON.stringify(body), credentials: "omit", ...(options.keepalive ? { keepalive: true } : {}), }).catch(() => undefined); } catch { // Direct end/telemetry sends are best-effort. The worker queue // still handles the normal path when available. } } private emitSessionEndRequested(reason: SessionEndReason): void { if (this.endRequestedEmitted) return; this.endRequestedEmitted = true; this.sendEventDirect( "session.end_requested", { reason, drainDeadlineMs: SESSION_END_DRAIN_DEADLINE_MS, }, { keepalive: true, notify: true }, ); } /** * Emit the terminal `session.ended` event. Unlike the public emit(), * this is called from clean programmatic teardown where it must * bypass the `ready` guard — the worker is still alive and able to * enqueue + flush, but `this.ready` may already be on its way down. * Posting the emit message BEFORE the subsequent flush/stop message * guarantees in-order handling in the worker: the event is enqueued * first, then drained by the same flush. * * Fired exactly once (guarded by `endedEmitted`). The server folds * this event into endedAt + terminal status. Clean stops run the * post-session analysis decision pass. */ private emitSessionEnded( reason: SessionEndReason, drain: { status: SessionEndDrainStatus; timeoutStreams: Array; }, ): void { if (this.endedEmitted) return; if (!this.worker) return; this.endedEmitted = true; this.postSessionEndedToWorker(reason, drain); } private postSessionEndedToWorker( reason: SessionEndReason, drain: { status: SessionEndDrainStatus; timeoutStreams: Array; }, ): void { const event: PartialEvent = { kind: "session.ended", timestamp: systemCaptureClock.now(), payload: { reason, drainStatus: drain.status, drainDeadlineMs: SESSION_END_DRAIN_DEADLINE_MS, ...(drain.timeoutStreams.length > 0 ? { drainTimeoutStreams: drain.timeoutStreams } : {}), }, }; this.notifySessionEvent(event); this.post({ type: "emit", event, }); } private notifySessionEvent(event: PartialEvent): void { try { this.onSessionEvent?.(event); } catch { // Host UI callbacks must not break the SDK event pipeline. } } /** * Request fullscreen entry on the document. The Fullscreen API * REQUIRES a fresh user gesture, so this must be called inside * a click / keypress / pointerup handler — same as the call * site that opens screen-share / camera permission. Returns a * promise that resolves true on entry, false on rejection * (browser blocked, user dismissed, already in another * fullscreen target). * * Use cases: * - Customer's "Start session" button handler: call this * alongside ProctoringClient construction so the candidate * enters fullscreen immediately. * - "Return to fullscreen" modal button: when the SDK fires * `fullscreen.exited` mid-session, the host UI shows a * modal whose button click calls this method to restore * the lock. * * The DomObservers' fullscreenchange listener will pick up * the entry and emit `fullscreen.entered` through the normal * event pipeline. */ async requestFullscreen(): Promise { if (typeof document === "undefined") return false; const target = document.documentElement; if (document.fullscreenElement === target) return true; try { await target.requestFullscreen({ navigationUI: "hide" }); return true; } catch (err) { // Don't throw — fullscreen rejection is a soft failure. // Caller's modal UI handles the recovery loop. console.warn( "[ProctoringClient] requestFullscreen rejected:", err instanceof Error ? err.message : err, ); return false; } } private async exitFullscreenOnSessionEnd(): Promise { if (typeof document === "undefined") return; if (!document.fullscreenElement) return; if (typeof document.exitFullscreen !== "function") return; try { await document.exitFullscreen(); } catch (err) { // Don't let browser fullscreen quirks block session teardown. At // this point the terminal event is already queued for upload. console.warn( "[ProctoringClient] exitFullscreen rejected:", err instanceof Error ? err.message : err, ); } } /** * Start recording an ad-hoc video clip. Use this for customer-driven * recordings — eg. answering a video question — that don't fit the * passive observer model. Returns a handle whose only method is * `stop()`. The clip uploads automatically on stop. * * Stream sourcing in priority order: * 1. Customer-provided MediaStream (`options.stream`) — shared with * the customer's app, the SDK does not own its lifecycle. * 2. The webcam observer's stream — reuses the camera handle the * observer is already holding for photos. No second permission * prompt, no second camera light. * 3. Fresh `getUserMedia({ video: true, audio: true })` — the SDK * owns this stream and releases it when the recorder stops. * * Must be called inside a user-activation handler when path 3 will * be taken (the customer's button click satisfies this). Paths 1 * and 2 already have an active stream so user activation doesn't * matter. * * Caps recording at 2 minutes by default to prevent runaway blobs * if the customer forgets to call stop(). */ async recordVideoClip( options: { stream?: MediaStream; /** * When a `stream` is supplied, stop its tracks on finalise. Default * false — a caller-provided stream's lifecycle is theirs to manage * (it may feed a preview element or another recorder). Set true when * the caller acquired a stream solely for this clip and wants the * SDK to release it (camera/mic light goes dark) on stop. No effect * when the SDK acquires the stream itself — it always owns those. */ stopStreamOnFinalize?: boolean; maxDurationMs?: number; videoBitrate?: number; /** * Whether to include a microphone in the clip. Default: true. * When the resolved stream has no audio track, the SDK acquires a * mic-only stream just for the clip and releases it on stop — * keeping the mic light dark for the rest of the session. */ audio?: boolean; onUploaded?: (clipNumber: number, byteSize: number, durationMs: number) => void; onDropped?: (clipNumber: number, reason: string) => void; /** * Best-effort mirror of the finalized clip to a second backend, * fired in parallel with the upload to the proctoring server. * Resolve = mirror succeeded; reject/throw = mirror failed. A * failure here NEVER changes the clip outcome — `onUploaded` / * `onDropped` still reflect the proctoring-server result. Observe * mirror failures via `onClipDataError`. */ onClipData?: (clip: { blob: Blob; mimeType: string; durationMs: number; clipNumber: number; }) => Promise; /** * Notified when `onClipData` rejects. Non-fatal: the clip is still * uploaded to the proctoring server. Exists so callers can log / * surface the mirror failure without it masquerading as a drop. */ onClipDataError?: (clipNumber: number, error: Error) => void; } = {}, ): Promise { const wantAudio = options.audio ?? true; // Build the stream the recorder will see. Critical rule: we never // mutate (addTrack/removeTrack) a stream that's being recorded by // some *other* MediaRecorder. Mutating an actively-recording // stream transitions that recorder to `inactive` and breaks it // silently — which is exactly how clips were murdering the // webcam observer's continuous recording. So whenever we have to // combine borrowed video with a freshly-acquired mic, we put the // combination in a *new* MediaStream object and record from that. // // tracksToStop tells the recorder which tracks it owns and // should stop on finalise; borrowed tracks stay alive. let stream: MediaStream; const tracksToStop: MediaStreamTrack[] = []; if (options.stream) { // Path 1: customer hands in their own stream. By default we never // touch its tracks — they may be using it for their own video // element or another recorder. If they want audio they should // include it themselves. When `stopStreamOnFinalize` is set the // caller is telling us they acquired it just for this clip, so we // take ownership of its tracks and release them on stop. stream = options.stream; if (options.stopStreamOnFinalize) { for (const track of stream.getTracks()) tracksToStop.push(track); } } else { const webcamStream = this.webcam?.getStream() ?? null; if (webcamStream) { // Path 2: reuse the webcam observer's camera, but record // through a *fresh* MediaStream containing the (borrowed) // video track plus an optionally-acquired (owned) mic. The // observer's stream is untouched — its MediaRecorder keeps // ticking. stream = new MediaStream(); for (const track of webcamStream.getVideoTracks()) { stream.addTrack(track); } if (wantAudio && typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia) { try { const micStream = await navigator.mediaDevices.getUserMedia({ audio: this.audioInputConstraint(), }); for (const track of micStream.getAudioTracks()) { stream.addTrack(track); tracksToStop.push(track); } } catch { // Mic denied / unavailable — fall through and record // video-only. The clip is still useful, just silent. } } } else { // Path 3: no webcam observer; acquire our own video+audio // and own everything. if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) { const error = new Error("getUserMedia is not available in this browser"); this.emitTelemetry("sdk.media.failed", { code: "runtime.video_clip.media_failed", phase: "runtime.video-clip", recoverable: false, error, details: { reason: "getUserMedia-unavailable" }, }); throw error; } try { stream = await navigator.mediaDevices.getUserMedia({ video: this.videoInputConstraint(), audio: wantAudio ? this.audioInputConstraint() : false, }); } catch (err) { const telemetry = mediaAcquisitionTelemetry("video", "runtime.video-clip", err); this.emitTelemetry(telemetry.kind, telemetry.input); throw err; } for (const track of stream.getTracks()) tracksToStop.push(track); } } this.videoClipCounter += 1; const clipNumber = this.videoClipCounter; const recorder = new VideoClipRecorder({ sessionId: this.config.sessionId, ingestUrl: this.config.ingestUrl, ...(this.config.appId ? { appId: this.config.appId } : {}), ...(this.config.fingerprintId ? { fingerprintId: this.config.fingerprintId } : {}), clipNumber, stream, tracksToStop, ...(options.maxDurationMs !== undefined ? { maxDurationMs: options.maxDurationMs } : {}), ...(options.videoBitrate !== undefined ? { videoBitrate: options.videoBitrate } : {}), ...(options.onClipData ? { // Wrap the caller's mirror so its rejection is surfaced via // onClipDataError instead of being swallowed. The recorder // already isolates this from the clip outcome. onClipData: async (clip) => { try { await options.onClipData!(clip); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); options.onClipDataError?.(clip.clipNumber, error); throw error; } }, } : {}), onUploaded: (n, byteSize, durationMs) => { try { this.emit("video-clip.uploaded", { clipNumber: n, byteSize, durationMs }); } catch { // Defensive: callback after teardown shouldn't crash. } options.onUploaded?.(n, byteSize, durationMs); }, onDropped: (n, reason) => { try { this.emit("video-clip.dropped", { clipNumber: n, reason }); } catch { // Defensive. } this.emitTelemetry("sdk.upload.failed", { code: "upload.media.clip_dropped", phase: "runtime.video-clip.upload", recoverable: reason !== "rejected", details: { clipNumber: n, reason, mediaKind: "video" }, }); options.onDropped?.(n, reason); }, }); try { this.emit("video-clip.started", { clipNumber }); } catch { // Defensive: emit can throw if called pre-ready; recordVideoClip // is typically called mid-session so this is fine. } try { recorder.start(); this.videoClipRecorders.add(recorder); } catch (err) { this.emitTelemetry("sdk.media.failed", { code: typeof MediaRecorder === "undefined" ? "runtime.media_recorder.unavailable" : "runtime.video_clip.media_failed", phase: "runtime.video-clip", recoverable: true, error: err, details: { clipNumber, mediaKind: "video" }, }); throw err; } // Return a wrapper so the customer can't call .start() again. return { clipNumber, stop: async () => { try { this.emit("video-clip.stopped", { clipNumber }); } catch { // Defensive. } await recorder.stop(); this.videoClipRecorders.delete(recorder); }, }; } /** * Record an ad-hoc audio-only clip. Mic gets acquired on demand * (or borrowed from the caller's stream), recorded to opus-in- * WebM by default, uploaded to /audio-clips on stop, and * surfaces on the dashboard's Audio tab. * * Stream sourcing strategy mirrors recordVideoClip: * 1. If the customer hands in a stream, use it directly. * They own the lifecycle; the recorder never stops their * tracks unless `stopStreamOnFinalize` is true. * 2. Otherwise call getUserMedia({ audio: true }) for a * fresh mic-only stream and own it (stopped on finalise * so the mic light goes dark). * * `recordAudioClip` is independent of `recordVideoClip` -- the * two can run simultaneously (eg. a video clip for "show me * your workspace" plus a parallel audio clip for narration). * Clip numbers come from a separate counter; see * `audioClipCounter` below. * * Caps at 2 minutes by default like its video sibling. */ async recordAudioClip( options: { stream?: MediaStream; stopStreamOnFinalize?: boolean; maxDurationMs?: number; onUploaded?: ( clipNumber: number, byteSize: number, durationMs: number, volumeAnalysis?: AudioClipVolumeAnalysis, ) => void; onDropped?: ( clipNumber: number, reason: string, volumeAnalysis?: AudioClipVolumeAnalysis, code?: ClipDropCode, ) => void; /** * Best-effort mirror of the finalized clip to a second backend, * fired in parallel with the upload to the proctoring server. * Resolve = mirror succeeded; reject/throw = mirror failed. A * failure here NEVER changes the clip outcome — `onUploaded` / * `onDropped` still reflect the proctoring-server result. Observe * mirror failures via `onClipDataError`. */ onClipData?: (clip: { blob: Blob; mimeType: string; durationMs: number; clipNumber: number; }) => Promise; /** * Notified when `onClipData` rejects. Non-fatal: the clip is still * uploaded to the proctoring server. Exists so callers can log / * surface the mirror failure without it masquerading as a drop. */ onClipDataError?: (clipNumber: number, error: Error) => void; } = {}, ): Promise { let stream: MediaStream; const tracksToStop: MediaStreamTrack[] = []; if (options.stream) { stream = options.stream; if (options.stopStreamOnFinalize) { for (const track of stream.getTracks()) tracksToStop.push(track); } } else { if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) { const error = new Error("getUserMedia is not available in this browser"); this.emitTelemetry("sdk.media.failed", { code: "runtime.audio_clip.media_failed", phase: "runtime.audio-clip", recoverable: false, error, details: { reason: "getUserMedia-unavailable" }, }); throw error; } try { stream = await navigator.mediaDevices.getUserMedia({ audio: this.audioInputConstraint(), }); } catch (err) { const telemetry = mediaAcquisitionTelemetry("audio", "runtime.audio-clip", err); this.emitTelemetry(telemetry.kind, telemetry.input); throw err; } for (const track of stream.getTracks()) tracksToStop.push(track); } this.audioClipCounter += 1; const clipNumber = this.audioClipCounter; const recorder = new VideoClipRecorder({ kind: "audio", sessionId: this.config.sessionId, ingestUrl: this.config.ingestUrl, ...(this.config.appId ? { appId: this.config.appId } : {}), ...(this.config.fingerprintId ? { fingerprintId: this.config.fingerprintId } : {}), clipNumber, stream, tracksToStop, ...(options.maxDurationMs !== undefined ? { maxDurationMs: options.maxDurationMs } : {}), ...(options.onClipData ? { // Wrap the caller's mirror so its rejection is surfaced via // onClipDataError instead of being swallowed. The recorder // already isolates this from the clip outcome. onClipData: async (clip) => { try { await options.onClipData!(clip); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); options.onClipDataError?.(clip.clipNumber, error); throw error; } }, } : {}), onUploaded: (n, byteSize, durationMs, volumeAnalysis) => { try { this.emit("audio-clip.uploaded", { clipNumber: n, byteSize, durationMs, ...(volumeAnalysis ? { volumeAnalysis } : {}), }); } catch { /* Defensive. */ } options.onUploaded?.(n, byteSize, durationMs, volumeAnalysis); }, onDropped: (n, reason, volumeAnalysis, code) => { try { this.emit("audio-clip.dropped", { clipNumber: n, reason, ...(volumeAnalysis ? { volumeAnalysis } : {}), }); } catch { /* Defensive. */ } this.emitTelemetry("sdk.upload.failed", { code: "upload.media.clip_dropped", phase: "runtime.audio-clip.upload", recoverable: reason !== "rejected", details: { clipNumber: n, reason, code, mediaKind: "audio" }, }); options.onDropped?.(n, reason, volumeAnalysis, code); }, }); try { this.emit("audio-clip.started", { clipNumber }); } catch { /* Defensive: emit can throw if called pre-ready. */ } try { recorder.start(); // Reuse the same Set as video clips for teardown bookkeeping // -- stop() on the recorder is kind-agnostic. this.videoClipRecorders.add(recorder); } catch (err) { this.emitTelemetry("sdk.media.failed", { code: typeof MediaRecorder === "undefined" ? "runtime.media_recorder.unavailable" : "runtime.audio_clip.media_failed", phase: "runtime.audio-clip", recoverable: true, error: err, details: { clipNumber, mediaKind: "audio" }, }); throw err; } return { clipNumber, stop: async () => { try { this.emit("audio-clip.stopped", { clipNumber }); } catch { /* Defensive. */ } await recorder.stop(); this.videoClipRecorders.delete(recorder); }, }; } /** * Begin recording the integrity signals for a written answer. Returns * a {@link TextAnswerHandle} whose methods emit the `text.*` events * (paste, typing dynamics, checkpoints, focus, synthetic input, save * outcome, submit). Used by the Vue . * * Note there is NO upload here — a writing answer's text is owned by * the integrator and saved through their own backend. This only ships * the integrity signals to the proctoring server via the normal event * pipeline. The emit swallows the pre-ready throw so a keystroke can * never crash the field. */ recordTextAnswer(options: { questionId: string }): TextAnswerHandle { return createTextAnswerRecorder({ questionId: options.questionId, emit: (kind, payload) => { try { this.emit(kind, payload); } catch { // Pre-ready or post-stop emit — drop the signal rather than // throw into the candidate's typing. } }, }); } /** * Upload a video clip the customer recorded themselves. Use this * when your app has its own preview / retake / accept UX and you * just want the SDK to ship the finished Blob to the proctoring * server. The clip lands in the same dashboard surface as one * recorded via `recordVideoClip()`. * * Returns a {@link VideoClipHandle} whose `clipNumber` is the * server-assigned identifier (1-based per session) and whose * `stop()` is a no-op — the recording is already done. * * Emits `video-clip.uploaded` or `video-clip.dropped` on the * timeline. Throws if the upload itself fails synchronously * (network unavailable); 4xx/5xx responses route through * `video-clip.dropped` and resolve normally. */ async uploadVideoClip( blob: Blob, options: { durationMs?: number; onUploaded?: (clipNumber: number, byteSize: number, durationMs: number) => void; onDropped?: (clipNumber: number, reason: string) => void; } = {}, ): Promise { this.videoClipCounter += 1; const clipNumber = this.videoClipCounter; const durationMs = Math.max(0, Math.floor(options.durationMs ?? 0)); try { this.emit("video-clip.started", { clipNumber, source: "customer-blob" }); } catch { // Defensive: emit can throw if the client isn't ready yet. } const result = await uploadVideoClipImpl({ sessionId: this.config.sessionId, ingestUrl: this.config.ingestUrl, appId: this.config.appId, ...(this.config.fingerprintId ? { fingerprintId: this.config.fingerprintId } : {}), clipNumber, blob, durationMs, }); if (result.kind === "uploaded") { try { this.emit("video-clip.uploaded", { clipNumber: result.clipNumber, byteSize: result.byteSize, durationMs: result.durationMs, }); } catch { // Defensive. } options.onUploaded?.(result.clipNumber, result.byteSize, result.durationMs); } else { try { this.emit("video-clip.dropped", { clipNumber: result.clipNumber, reason: result.reason, }); } catch { // Defensive. } this.emitTelemetry("sdk.upload.failed", { code: "upload.media.clip_dropped", phase: "runtime.video-clip.upload", recoverable: result.reason !== "rejected", details: { clipNumber: result.clipNumber, reason: result.reason, mediaKind: "video", }, }); options.onDropped?.(result.clipNumber, result.reason); } // Even when the upload failed, we surface a handle so the customer // has a stable shape to consume. stop() is a no-op because the // operation is already complete. return { clipNumber, stop: async () => undefined, }; } /** * Play an assessment-provided audio file, for example a listening * question prompt. Pass only the URL for a detached player, or pass * your own