@a4anthony/proctorkit-vue 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -24387,7 +24387,7 @@ function deepFreeze(value) {
24387
24387
  }
24388
24388
  return Object.freeze(value);
24389
24389
  }
24390
- const workerSource = 'const instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c);\nlet idbProxyableTypes;\nlet cursorAdvanceMethods;\nfunction getIdbProxyableTypes() {\n return idbProxyableTypes || (idbProxyableTypes = [\n IDBDatabase,\n IDBObjectStore,\n IDBIndex,\n IDBCursor,\n IDBTransaction\n ]);\n}\nfunction getCursorAdvanceMethods() {\n return cursorAdvanceMethods || (cursorAdvanceMethods = [\n IDBCursor.prototype.advance,\n IDBCursor.prototype.continue,\n IDBCursor.prototype.continuePrimaryKey\n ]);\n}\nconst transactionDoneMap = /* @__PURE__ */ new WeakMap();\nconst transformCache = /* @__PURE__ */ new WeakMap();\nconst reverseTransformCache = /* @__PURE__ */ new WeakMap();\nfunction promisifyRequest(request) {\n const promise = new Promise((resolve, reject) => {\n const unlisten = () => {\n request.removeEventListener("success", success);\n request.removeEventListener("error", error);\n };\n const success = () => {\n resolve(wrap(request.result));\n unlisten();\n };\n const error = () => {\n reject(request.error);\n unlisten();\n };\n request.addEventListener("success", success);\n request.addEventListener("error", error);\n });\n reverseTransformCache.set(promise, request);\n return promise;\n}\nfunction cacheDonePromiseForTransaction(tx) {\n if (transactionDoneMap.has(tx))\n return;\n const done = new Promise((resolve, reject) => {\n const unlisten = () => {\n tx.removeEventListener("complete", complete);\n tx.removeEventListener("error", error);\n tx.removeEventListener("abort", error);\n };\n const complete = () => {\n resolve();\n unlisten();\n };\n const error = () => {\n reject(tx.error || new DOMException("AbortError", "AbortError"));\n unlisten();\n };\n tx.addEventListener("complete", complete);\n tx.addEventListener("error", error);\n tx.addEventListener("abort", error);\n });\n transactionDoneMap.set(tx, done);\n}\nlet idbProxyTraps = {\n get(target, prop, receiver) {\n if (target instanceof IDBTransaction) {\n if (prop === "done")\n return transactionDoneMap.get(target);\n if (prop === "store") {\n return receiver.objectStoreNames[1] ? void 0 : receiver.objectStore(receiver.objectStoreNames[0]);\n }\n }\n return wrap(target[prop]);\n },\n set(target, prop, value) {\n target[prop] = value;\n return true;\n },\n has(target, prop) {\n if (target instanceof IDBTransaction && (prop === "done" || prop === "store")) {\n return true;\n }\n return prop in target;\n }\n};\nfunction replaceTraps(callback) {\n idbProxyTraps = callback(idbProxyTraps);\n}\nfunction wrapFunction(func) {\n if (getCursorAdvanceMethods().includes(func)) {\n return function(...args) {\n func.apply(unwrap(this), args);\n return wrap(this.request);\n };\n }\n return function(...args) {\n return wrap(func.apply(unwrap(this), args));\n };\n}\nfunction transformCachableValue(value) {\n if (typeof value === "function")\n return wrapFunction(value);\n if (value instanceof IDBTransaction)\n cacheDonePromiseForTransaction(value);\n if (instanceOfAny(value, getIdbProxyableTypes()))\n return new Proxy(value, idbProxyTraps);\n return value;\n}\nfunction wrap(value) {\n if (value instanceof IDBRequest)\n return promisifyRequest(value);\n if (transformCache.has(value))\n return transformCache.get(value);\n const newValue = transformCachableValue(value);\n if (newValue !== value) {\n transformCache.set(value, newValue);\n reverseTransformCache.set(newValue, value);\n }\n return newValue;\n}\nconst unwrap = (value) => reverseTransformCache.get(value);\nfunction openDB(name, version, { blocked, upgrade, blocking, terminated } = {}) {\n const request = indexedDB.open(name, version);\n const openPromise = wrap(request);\n if (upgrade) {\n request.addEventListener("upgradeneeded", (event) => {\n upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event);\n });\n }\n if (blocked) {\n request.addEventListener("blocked", (event) => blocked(\n // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405\n event.oldVersion,\n event.newVersion,\n event\n ));\n }\n openPromise.then((db) => {\n if (terminated)\n db.addEventListener("close", () => terminated());\n if (blocking) {\n db.addEventListener("versionchange", (event) => blocking(event.oldVersion, event.newVersion, event));\n }\n }).catch(() => {\n });\n return openPromise;\n}\nfunction deleteDB(name, { blocked } = {}) {\n const request = indexedDB.deleteDatabase(name);\n if (blocked) {\n request.addEventListener("blocked", (event) => blocked(\n // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405\n event.oldVersion,\n event\n ));\n }\n return wrap(request).then(() => void 0);\n}\nconst readMethods = ["get", "getKey", "getAll", "getAllKeys", "count"];\nconst writeMethods = ["put", "add", "delete", "clear"];\nconst cachedMethods = /* @__PURE__ */ new Map();\nfunction getMethod(target, prop) {\n if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === "string")) {\n return;\n }\n if (cachedMethods.get(prop))\n return cachedMethods.get(prop);\n const targetFuncName = prop.replace(/FromIndex$/, "");\n const useIndex = prop !== targetFuncName;\n const isWrite = writeMethods.includes(targetFuncName);\n if (\n // Bail if the target doesn\'t exist on the target. Eg, getAll isn\'t in Edge.\n !(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))\n ) {\n return;\n }\n const method = async function(storeName, ...args) {\n const tx = this.transaction(storeName, isWrite ? "readwrite" : "readonly");\n let target2 = tx.store;\n if (useIndex)\n target2 = target2.index(args.shift());\n return (await Promise.all([\n target2[targetFuncName](...args),\n isWrite && tx.done\n ]))[0];\n };\n cachedMethods.set(prop, method);\n return method;\n}\nreplaceTraps((oldTraps) => ({\n ...oldTraps,\n get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver),\n has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop)\n}));\nconst advanceMethodProps = ["continue", "continuePrimaryKey", "advance"];\nconst methodMap = {};\nconst advanceResults = /* @__PURE__ */ new WeakMap();\nconst ittrProxiedCursorToOriginalProxy = /* @__PURE__ */ new WeakMap();\nconst cursorIteratorTraps = {\n get(target, prop) {\n if (!advanceMethodProps.includes(prop))\n return target[prop];\n let cachedFunc = methodMap[prop];\n if (!cachedFunc) {\n cachedFunc = methodMap[prop] = function(...args) {\n advanceResults.set(this, ittrProxiedCursorToOriginalProxy.get(this)[prop](...args));\n };\n }\n return cachedFunc;\n }\n};\nasync function* iterate(...args) {\n let cursor = this;\n if (!(cursor instanceof IDBCursor)) {\n cursor = await cursor.openCursor(...args);\n }\n if (!cursor)\n return;\n cursor = cursor;\n const proxiedCursor = new Proxy(cursor, cursorIteratorTraps);\n ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor);\n reverseTransformCache.set(proxiedCursor, unwrap(cursor));\n while (cursor) {\n yield proxiedCursor;\n cursor = await (advanceResults.get(proxiedCursor) || cursor.continue());\n advanceResults.delete(proxiedCursor);\n }\n}\nfunction isIteratorProp(target, prop) {\n return prop === Symbol.asyncIterator && instanceOfAny(target, [IDBIndex, IDBObjectStore, IDBCursor]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]);\n}\nreplaceTraps((oldTraps) => ({\n ...oldTraps,\n get(target, prop, receiver) {\n if (isIteratorProp(target, prop))\n return iterate;\n return oldTraps.get(target, prop, receiver);\n },\n has(target, prop) {\n return isIteratorProp(target, prop) || oldTraps.has(target, prop);\n }\n}));\nconst DEFAULT_DB_NAME = "proctoring-sdk";\nconst DEFAULT_MAX_BYTES = 50 * 1024 * 1024;\nconst SEQ_KEY = "seq";\nconst BYTES_KEY = "bytes";\nconst SESSION_DB_PREFIX = "proctoring-sess";\nfunction sessionDbName(sessionId) {\n return `proctoring-${sessionId}`;\n}\nclass EventQueue {\n db = null;\n memory = null;\n dbName;\n maxBytes;\n storage;\n fallbackToMemory;\n constructor(options = {}) {\n this.dbName = options.dbName ?? DEFAULT_DB_NAME;\n this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;\n this.storage = options.storage ?? "indexeddb";\n this.fallbackToMemory = options.fallbackToMemory ?? true;\n }\n /** Opens (or upgrades) the underlying IndexedDB database. Must be called once before any other method. Idempotent. */\n async open() {\n if (this.db || this.memory) return;\n if (this.storage === "memory") {\n this.openMemory("configured-memory-storage");\n return;\n }\n try {\n this.db = await openDB(this.dbName, 1, {\n upgrade(db) {\n const events = db.createObjectStore("events", { keyPath: "id" });\n events.createIndex("by-seq", "seq");\n db.createObjectStore("meta", { keyPath: "key" });\n }\n });\n } catch (err) {\n if (!this.fallbackToMemory) throw err;\n this.openMemory(indexedDbOpenFailureReason(err));\n }\n }\n storageMode() {\n return this.memory ? "memory" : "indexeddb";\n }\n storageFallbackReason() {\n return this.memory?.reason ?? null;\n }\n /**\n * Appends an event to the tail. If the queue would exceed\n * `maxBytes`, drops the oldest events until it fits — the new\n * event is never the one dropped. Returns the count of dropped\n * events so callers can surface backpressure to the host.\n */\n async enqueue(event) {\n if (this.memory) return this.enqueueMemory(event);\n const db = this.requireDb();\n const bytes = byteLength(event);\n const tx = db.transaction(["events", "meta"], "readwrite");\n const events = tx.objectStore("events");\n const meta = tx.objectStore("meta");\n const seq = ((await meta.get(SEQ_KEY))?.value ?? 0) + 1;\n let totalBytes = ((await meta.get(BYTES_KEY))?.value ?? 0) + bytes;\n await events.put({ ...event, seq, bytes });\n await meta.put({ key: SEQ_KEY, value: seq });\n let dropped = 0;\n if (totalBytes > this.maxBytes) {\n const cursor = await events.index("by-seq").openCursor();\n let c = cursor;\n while (c && totalBytes > this.maxBytes) {\n const stored = c.value;\n if (stored.id === event.id) {\n c = await c.continue();\n continue;\n }\n totalBytes -= stored.bytes;\n dropped += 1;\n await c.delete();\n c = await c.continue();\n }\n }\n await meta.put({ key: BYTES_KEY, value: totalBytes });\n await tx.done;\n return { dropped };\n }\n /** Returns up to `limit` oldest events without removing them. */\n async peek(limit) {\n if (this.memory) {\n return this.memory.events.slice(0, limit).map(({ seq: _seq, bytes: _bytes, ...event }) => event);\n }\n const db = this.requireDb();\n const tx = db.transaction("events", "readonly");\n const out = [];\n let cursor = await tx.store.index("by-seq").openCursor();\n while (cursor && out.length < limit) {\n const { seq: _seq, bytes: _bytes, ...event } = cursor.value;\n out.push(event);\n cursor = await cursor.continue();\n }\n return out;\n }\n /**\n * Removes events by id. Unknown ids are silently skipped so callers\n * can safely ack the same id twice (eg. when an idempotency-keyed\n * server retry returns success after we already acked locally).\n */\n async ack(ids) {\n if (ids.length === 0) return;\n if (this.memory) {\n const idSet = new Set(ids);\n let bytes = this.memory.bytes;\n this.memory.events = this.memory.events.filter((event) => {\n if (!idSet.has(event.id)) return true;\n bytes -= event.bytes;\n return false;\n });\n this.memory.bytes = Math.max(0, bytes);\n return;\n }\n const db = this.requireDb();\n const tx = db.transaction(["events", "meta"], "readwrite");\n const events = tx.objectStore("events");\n const meta = tx.objectStore("meta");\n let totalBytes = (await meta.get(BYTES_KEY))?.value ?? 0;\n for (const id of ids) {\n const stored = await events.get(id);\n if (!stored) continue;\n totalBytes -= stored.bytes;\n await events.delete(id);\n }\n await meta.put({ key: BYTES_KEY, value: Math.max(0, totalBytes) });\n await tx.done;\n }\n /** Number of events currently queued. */\n async size() {\n if (this.memory) return this.memory.events.length;\n const db = this.requireDb();\n return db.count("events");\n }\n /** Cumulative byte size of all queued events (JSON-encoded). */\n async bytes() {\n if (this.memory) return this.memory.bytes;\n const db = this.requireDb();\n return (await db.get("meta", BYTES_KEY))?.value ?? 0;\n }\n /** Closes the database handle. Safe to call multiple times. */\n async close() {\n this.db?.close();\n this.db = null;\n this.memory = null;\n }\n /**\n * Closes the handle AND deletes the underlying database from disk.\n *\n * Call this only once the queue is known to be fully drained — a\n * deleted database takes any not-yet-uploaded events with it. The\n * worker gates this on `size() === 0` after draining at teardown, so\n * an offline teardown (events still queued) keeps the database for a\n * later page load to flush. Reclaiming the database is what stops the\n * per-session databases accumulating until the origin hits its\n * storage quota and `open()` starts throwing.\n */\n async destroy() {\n if (this.memory) {\n await this.close();\n return;\n }\n await this.close();\n await deleteDB(this.dbName);\n }\n /**\n * Best-effort reclamation of leaked per-session databases left by\n * earlier sessions on this origin. Enumerates all IndexedDB\n * databases, and for each `proctoring-sess-*` one NOT in `keep`,\n * opens it, checks it holds zero events, and deletes it if so. A\n * database with pending events is left alone — it may still flush on\n * a future load.\n *\n * No-ops where `indexedDB.databases()` is unavailable (Firefox, older\n * Safari): there is no enumeration API there, so leaked databases\n * can\'t be discovered. New sessions still self-clean via destroy(),\n * so the leak is bounded going forward even on those browsers.\n *\n * Never throws — cleanup must not be able to fail a session.\n */\n static async sweepStale(keep = /* @__PURE__ */ new Set()) {\n let removed = 0;\n try {\n if (typeof indexedDB === "undefined" || typeof indexedDB.databases !== "function") {\n return 0;\n }\n const entries = await indexedDB.databases();\n for (const { name } of entries) {\n if (!name || keep.has(name) || !name.startsWith(SESSION_DB_PREFIX)) {\n continue;\n }\n try {\n const queue = new EventQueue({ dbName: name });\n await queue.open();\n const pending = await queue.size();\n await queue.close();\n if (pending === 0) {\n await deleteDB(name);\n removed += 1;\n }\n } catch {\n }\n }\n } catch {\n }\n return removed;\n }\n requireDb() {\n if (!this.db) {\n throw new Error("EventQueue: call open() before any other operation");\n }\n return this.db;\n }\n openMemory(reason) {\n this.db = null;\n this.memory = {\n events: [],\n seq: 0,\n bytes: 0,\n reason\n };\n }\n enqueueMemory(event) {\n if (!this.memory) {\n throw new Error("EventQueue: call open() before any other operation");\n }\n const bytes = byteLength(event);\n const stored = {\n ...event,\n seq: this.memory.seq + 1,\n bytes\n };\n this.memory.seq = stored.seq;\n this.memory.events.push(stored);\n this.memory.bytes += bytes;\n let dropped = 0;\n while (this.memory.events.length > 1 && this.memory.bytes > this.maxBytes) {\n const oldest = this.memory.events[0];\n if (!oldest || oldest.id === event.id) break;\n this.memory.events.shift();\n this.memory.bytes -= oldest.bytes;\n dropped += 1;\n }\n return { dropped };\n }\n}\nfunction byteLength(event) {\n return new TextEncoder().encode(JSON.stringify(event)).byteLength;\n}\nfunction indexedDbOpenFailureReason(err) {\n if (err instanceof Error) {\n return `${err.name}: ${err.message}`;\n }\n return String(err);\n}\nclass DeliveryReadinessError extends Error {\n code;\n status;\n /** Structured operator diagnostics. Never render this object in candidate UI. */\n details;\n constructor(code, message, options) {\n super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);\n this.name = "DeliveryReadinessError";\n this.code = code;\n this.status = options?.status;\n this.details = options?.details;\n }\n}\nfunction newId(prefix) {\n if (typeof crypto !== "undefined" && "randomUUID" in crypto) {\n return crypto.randomUUID();\n }\n return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;\n}\nconst EVENT_REQUEST_TIMEOUT_MS = 1e4;\nclass RequestDeadlineError extends Error {\n constructor(operation, timeoutMs) {\n super(`${operation} timed out after ${timeoutMs}ms`);\n this.operation = operation;\n this.timeoutMs = timeoutMs;\n this.name = "RequestDeadlineError";\n }\n code = "request-timeout";\n}\nasync function withRequestDeadline(operation, timeoutMs, run, parentSignal) {\n if (parentSignal?.aborted) throw abortError();\n const controller = new AbortController();\n let timedOut = false;\n let timer;\n let onParentAbort;\n const cancelled = new Promise((_resolve, reject) => {\n onParentAbort = () => {\n controller.abort();\n reject(abortError());\n };\n parentSignal?.addEventListener("abort", onParentAbort, { once: true });\n timer = setTimeout(\n () => {\n timedOut = true;\n controller.abort();\n reject(new RequestDeadlineError(operation, timeoutMs));\n },\n Math.max(0, timeoutMs)\n );\n });\n try {\n return await Promise.race([Promise.resolve().then(() => run(controller.signal)), cancelled]);\n } catch (error) {\n if (timedOut) throw new RequestDeadlineError(operation, timeoutMs);\n throw error;\n } finally {\n if (timer !== void 0) clearTimeout(timer);\n if (onParentAbort) parentSignal?.removeEventListener("abort", onParentAbort);\n }\n}\nfunction abortError() {\n const error = new Error("The request was aborted");\n error.name = "AbortError";\n return error;\n}\nclass Uploader {\n opts;\n events = {};\n timer = null;\n draining = false;\n stopped = false;\n controller = null;\n constructor(options) {\n this.opts = {\n queue: options.queue,\n ingestUrl: options.ingestUrl,\n sessionId: options.sessionId,\n batchSize: options.batchSize ?? 50,\n batchIntervalMs: options.batchIntervalMs ?? 2e3,\n maxRetries: options.maxRetries ?? 5,\n fetchImpl: options.fetchImpl ?? fetch.bind(globalThis),\n newBatchId: options.newBatchId ?? (() => newId("batch")),\n appId: options.appId ?? null,\n candidate: options.candidate ?? (() => void 0)\n };\n }\n /** Replaces the lifecycle handlers. The Uploader keeps a single set; pass `{}` to clear. */\n on(events) {\n this.events = events;\n }\n /** Starts the periodic drain timer. No-op if already started or stopped. */\n start() {\n if (this.timer || this.stopped) return;\n this.timer = setInterval(() => {\n void this.drain();\n }, this.opts.batchIntervalMs);\n }\n /**\n * Drains the queue in batches until empty, no progress was made, or\n * a send fails. Re-entrant calls are guarded — only one drain runs\n * at a time even under concurrent invocations.\n */\n async drain() {\n if (this.draining || this.stopped) return;\n this.draining = true;\n try {\n while (!this.stopped) {\n const events = await this.opts.queue.peek(this.opts.batchSize);\n if (events.length === 0) return;\n const sizeBefore = await this.opts.queue.size();\n const result = await this.sendBatch(events, { keepalive: false });\n if (!result.progress) return;\n const sizeAfter = await this.opts.queue.size();\n if (sizeAfter >= sizeBefore) return;\n }\n } finally {\n this.draining = false;\n }\n }\n /**\n * Sends one batch. With `keepalive: true` the request survives page\n * navigation but is capped by the browser to ~64KB total in-flight;\n * we therefore send at most one batch and do not retry on failure.\n */\n async flush(options) {\n const events = await this.opts.queue.peek(this.opts.batchSize);\n if (events.length === 0) return;\n await this.sendBatch(events, { keepalive: options.keepalive, noRetry: options.keepalive });\n }\n /**\n * Prove that the real ingest route accepts an event before SDK readiness is\n * announced. This uses the same request builder, auth headers, response\n * validation, and retry path as normal queue delivery. It is intentionally\n * not configurable by SDK consumers.\n */\n async verifyDelivery(event, timeoutMs) {\n const result = await this.sendBatch([event], {\n keepalive: false,\n ackQueue: false,\n maxAttempts: 2,\n timeoutMs\n });\n if (result.accepted.includes(event.id)) return;\n if (result.rejected.includes(event.id)) {\n throw new DeliveryReadinessError(\n "ingest-rejected",\n "The evidence ingest route rejected the delivery canary.",\n { status: result.status }\n );\n }\n if (result.timedOut) {\n throw new DeliveryReadinessError(\n "ingest-timeout",\n "Timed out while verifying evidence delivery.",\n { cause: result.error }\n );\n }\n if (result.status === 401 || result.status === 403) {\n throw new DeliveryReadinessError(\n "ingest-not-authorized",\n "The evidence ingest route did not authorize this application and origin.",\n { status: result.status, cause: result.error }\n );\n }\n if (result.status !== void 0 && result.status >= 400 && result.status < 500) {\n throw new DeliveryReadinessError(\n "ingest-rejected",\n "The evidence ingest route rejected the delivery canary.",\n { status: result.status, cause: result.error }\n );\n }\n if (result.invalidResponse) {\n throw new DeliveryReadinessError(\n "ingest-invalid-response",\n "The evidence ingest route returned an invalid acknowledgement.",\n { cause: result.error }\n );\n }\n throw new DeliveryReadinessError(\n "ingest-unreachable",\n "The evidence ingest route could not be reached.",\n { cause: result.error, status: result.status }\n );\n }\n /** Stops the drain timer and aborts any in-flight non-keepalive request. */\n async stop() {\n this.stopped = true;\n if (this.timer) {\n clearInterval(this.timer);\n this.timer = null;\n }\n this.controller?.abort();\n }\n async sendBatch(events, {\n keepalive,\n noRetry = false,\n ackQueue = true,\n maxAttempts = this.opts.maxRetries,\n timeoutMs\n }) {\n const batchId = this.opts.newBatchId();\n const candidate = this.opts.candidate();\n const body = {\n batchId,\n sessionId: this.opts.sessionId,\n events,\n ...candidate ? { candidate } : {}\n };\n const controller = keepalive ? null : new AbortController();\n this.controller = controller;\n const signal = controller?.signal;\n let timedOut = false;\n const deadlineAt = timeoutMs === void 0 ? void 0 : Date.now() + timeoutMs;\n const timeout = controller && timeoutMs !== void 0 ? setTimeout(() => {\n timedOut = true;\n controller.abort();\n }, timeoutMs) : null;\n let lastError;\n let lastStatus;\n let invalidResponse = false;\n try {\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n if (this.stopped && !keepalive) {\n return { progress: false, accepted: [], rejected: [] };\n }\n let res;\n let parsedResponse;\n let parseError;\n try {\n const attemptTimeoutMs = deadlineAt === void 0 ? EVENT_REQUEST_TIMEOUT_MS : Math.max(1, Math.min(EVENT_REQUEST_TIMEOUT_MS, deadlineAt - Date.now()));\n const response = keepalive ? await this.opts.fetchImpl(this.opts.ingestUrl, {\n method: "POST",\n headers: {\n "content-type": "application/json",\n "idempotency-key": batchId,\n ...this.opts.appId ? { "x-app-id": this.opts.appId } : {}\n },\n body: JSON.stringify(body),\n keepalive\n }) : await withRequestDeadline(\n "event-ingest",\n attemptTimeoutMs,\n async (requestSignal) => {\n const request = await this.opts.fetchImpl(this.opts.ingestUrl, {\n method: "POST",\n headers: {\n "content-type": "application/json",\n "idempotency-key": batchId,\n ...this.opts.appId ? { "x-app-id": this.opts.appId } : {}\n },\n body: JSON.stringify(body),\n keepalive: false,\n signal: requestSignal\n });\n if (request.ok) {\n try {\n parsedResponse = await request.json();\n } catch (error) {\n parseError = error instanceof Error ? error : new Error(String(error));\n }\n }\n return request;\n },\n signal\n );\n res = response;\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err));\n lastError = error;\n if (error instanceof RequestDeadlineError) timedOut = true;\n if (error.name === "AbortError") break;\n this.events.onUploadFailed?.(batchId, attempt, error);\n if (noRetry || attempt === maxAttempts) break;\n await sleep(backoffMs(attempt), signal);\n continue;\n }\n lastStatus = res.status;\n timedOut = false;\n if (res.ok) {\n let parsed;\n if (keepalive) {\n try {\n parsed = await res.json();\n } catch (error) {\n parseError = error instanceof Error ? error : new Error(String(error));\n parsed = { batchId, accepted: [], rejected: [] };\n }\n } else {\n parsed = parsedResponse ?? { batchId, accepted: [], rejected: [] };\n }\n if (parseError) {\n invalidResponse = true;\n lastError = parseError;\n this.events.onUploadFailed?.(batchId, attempt, lastError);\n if (noRetry || attempt === maxAttempts) break;\n await sleep(backoffMs(attempt), signal);\n continue;\n }\n const accepted = Array.isArray(parsed.accepted) ? parsed.accepted : [];\n const rejected = Array.isArray(parsed.rejected) ? parsed.rejected : [];\n if (accepted.length === 0 && rejected.length === 0) {\n invalidResponse = true;\n lastError = new Error("malformed response: empty accepted and rejected");\n this.events.onUploadFailed?.(batchId, attempt, lastError);\n } else {\n const toAck = [...accepted, ...rejected];\n if (ackQueue) await this.opts.queue.ack(toAck);\n this.events.onUploaded?.(batchId, accepted.length, accepted, rejected);\n return {\n progress: toAck.length > 0,\n accepted,\n rejected,\n status: res.status\n };\n }\n } else if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {\n lastError = new Error(`non-retryable ${res.status}`);\n this.events.onUploadFailed?.(batchId, attempt, lastError);\n return {\n progress: false,\n accepted: [],\n rejected: [],\n error: lastError,\n status: res.status\n };\n } else {\n lastError = new Error(`retryable ${res.status}`);\n this.events.onUploadFailed?.(batchId, attempt, lastError);\n }\n if (noRetry || attempt === maxAttempts) break;\n await sleep(backoffMs(attempt), signal);\n }\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err));\n lastError = error;\n if (error.name !== "AbortError" || timedOut) {\n this.events.onUploadFailed?.(batchId, Math.max(1, maxAttempts), error);\n }\n } finally {\n if (timeout) clearTimeout(timeout);\n if (this.controller === controller) this.controller = null;\n }\n return {\n progress: false,\n accepted: [],\n rejected: [],\n ...lastError ? { error: lastError } : {},\n ...lastStatus !== void 0 ? { status: lastStatus } : {},\n ...timedOut ? { timedOut: true } : {},\n ...invalidResponse ? { invalidResponse: true } : {}\n };\n }\n}\nfunction backoffMs(attempt) {\n const base = Math.min(3e4, 500 * Math.pow(2, attempt - 1));\n const jitter = Math.random() * base * 0.25;\n return base + jitter;\n}\nfunction sleep(ms, signal) {\n return new Promise((resolve) => {\n if (signal?.aborted) {\n resolve();\n return;\n }\n const id = setTimeout(resolve, ms);\n signal?.addEventListener(\n "abort",\n () => {\n clearTimeout(id);\n resolve();\n },\n { once: true }\n );\n });\n}\nconst SDK_VERSION = "0.2.0";\nconst SDK_BUILD_SHA = "0e08674057f5d4f2e5c5fac34cd5feeaa40d97f4";\nconst SDK_BUILD_INFO = Object.freeze({\n sdkVersion: SDK_VERSION,\n buildSha: SDK_BUILD_SHA\n});\nconst DELIVERY_CANARY_TIMEOUT_MS = 8e3;\nclass WorkerCore {\n constructor(host, deps = {}) {\n this.host = host;\n this.deps = {\n queueFactory: deps.queueFactory ?? ((sessionId) => new EventQueue({ dbName: sessionDbName(sessionId) })),\n uploaderFactory: deps.uploaderFactory ?? ((queue, config) => new Uploader({\n queue,\n ingestUrl: config.ingestUrl,\n sessionId: config.sessionId,\n appId: config.appId,\n candidate: () => this.candidate,\n batchSize: config.batchSize,\n batchIntervalMs: config.batchIntervalMs,\n maxRetries: config.maxRetries\n })),\n newEventId: deps.newEventId ?? (() => newId("evt")),\n now: deps.now ?? Date.now,\n deliveryCanaryTimeoutMs: deps.deliveryCanaryTimeoutMs ?? DELIVERY_CANARY_TIMEOUT_MS\n };\n this.host.onMessage((msg) => {\n void this.handle(msg);\n });\n }\n queue = null;\n uploader = null;\n config = null;\n candidate;\n deps;\n async handle(msg) {\n switch (msg.type) {\n case "init":\n await this.init(msg.config);\n return;\n case "identify":\n this.candidate = msg.candidate;\n return;\n case "emit":\n await this.emit(msg.event);\n return;\n case "flush":\n await this.uploader?.flush({ keepalive: true });\n return;\n case "stop":\n await this.stop();\n return;\n }\n }\n async init(config) {\n try {\n this.config = config;\n this.queue = this.deps.queueFactory(config.sessionId);\n await this.queue.open();\n this.uploader = this.deps.uploaderFactory(this.queue, config);\n this.uploader.on({\n onUploaded: (batchId, count, accepted, rejected) => this.host.postMessage({\n type: "uploaded",\n batchId,\n count,\n accepted,\n rejected\n }),\n onUploadFailed: (batchId, attempt, error) => this.host.postMessage({\n type: "upload-failed",\n batchId,\n attempt,\n error: error.message\n })\n });\n const deliveryCanary = this.createEvent({\n kind: "sdk.delivery.ready",\n payload: {\n phase: "startup",\n ...SDK_BUILD_INFO\n }\n });\n await this.uploader.verifyDelivery(deliveryCanary, this.deps.deliveryCanaryTimeoutMs);\n this.uploader.start();\n this.host.postMessage({ type: "ready" });\n if (this.queue.storageMode() === "memory") {\n this.host.postMessage({\n type: "storage-fallback",\n mode: "memory",\n reason: this.queue.storageFallbackReason() ?? "IndexedDB unavailable; using in-memory queue"\n });\n }\n void EventQueue.sweepStale(/* @__PURE__ */ new Set([sessionDbName(config.sessionId)]));\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n await this.uploader?.stop().catch(() => void 0);\n await this.queue?.close().catch(() => void 0);\n this.config = null;\n this.queue = null;\n this.uploader = null;\n this.host.postMessage({\n type: "init-failed",\n error: message,\n ...err instanceof DeliveryReadinessError ? { code: err.code } : {}\n });\n }\n }\n async emit(event) {\n if (!this.queue || !this.config) return;\n const stored = this.createEvent(event);\n const { dropped } = await this.queue.enqueue(stored);\n this.host.postMessage({ type: "queued", eventId: stored.id });\n if (dropped > 0) {\n this.host.postMessage({ type: "dropped", reason: "overflow", count: dropped });\n }\n }\n createEvent(event) {\n if (!this.config) {\n throw new Error("worker is not configured");\n }\n return {\n id: event.id ?? this.deps.newEventId(),\n sessionId: this.config.sessionId,\n kind: event.kind,\n timestamp: event.timestamp ?? this.deps.now(),\n ...event.payload !== void 0 ? { payload: event.payload } : {},\n // Stamp the per-device fingerprint on every event so the\n // server can render multi-device timelines. The\n // fingerprint is computed once on the main thread and\n // forwarded via the init message; the worker never\n // recomputes it because every event in a single SDK\n // session has the same device id by definition.\n ...this.config.fingerprintId !== void 0 ? { fingerprintId: this.config.fingerprintId } : {}\n };\n }\n async stop() {\n await this.uploader?.drain();\n await this.uploader?.stop();\n const pending = await this.queue?.size() ?? 0;\n if (this.queue && pending === 0) {\n await this.queue.destroy();\n } else {\n await this.queue?.close();\n }\n this.host.postMessage({ type: "stopped" });\n }\n}\nconst ctx = self;\nnew WorkerCore({\n postMessage: (msg) => ctx.postMessage(msg),\n onMessage: (handler) => {\n ctx.addEventListener("message", (event) => {\n handler(event.data);\n });\n }\n});\n';
24390
+ const workerSource = 'const instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c);\nlet idbProxyableTypes;\nlet cursorAdvanceMethods;\nfunction getIdbProxyableTypes() {\n return idbProxyableTypes || (idbProxyableTypes = [\n IDBDatabase,\n IDBObjectStore,\n IDBIndex,\n IDBCursor,\n IDBTransaction\n ]);\n}\nfunction getCursorAdvanceMethods() {\n return cursorAdvanceMethods || (cursorAdvanceMethods = [\n IDBCursor.prototype.advance,\n IDBCursor.prototype.continue,\n IDBCursor.prototype.continuePrimaryKey\n ]);\n}\nconst transactionDoneMap = /* @__PURE__ */ new WeakMap();\nconst transformCache = /* @__PURE__ */ new WeakMap();\nconst reverseTransformCache = /* @__PURE__ */ new WeakMap();\nfunction promisifyRequest(request) {\n const promise = new Promise((resolve, reject) => {\n const unlisten = () => {\n request.removeEventListener("success", success);\n request.removeEventListener("error", error);\n };\n const success = () => {\n resolve(wrap(request.result));\n unlisten();\n };\n const error = () => {\n reject(request.error);\n unlisten();\n };\n request.addEventListener("success", success);\n request.addEventListener("error", error);\n });\n reverseTransformCache.set(promise, request);\n return promise;\n}\nfunction cacheDonePromiseForTransaction(tx) {\n if (transactionDoneMap.has(tx))\n return;\n const done = new Promise((resolve, reject) => {\n const unlisten = () => {\n tx.removeEventListener("complete", complete);\n tx.removeEventListener("error", error);\n tx.removeEventListener("abort", error);\n };\n const complete = () => {\n resolve();\n unlisten();\n };\n const error = () => {\n reject(tx.error || new DOMException("AbortError", "AbortError"));\n unlisten();\n };\n tx.addEventListener("complete", complete);\n tx.addEventListener("error", error);\n tx.addEventListener("abort", error);\n });\n transactionDoneMap.set(tx, done);\n}\nlet idbProxyTraps = {\n get(target, prop, receiver) {\n if (target instanceof IDBTransaction) {\n if (prop === "done")\n return transactionDoneMap.get(target);\n if (prop === "store") {\n return receiver.objectStoreNames[1] ? void 0 : receiver.objectStore(receiver.objectStoreNames[0]);\n }\n }\n return wrap(target[prop]);\n },\n set(target, prop, value) {\n target[prop] = value;\n return true;\n },\n has(target, prop) {\n if (target instanceof IDBTransaction && (prop === "done" || prop === "store")) {\n return true;\n }\n return prop in target;\n }\n};\nfunction replaceTraps(callback) {\n idbProxyTraps = callback(idbProxyTraps);\n}\nfunction wrapFunction(func) {\n if (getCursorAdvanceMethods().includes(func)) {\n return function(...args) {\n func.apply(unwrap(this), args);\n return wrap(this.request);\n };\n }\n return function(...args) {\n return wrap(func.apply(unwrap(this), args));\n };\n}\nfunction transformCachableValue(value) {\n if (typeof value === "function")\n return wrapFunction(value);\n if (value instanceof IDBTransaction)\n cacheDonePromiseForTransaction(value);\n if (instanceOfAny(value, getIdbProxyableTypes()))\n return new Proxy(value, idbProxyTraps);\n return value;\n}\nfunction wrap(value) {\n if (value instanceof IDBRequest)\n return promisifyRequest(value);\n if (transformCache.has(value))\n return transformCache.get(value);\n const newValue = transformCachableValue(value);\n if (newValue !== value) {\n transformCache.set(value, newValue);\n reverseTransformCache.set(newValue, value);\n }\n return newValue;\n}\nconst unwrap = (value) => reverseTransformCache.get(value);\nfunction openDB(name, version, { blocked, upgrade, blocking, terminated } = {}) {\n const request = indexedDB.open(name, version);\n const openPromise = wrap(request);\n if (upgrade) {\n request.addEventListener("upgradeneeded", (event) => {\n upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event);\n });\n }\n if (blocked) {\n request.addEventListener("blocked", (event) => blocked(\n // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405\n event.oldVersion,\n event.newVersion,\n event\n ));\n }\n openPromise.then((db) => {\n if (terminated)\n db.addEventListener("close", () => terminated());\n if (blocking) {\n db.addEventListener("versionchange", (event) => blocking(event.oldVersion, event.newVersion, event));\n }\n }).catch(() => {\n });\n return openPromise;\n}\nfunction deleteDB(name, { blocked } = {}) {\n const request = indexedDB.deleteDatabase(name);\n if (blocked) {\n request.addEventListener("blocked", (event) => blocked(\n // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405\n event.oldVersion,\n event\n ));\n }\n return wrap(request).then(() => void 0);\n}\nconst readMethods = ["get", "getKey", "getAll", "getAllKeys", "count"];\nconst writeMethods = ["put", "add", "delete", "clear"];\nconst cachedMethods = /* @__PURE__ */ new Map();\nfunction getMethod(target, prop) {\n if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === "string")) {\n return;\n }\n if (cachedMethods.get(prop))\n return cachedMethods.get(prop);\n const targetFuncName = prop.replace(/FromIndex$/, "");\n const useIndex = prop !== targetFuncName;\n const isWrite = writeMethods.includes(targetFuncName);\n if (\n // Bail if the target doesn\'t exist on the target. Eg, getAll isn\'t in Edge.\n !(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))\n ) {\n return;\n }\n const method = async function(storeName, ...args) {\n const tx = this.transaction(storeName, isWrite ? "readwrite" : "readonly");\n let target2 = tx.store;\n if (useIndex)\n target2 = target2.index(args.shift());\n return (await Promise.all([\n target2[targetFuncName](...args),\n isWrite && tx.done\n ]))[0];\n };\n cachedMethods.set(prop, method);\n return method;\n}\nreplaceTraps((oldTraps) => ({\n ...oldTraps,\n get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver),\n has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop)\n}));\nconst advanceMethodProps = ["continue", "continuePrimaryKey", "advance"];\nconst methodMap = {};\nconst advanceResults = /* @__PURE__ */ new WeakMap();\nconst ittrProxiedCursorToOriginalProxy = /* @__PURE__ */ new WeakMap();\nconst cursorIteratorTraps = {\n get(target, prop) {\n if (!advanceMethodProps.includes(prop))\n return target[prop];\n let cachedFunc = methodMap[prop];\n if (!cachedFunc) {\n cachedFunc = methodMap[prop] = function(...args) {\n advanceResults.set(this, ittrProxiedCursorToOriginalProxy.get(this)[prop](...args));\n };\n }\n return cachedFunc;\n }\n};\nasync function* iterate(...args) {\n let cursor = this;\n if (!(cursor instanceof IDBCursor)) {\n cursor = await cursor.openCursor(...args);\n }\n if (!cursor)\n return;\n cursor = cursor;\n const proxiedCursor = new Proxy(cursor, cursorIteratorTraps);\n ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor);\n reverseTransformCache.set(proxiedCursor, unwrap(cursor));\n while (cursor) {\n yield proxiedCursor;\n cursor = await (advanceResults.get(proxiedCursor) || cursor.continue());\n advanceResults.delete(proxiedCursor);\n }\n}\nfunction isIteratorProp(target, prop) {\n return prop === Symbol.asyncIterator && instanceOfAny(target, [IDBIndex, IDBObjectStore, IDBCursor]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]);\n}\nreplaceTraps((oldTraps) => ({\n ...oldTraps,\n get(target, prop, receiver) {\n if (isIteratorProp(target, prop))\n return iterate;\n return oldTraps.get(target, prop, receiver);\n },\n has(target, prop) {\n return isIteratorProp(target, prop) || oldTraps.has(target, prop);\n }\n}));\nconst DEFAULT_DB_NAME = "proctoring-sdk";\nconst DEFAULT_MAX_BYTES = 50 * 1024 * 1024;\nconst SEQ_KEY = "seq";\nconst BYTES_KEY = "bytes";\nconst SESSION_DB_PREFIX = "proctoring-sess";\nfunction sessionDbName(sessionId) {\n return `proctoring-${sessionId}`;\n}\nclass EventQueue {\n db = null;\n memory = null;\n dbName;\n maxBytes;\n storage;\n fallbackToMemory;\n constructor(options = {}) {\n this.dbName = options.dbName ?? DEFAULT_DB_NAME;\n this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;\n this.storage = options.storage ?? "indexeddb";\n this.fallbackToMemory = options.fallbackToMemory ?? true;\n }\n /** Opens (or upgrades) the underlying IndexedDB database. Must be called once before any other method. Idempotent. */\n async open() {\n if (this.db || this.memory) return;\n if (this.storage === "memory") {\n this.openMemory("configured-memory-storage");\n return;\n }\n try {\n this.db = await openDB(this.dbName, 1, {\n upgrade(db) {\n const events = db.createObjectStore("events", { keyPath: "id" });\n events.createIndex("by-seq", "seq");\n db.createObjectStore("meta", { keyPath: "key" });\n }\n });\n } catch (err) {\n if (!this.fallbackToMemory) throw err;\n this.openMemory(indexedDbOpenFailureReason(err));\n }\n }\n storageMode() {\n return this.memory ? "memory" : "indexeddb";\n }\n storageFallbackReason() {\n return this.memory?.reason ?? null;\n }\n /**\n * Appends an event to the tail. If the queue would exceed\n * `maxBytes`, drops the oldest events until it fits — the new\n * event is never the one dropped. Returns the count of dropped\n * events so callers can surface backpressure to the host.\n */\n async enqueue(event) {\n if (this.memory) return this.enqueueMemory(event);\n const db = this.requireDb();\n const bytes = byteLength(event);\n const tx = db.transaction(["events", "meta"], "readwrite");\n const events = tx.objectStore("events");\n const meta = tx.objectStore("meta");\n const seq = ((await meta.get(SEQ_KEY))?.value ?? 0) + 1;\n let totalBytes = ((await meta.get(BYTES_KEY))?.value ?? 0) + bytes;\n await events.put({ ...event, seq, bytes });\n await meta.put({ key: SEQ_KEY, value: seq });\n let dropped = 0;\n if (totalBytes > this.maxBytes) {\n const cursor = await events.index("by-seq").openCursor();\n let c = cursor;\n while (c && totalBytes > this.maxBytes) {\n const stored = c.value;\n if (stored.id === event.id) {\n c = await c.continue();\n continue;\n }\n totalBytes -= stored.bytes;\n dropped += 1;\n await c.delete();\n c = await c.continue();\n }\n }\n await meta.put({ key: BYTES_KEY, value: totalBytes });\n await tx.done;\n return { dropped };\n }\n /** Returns up to `limit` oldest events without removing them. */\n async peek(limit) {\n if (this.memory) {\n return this.memory.events.slice(0, limit).map(({ seq: _seq, bytes: _bytes, ...event }) => event);\n }\n const db = this.requireDb();\n const tx = db.transaction("events", "readonly");\n const out = [];\n let cursor = await tx.store.index("by-seq").openCursor();\n while (cursor && out.length < limit) {\n const { seq: _seq, bytes: _bytes, ...event } = cursor.value;\n out.push(event);\n cursor = await cursor.continue();\n }\n return out;\n }\n /**\n * Removes events by id. Unknown ids are silently skipped so callers\n * can safely ack the same id twice (eg. when an idempotency-keyed\n * server retry returns success after we already acked locally).\n */\n async ack(ids) {\n if (ids.length === 0) return;\n if (this.memory) {\n const idSet = new Set(ids);\n let bytes = this.memory.bytes;\n this.memory.events = this.memory.events.filter((event) => {\n if (!idSet.has(event.id)) return true;\n bytes -= event.bytes;\n return false;\n });\n this.memory.bytes = Math.max(0, bytes);\n return;\n }\n const db = this.requireDb();\n const tx = db.transaction(["events", "meta"], "readwrite");\n const events = tx.objectStore("events");\n const meta = tx.objectStore("meta");\n let totalBytes = (await meta.get(BYTES_KEY))?.value ?? 0;\n for (const id of ids) {\n const stored = await events.get(id);\n if (!stored) continue;\n totalBytes -= stored.bytes;\n await events.delete(id);\n }\n await meta.put({ key: BYTES_KEY, value: Math.max(0, totalBytes) });\n await tx.done;\n }\n /** Number of events currently queued. */\n async size() {\n if (this.memory) return this.memory.events.length;\n const db = this.requireDb();\n return db.count("events");\n }\n /** Cumulative byte size of all queued events (JSON-encoded). */\n async bytes() {\n if (this.memory) return this.memory.bytes;\n const db = this.requireDb();\n return (await db.get("meta", BYTES_KEY))?.value ?? 0;\n }\n /** Closes the database handle. Safe to call multiple times. */\n async close() {\n this.db?.close();\n this.db = null;\n this.memory = null;\n }\n /**\n * Closes the handle AND deletes the underlying database from disk.\n *\n * Call this only once the queue is known to be fully drained — a\n * deleted database takes any not-yet-uploaded events with it. The\n * worker gates this on `size() === 0` after draining at teardown, so\n * an offline teardown (events still queued) keeps the database for a\n * later page load to flush. Reclaiming the database is what stops the\n * per-session databases accumulating until the origin hits its\n * storage quota and `open()` starts throwing.\n */\n async destroy() {\n if (this.memory) {\n await this.close();\n return;\n }\n await this.close();\n await deleteDB(this.dbName);\n }\n /**\n * Best-effort reclamation of leaked per-session databases left by\n * earlier sessions on this origin. Enumerates all IndexedDB\n * databases, and for each `proctoring-sess-*` one NOT in `keep`,\n * opens it, checks it holds zero events, and deletes it if so. A\n * database with pending events is left alone — it may still flush on\n * a future load.\n *\n * No-ops where `indexedDB.databases()` is unavailable (Firefox, older\n * Safari): there is no enumeration API there, so leaked databases\n * can\'t be discovered. New sessions still self-clean via destroy(),\n * so the leak is bounded going forward even on those browsers.\n *\n * Never throws — cleanup must not be able to fail a session.\n */\n static async sweepStale(keep = /* @__PURE__ */ new Set()) {\n let removed = 0;\n try {\n if (typeof indexedDB === "undefined" || typeof indexedDB.databases !== "function") {\n return 0;\n }\n const entries = await indexedDB.databases();\n for (const { name } of entries) {\n if (!name || keep.has(name) || !name.startsWith(SESSION_DB_PREFIX)) {\n continue;\n }\n try {\n const queue = new EventQueue({ dbName: name });\n await queue.open();\n const pending = await queue.size();\n await queue.close();\n if (pending === 0) {\n await deleteDB(name);\n removed += 1;\n }\n } catch {\n }\n }\n } catch {\n }\n return removed;\n }\n requireDb() {\n if (!this.db) {\n throw new Error("EventQueue: call open() before any other operation");\n }\n return this.db;\n }\n openMemory(reason) {\n this.db = null;\n this.memory = {\n events: [],\n seq: 0,\n bytes: 0,\n reason\n };\n }\n enqueueMemory(event) {\n if (!this.memory) {\n throw new Error("EventQueue: call open() before any other operation");\n }\n const bytes = byteLength(event);\n const stored = {\n ...event,\n seq: this.memory.seq + 1,\n bytes\n };\n this.memory.seq = stored.seq;\n this.memory.events.push(stored);\n this.memory.bytes += bytes;\n let dropped = 0;\n while (this.memory.events.length > 1 && this.memory.bytes > this.maxBytes) {\n const oldest = this.memory.events[0];\n if (!oldest || oldest.id === event.id) break;\n this.memory.events.shift();\n this.memory.bytes -= oldest.bytes;\n dropped += 1;\n }\n return { dropped };\n }\n}\nfunction byteLength(event) {\n return new TextEncoder().encode(JSON.stringify(event)).byteLength;\n}\nfunction indexedDbOpenFailureReason(err) {\n if (err instanceof Error) {\n return `${err.name}: ${err.message}`;\n }\n return String(err);\n}\nclass DeliveryReadinessError extends Error {\n code;\n status;\n /** Structured operator diagnostics. Never render this object in candidate UI. */\n details;\n constructor(code, message, options) {\n super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);\n this.name = "DeliveryReadinessError";\n this.code = code;\n this.status = options?.status;\n this.details = options?.details;\n }\n}\nfunction newId(prefix) {\n if (typeof crypto !== "undefined" && "randomUUID" in crypto) {\n return crypto.randomUUID();\n }\n return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;\n}\nconst EVENT_REQUEST_TIMEOUT_MS = 1e4;\nclass RequestDeadlineError extends Error {\n constructor(operation, timeoutMs) {\n super(`${operation} timed out after ${timeoutMs}ms`);\n this.operation = operation;\n this.timeoutMs = timeoutMs;\n this.name = "RequestDeadlineError";\n }\n code = "request-timeout";\n}\nasync function withRequestDeadline(operation, timeoutMs, run, parentSignal) {\n if (parentSignal?.aborted) throw abortError();\n const controller = new AbortController();\n let timedOut = false;\n let timer;\n let onParentAbort;\n const cancelled = new Promise((_resolve, reject) => {\n onParentAbort = () => {\n controller.abort();\n reject(abortError());\n };\n parentSignal?.addEventListener("abort", onParentAbort, { once: true });\n timer = setTimeout(\n () => {\n timedOut = true;\n controller.abort();\n reject(new RequestDeadlineError(operation, timeoutMs));\n },\n Math.max(0, timeoutMs)\n );\n });\n try {\n return await Promise.race([Promise.resolve().then(() => run(controller.signal)), cancelled]);\n } catch (error) {\n if (timedOut) throw new RequestDeadlineError(operation, timeoutMs);\n throw error;\n } finally {\n if (timer !== void 0) clearTimeout(timer);\n if (onParentAbort) parentSignal?.removeEventListener("abort", onParentAbort);\n }\n}\nfunction abortError() {\n const error = new Error("The request was aborted");\n error.name = "AbortError";\n return error;\n}\nclass Uploader {\n opts;\n events = {};\n timer = null;\n draining = false;\n stopped = false;\n controller = null;\n constructor(options) {\n this.opts = {\n queue: options.queue,\n ingestUrl: options.ingestUrl,\n sessionId: options.sessionId,\n batchSize: options.batchSize ?? 50,\n batchIntervalMs: options.batchIntervalMs ?? 2e3,\n maxRetries: options.maxRetries ?? 5,\n fetchImpl: options.fetchImpl ?? fetch.bind(globalThis),\n newBatchId: options.newBatchId ?? (() => newId("batch")),\n appId: options.appId ?? null,\n candidate: options.candidate ?? (() => void 0)\n };\n }\n /** Replaces the lifecycle handlers. The Uploader keeps a single set; pass `{}` to clear. */\n on(events) {\n this.events = events;\n }\n /** Starts the periodic drain timer. No-op if already started or stopped. */\n start() {\n if (this.timer || this.stopped) return;\n this.timer = setInterval(() => {\n void this.drain();\n }, this.opts.batchIntervalMs);\n }\n /**\n * Drains the queue in batches until empty, no progress was made, or\n * a send fails. Re-entrant calls are guarded — only one drain runs\n * at a time even under concurrent invocations.\n */\n async drain() {\n if (this.draining || this.stopped) return;\n this.draining = true;\n try {\n while (!this.stopped) {\n const events = await this.opts.queue.peek(this.opts.batchSize);\n if (events.length === 0) return;\n const sizeBefore = await this.opts.queue.size();\n const result = await this.sendBatch(events, { keepalive: false });\n if (!result.progress) return;\n const sizeAfter = await this.opts.queue.size();\n if (sizeAfter >= sizeBefore) return;\n }\n } finally {\n this.draining = false;\n }\n }\n /**\n * Sends one batch. With `keepalive: true` the request survives page\n * navigation but is capped by the browser to ~64KB total in-flight;\n * we therefore send at most one batch and do not retry on failure.\n */\n async flush(options) {\n const events = await this.opts.queue.peek(this.opts.batchSize);\n if (events.length === 0) return;\n await this.sendBatch(events, { keepalive: options.keepalive, noRetry: options.keepalive });\n }\n /**\n * Prove that the real ingest route accepts an event before SDK readiness is\n * announced. This uses the same request builder, auth headers, response\n * validation, and retry path as normal queue delivery. It is intentionally\n * not configurable by SDK consumers.\n */\n async verifyDelivery(event, timeoutMs) {\n const result = await this.sendBatch([event], {\n keepalive: false,\n ackQueue: false,\n maxAttempts: 2,\n timeoutMs\n });\n if (result.accepted.includes(event.id)) return;\n if (result.rejected.includes(event.id)) {\n throw new DeliveryReadinessError(\n "ingest-rejected",\n "The evidence ingest route rejected the delivery canary.",\n { status: result.status }\n );\n }\n if (result.timedOut) {\n throw new DeliveryReadinessError(\n "ingest-timeout",\n "Timed out while verifying evidence delivery.",\n { cause: result.error }\n );\n }\n if (result.status === 401 || result.status === 403) {\n throw new DeliveryReadinessError(\n "ingest-not-authorized",\n "The evidence ingest route did not authorize this application and origin.",\n { status: result.status, cause: result.error }\n );\n }\n if (result.status !== void 0 && result.status >= 400 && result.status < 500) {\n throw new DeliveryReadinessError(\n "ingest-rejected",\n "The evidence ingest route rejected the delivery canary.",\n { status: result.status, cause: result.error }\n );\n }\n if (result.invalidResponse) {\n throw new DeliveryReadinessError(\n "ingest-invalid-response",\n "The evidence ingest route returned an invalid acknowledgement.",\n { cause: result.error }\n );\n }\n throw new DeliveryReadinessError(\n "ingest-unreachable",\n "The evidence ingest route could not be reached.",\n { cause: result.error, status: result.status }\n );\n }\n /** Stops the drain timer and aborts any in-flight non-keepalive request. */\n async stop() {\n this.stopped = true;\n if (this.timer) {\n clearInterval(this.timer);\n this.timer = null;\n }\n this.controller?.abort();\n }\n async sendBatch(events, {\n keepalive,\n noRetry = false,\n ackQueue = true,\n maxAttempts = this.opts.maxRetries,\n timeoutMs\n }) {\n const batchId = this.opts.newBatchId();\n const candidate = this.opts.candidate();\n const body = {\n batchId,\n sessionId: this.opts.sessionId,\n events,\n ...candidate ? { candidate } : {}\n };\n const controller = keepalive ? null : new AbortController();\n this.controller = controller;\n const signal = controller?.signal;\n let timedOut = false;\n const deadlineAt = timeoutMs === void 0 ? void 0 : Date.now() + timeoutMs;\n const timeout = controller && timeoutMs !== void 0 ? setTimeout(() => {\n timedOut = true;\n controller.abort();\n }, timeoutMs) : null;\n let lastError;\n let lastStatus;\n let invalidResponse = false;\n try {\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n if (this.stopped && !keepalive) {\n return { progress: false, accepted: [], rejected: [] };\n }\n let res;\n let parsedResponse;\n let parseError;\n try {\n const attemptTimeoutMs = deadlineAt === void 0 ? EVENT_REQUEST_TIMEOUT_MS : Math.max(1, Math.min(EVENT_REQUEST_TIMEOUT_MS, deadlineAt - Date.now()));\n const response = keepalive ? await this.opts.fetchImpl(this.opts.ingestUrl, {\n method: "POST",\n headers: {\n "content-type": "application/json",\n "idempotency-key": batchId,\n ...this.opts.appId ? { "x-app-id": this.opts.appId } : {}\n },\n body: JSON.stringify(body),\n keepalive\n }) : await withRequestDeadline(\n "event-ingest",\n attemptTimeoutMs,\n async (requestSignal) => {\n const request = await this.opts.fetchImpl(this.opts.ingestUrl, {\n method: "POST",\n headers: {\n "content-type": "application/json",\n "idempotency-key": batchId,\n ...this.opts.appId ? { "x-app-id": this.opts.appId } : {}\n },\n body: JSON.stringify(body),\n keepalive: false,\n signal: requestSignal\n });\n if (request.ok) {\n try {\n parsedResponse = await request.json();\n } catch (error) {\n parseError = error instanceof Error ? error : new Error(String(error));\n }\n }\n return request;\n },\n signal\n );\n res = response;\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err));\n lastError = error;\n if (error instanceof RequestDeadlineError) timedOut = true;\n if (error.name === "AbortError") break;\n this.events.onUploadFailed?.(batchId, attempt, error);\n if (noRetry || attempt === maxAttempts) break;\n await sleep(backoffMs(attempt), signal);\n continue;\n }\n lastStatus = res.status;\n timedOut = false;\n if (res.ok) {\n let parsed;\n if (keepalive) {\n try {\n parsed = await res.json();\n } catch (error) {\n parseError = error instanceof Error ? error : new Error(String(error));\n parsed = { batchId, accepted: [], rejected: [] };\n }\n } else {\n parsed = parsedResponse ?? { batchId, accepted: [], rejected: [] };\n }\n if (parseError) {\n invalidResponse = true;\n lastError = parseError;\n this.events.onUploadFailed?.(batchId, attempt, lastError);\n if (noRetry || attempt === maxAttempts) break;\n await sleep(backoffMs(attempt), signal);\n continue;\n }\n const accepted = Array.isArray(parsed.accepted) ? parsed.accepted : [];\n const rejected = Array.isArray(parsed.rejected) ? parsed.rejected : [];\n if (accepted.length === 0 && rejected.length === 0) {\n invalidResponse = true;\n lastError = new Error("malformed response: empty accepted and rejected");\n this.events.onUploadFailed?.(batchId, attempt, lastError);\n } else {\n const toAck = [...accepted, ...rejected];\n if (ackQueue) await this.opts.queue.ack(toAck);\n this.events.onUploaded?.(batchId, accepted.length, accepted, rejected);\n return {\n progress: toAck.length > 0,\n accepted,\n rejected,\n status: res.status\n };\n }\n } else if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {\n lastError = new Error(`non-retryable ${res.status}`);\n this.events.onUploadFailed?.(batchId, attempt, lastError);\n return {\n progress: false,\n accepted: [],\n rejected: [],\n error: lastError,\n status: res.status\n };\n } else {\n lastError = new Error(`retryable ${res.status}`);\n this.events.onUploadFailed?.(batchId, attempt, lastError);\n }\n if (noRetry || attempt === maxAttempts) break;\n await sleep(backoffMs(attempt), signal);\n }\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err));\n lastError = error;\n if (error.name !== "AbortError" || timedOut) {\n this.events.onUploadFailed?.(batchId, Math.max(1, maxAttempts), error);\n }\n } finally {\n if (timeout) clearTimeout(timeout);\n if (this.controller === controller) this.controller = null;\n }\n return {\n progress: false,\n accepted: [],\n rejected: [],\n ...lastError ? { error: lastError } : {},\n ...lastStatus !== void 0 ? { status: lastStatus } : {},\n ...timedOut ? { timedOut: true } : {},\n ...invalidResponse ? { invalidResponse: true } : {}\n };\n }\n}\nfunction backoffMs(attempt) {\n const base = Math.min(3e4, 500 * Math.pow(2, attempt - 1));\n const jitter = Math.random() * base * 0.25;\n return base + jitter;\n}\nfunction sleep(ms, signal) {\n return new Promise((resolve) => {\n if (signal?.aborted) {\n resolve();\n return;\n }\n const id = setTimeout(resolve, ms);\n signal?.addEventListener(\n "abort",\n () => {\n clearTimeout(id);\n resolve();\n },\n { once: true }\n );\n });\n}\nconst SDK_VERSION = "0.2.2";\nconst SDK_BUILD_SHA = "73e86966ca237da4aa670a95f79166d8c286f742";\nconst SDK_BUILD_INFO = Object.freeze({\n sdkVersion: SDK_VERSION,\n buildSha: SDK_BUILD_SHA\n});\nconst DELIVERY_CANARY_TIMEOUT_MS = 8e3;\nclass WorkerCore {\n constructor(host, deps = {}) {\n this.host = host;\n this.deps = {\n queueFactory: deps.queueFactory ?? ((sessionId) => new EventQueue({ dbName: sessionDbName(sessionId) })),\n uploaderFactory: deps.uploaderFactory ?? ((queue, config) => new Uploader({\n queue,\n ingestUrl: config.ingestUrl,\n sessionId: config.sessionId,\n appId: config.appId,\n candidate: () => this.candidate,\n batchSize: config.batchSize,\n batchIntervalMs: config.batchIntervalMs,\n maxRetries: config.maxRetries\n })),\n newEventId: deps.newEventId ?? (() => newId("evt")),\n now: deps.now ?? Date.now,\n deliveryCanaryTimeoutMs: deps.deliveryCanaryTimeoutMs ?? DELIVERY_CANARY_TIMEOUT_MS\n };\n this.host.onMessage((msg) => {\n void this.handle(msg);\n });\n }\n queue = null;\n uploader = null;\n config = null;\n candidate;\n deps;\n async handle(msg) {\n switch (msg.type) {\n case "init":\n await this.init(msg.config);\n return;\n case "identify":\n this.candidate = msg.candidate;\n return;\n case "emit":\n await this.emit(msg.event);\n return;\n case "flush":\n await this.uploader?.flush({ keepalive: true });\n return;\n case "stop":\n await this.stop();\n return;\n }\n }\n async init(config) {\n try {\n this.config = config;\n this.queue = this.deps.queueFactory(config.sessionId);\n await this.queue.open();\n this.uploader = this.deps.uploaderFactory(this.queue, config);\n this.uploader.on({\n onUploaded: (batchId, count, accepted, rejected) => this.host.postMessage({\n type: "uploaded",\n batchId,\n count,\n accepted,\n rejected\n }),\n onUploadFailed: (batchId, attempt, error) => this.host.postMessage({\n type: "upload-failed",\n batchId,\n attempt,\n error: error.message\n })\n });\n const deliveryCanary = this.createEvent({\n kind: "sdk.delivery.ready",\n payload: {\n phase: "startup",\n ...SDK_BUILD_INFO\n }\n });\n await this.uploader.verifyDelivery(deliveryCanary, this.deps.deliveryCanaryTimeoutMs);\n this.uploader.start();\n this.host.postMessage({ type: "ready" });\n if (this.queue.storageMode() === "memory") {\n this.host.postMessage({\n type: "storage-fallback",\n mode: "memory",\n reason: this.queue.storageFallbackReason() ?? "IndexedDB unavailable; using in-memory queue"\n });\n }\n void EventQueue.sweepStale(/* @__PURE__ */ new Set([sessionDbName(config.sessionId)]));\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n await this.uploader?.stop().catch(() => void 0);\n await this.queue?.close().catch(() => void 0);\n this.config = null;\n this.queue = null;\n this.uploader = null;\n this.host.postMessage({\n type: "init-failed",\n error: message,\n ...err instanceof DeliveryReadinessError ? { code: err.code } : {}\n });\n }\n }\n async emit(event) {\n if (!this.queue || !this.config) return;\n const stored = this.createEvent(event);\n const { dropped } = await this.queue.enqueue(stored);\n this.host.postMessage({ type: "queued", eventId: stored.id });\n if (dropped > 0) {\n this.host.postMessage({ type: "dropped", reason: "overflow", count: dropped });\n }\n }\n createEvent(event) {\n if (!this.config) {\n throw new Error("worker is not configured");\n }\n return {\n id: event.id ?? this.deps.newEventId(),\n sessionId: this.config.sessionId,\n kind: event.kind,\n timestamp: event.timestamp ?? this.deps.now(),\n ...event.payload !== void 0 ? { payload: event.payload } : {},\n // Stamp the per-device fingerprint on every event so the\n // server can render multi-device timelines. The\n // fingerprint is computed once on the main thread and\n // forwarded via the init message; the worker never\n // recomputes it because every event in a single SDK\n // session has the same device id by definition.\n ...this.config.fingerprintId !== void 0 ? { fingerprintId: this.config.fingerprintId } : {}\n };\n }\n async stop() {\n await this.uploader?.drain();\n await this.uploader?.stop();\n const pending = await this.queue?.size() ?? 0;\n if (this.queue && pending === 0) {\n await this.queue.destroy();\n } else {\n await this.queue?.close();\n }\n this.host.postMessage({ type: "stopped" });\n }\n}\nconst ctx = self;\nnew WorkerCore({\n postMessage: (msg) => ctx.postMessage(msg),\n onMessage: (handler) => {\n ctx.addEventListener("message", (event) => {\n handler(event.data);\n });\n }\n});\n';
24391
24391
  class BlobWorker {
24392
24392
  url;
24393
24393
  worker;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4anthony/proctorkit-vue",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Vue 3 components for the proctoring SDK — pre-flight system check wizard.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,7 @@
25
25
  "@ricky0123/vad-web": "^0.0.30",
26
26
  "vue-i18n": "^11.1.0",
27
27
  "@a4anthony/proctorkit-content": "0.1.0",
28
- "@a4anthony/proctorkit-sdk": "0.2.0"
28
+ "@a4anthony/proctorkit-sdk": "0.2.2"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@storybook/vue3-vite": "^10.4.0",