@usero/sdk 0.3.1 → 0.3.4
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.
- package/dist/plugins/session-replay.cjs +324 -67
- package/dist/plugins/session-replay.cjs.map +1 -1
- package/dist/plugins/session-replay.d.cts +33 -11
- package/dist/plugins/session-replay.d.ts +33 -11
- package/dist/plugins/session-replay.js +324 -68
- package/dist/plugins/session-replay.js.map +1 -1
- package/dist/plugins/user-test.cjs +528 -0
- package/dist/plugins/user-test.cjs.map +1 -0
- package/dist/plugins/user-test.d.cts +63 -0
- package/dist/plugins/user-test.d.ts +63 -0
- package/dist/plugins/user-test.js +525 -0
- package/dist/plugins/user-test.js.map +1 -0
- package/dist/react.cjs +12 -0
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +2 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +12 -0
- package/dist/react.js.map +1 -1
- package/dist/usero.iife.js +27 -27
- package/dist/usero.iife.js.map +1 -1
- package/dist/vanilla.cjs +12 -0
- package/dist/vanilla.cjs.map +1 -1
- package/dist/vanilla.d.cts +2 -0
- package/dist/vanilla.d.ts +2 -0
- package/dist/vanilla.js +12 -0
- package/dist/vanilla.js.map +1 -1
- package/package.json +6 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/session-replay.ts"],"names":[],"mappings":"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","file":"session-replay.js","sourcesContent":["// Session replay plugin for the Usero widget.\n//\n// Lazy-loads rrweb the first time it's actually needed and keeps a rolling\n// in-memory buffer of the last `bufferSeconds` of events. On feedback\n// submit, the buffer is gzipped via the native CompressionStream API and\n// attached as `replayEvents` (base64 string) on the outgoing payload.\n//\n// Bundle hygiene is the whole point of this plugin existing as its own\n// subpath export. Importing this module pulls in the small wrapper below;\n// the heavy rrweb dependency only loads at runtime via dynamic `import()`.\n//\n// Privacy defaults err on the side of safety: all <input>/<textarea> values\n// are masked, anything tagged `data-usero-mask` is masked too, and inline\n// styles are inlined so we never leak external stylesheet URLs.\n\nimport type { UseroPlugin, PluginContext } from '../plugin'\nimport type { FeedbackSubmission } from '../types'\n\n// We deliberately avoid importing rrweb's types at the top level — that\n// would force consumers to install rrweb as a peer dep and would also pull\n// the type declarations into our published .d.ts files. Plugin internals\n// use a minimal local shape and rely on rrweb's runtime API only.\n\n// rrweb event sample-rate map. Keys match rrweb's `sampling` option. See\n// https://github.com/rrweb-io/rrweb/blob/master/guide.md#options for the\n// full list. We expose the two that matter most (mousemove, scroll).\nexport interface ReplaySampling {\n\t// Capture a mousemove event at most every N ms (default 50).\n\tmousemove?: number\n\t// Capture a scroll event at most every N ms (default 100).\n\tscroll?: number\n\t// Capture a media-interaction event at most every N ms.\n\tmedia?: number\n\t// Capture an input event at most every N ms, or 'last' to keep only the\n\t// final input value per element.\n\tinput?: number | 'last'\n}\n\nexport interface SessionReplayOptions {\n\t// Length of the rolling buffer in seconds. Older events are evicted as\n\t// new ones arrive. Default 30.\n\tbufferSeconds?: number\n\t// Wait this many ms after page load before loading rrweb and starting\n\t// to record. If the user navigates away before this elapses, rrweb is\n\t// never loaded. Default 0 (start immediately).\n\tstartAfterMs?: number\n\t// Probability (0..1) that this session records at all. Decided once at\n\t// init via Math.random(). Sessions that lose the dice roll never load\n\t// rrweb. Default 1 (always record).\n\tsampleRate?: number\n\t// rrweb sampling rates per event type. See ReplaySampling above.\n\tsampling?: ReplaySampling\n\t// Mask all <input>/<textarea> values in the recording. Default true.\n\tmaskAllInputs?: boolean\n\t// CSS selector for nodes whose text content should be masked. Default\n\t// `[data-usero-mask]`. Pass an empty string to disable selector masking.\n\tmaskTextSelector?: string\n\t// Inline external stylesheets into the recording so the replay viewer\n\t// renders correctly without network access. Default true.\n\tinlineStylesheet?: boolean\n\t// Block (entirely skip) DOM subtrees matching this selector. Default\n\t// `[data-usero-block]`.\n\tblockSelector?: string\n}\n\ninterface RrwebEvent {\n\ttype: number\n\tdata: unknown\n\ttimestamp: number\n}\n\ninterface RrwebRecordOptions {\n\temit: (event: RrwebEvent) => void\n\tmaskAllInputs?: boolean\n\tmaskTextSelector?: string\n\tinlineStylesheet?: boolean\n\tblockSelector?: string\n\tsampling?: ReplaySampling\n}\n\ntype RrwebRecord = (opts: RrwebRecordOptions) => () => void\n\ninterface ReplayStore {\n\tevents: RrwebEvent[]\n\tstopRecording: (() => void) | null\n\tstartTimer: ReturnType<typeof setTimeout> | null\n\tpageHideHandler: (() => void) | null\n\tloadInProgress: boolean\n\tcancelled: boolean\n\toptions: Required<Omit<SessionReplayOptions, 'sampling'>> & { sampling: ReplaySampling }\n}\n\nconst DEFAULT_OPTIONS: Required<Omit<SessionReplayOptions, 'sampling'>> & {\n\tsampling: ReplaySampling\n} = {\n\tbufferSeconds: 30,\n\tstartAfterMs: 0,\n\tsampleRate: 1,\n\tsampling: { mousemove: 50, scroll: 100 },\n\tmaskAllInputs: true,\n\tmaskTextSelector: '[data-usero-mask]',\n\tinlineStylesheet: true,\n\tblockSelector: '[data-usero-block]',\n}\n\nfunction evictOldEvents(events: RrwebEvent[], bufferSeconds: number, now: number): void {\n\tif (events.length === 0) return\n\tconst cutoff = now - bufferSeconds * 1000\n\t// Events are appended in chronological order; find the first event\n\t// inside the window with a linear scan from the head and splice once.\n\tlet dropCount = 0\n\tfor (const e of events) {\n\t\tif (e.timestamp >= cutoff) break\n\t\tdropCount++\n\t}\n\tif (dropCount > 0) events.splice(0, dropCount)\n}\n\n// Convert a Uint8Array to base64 without bringing in a dependency. Chunked\n// to avoid blowing the call stack on large buffers.\nfunction uint8ToBase64(bytes: Uint8Array): string {\n\tlet binary = ''\n\tconst chunkSize = 0x8000\n\tfor (let i = 0; i < bytes.length; i += chunkSize) {\n\t\tconst chunk = bytes.subarray(i, i + chunkSize)\n\t\tbinary += String.fromCharCode.apply(null, Array.from(chunk))\n\t}\n\treturn typeof btoa === 'function' ? btoa(binary) : ''\n}\n\nasync function gzipString(input: string): Promise<string> {\n\tif (typeof CompressionStream === 'undefined') {\n\t\t// Browsers without CompressionStream (very old) fall back to raw\n\t\t// base64 of the JSON. The server can detect this by sniffing the\n\t\t// gzip magic bytes; cheaper than shipping a JS gzip lib.\n\t\tconst bytes = new TextEncoder().encode(input)\n\t\treturn uint8ToBase64(bytes)\n\t}\n\tconst stream = new Blob([input]).stream().pipeThrough(new CompressionStream('gzip'))\n\tconst compressed = await new Response(stream).arrayBuffer()\n\treturn uint8ToBase64(new Uint8Array(compressed))\n}\n\nasync function loadRrwebRecord(): Promise<RrwebRecord | null> {\n\ttry {\n\t\tconst mod: unknown = await import(/* webpackChunkName: \"rrweb\" */ 'rrweb')\n\t\tif (\n\t\t\tmod &&\n\t\t\ttypeof mod === 'object' &&\n\t\t\t'record' in mod &&\n\t\t\ttypeof (mod as { record: unknown }).record === 'function'\n\t\t) {\n\t\t\treturn (mod as { record: RrwebRecord }).record\n\t\t}\n\t\treturn null\n\t} catch {\n\t\treturn null\n\t}\n}\n\nfunction startRecording(store: ReplayStore, ctx: PluginContext): void {\n\tif (store.cancelled || store.stopRecording || store.loadInProgress) return\n\tstore.loadInProgress = true\n\tvoid loadRrwebRecord().then(record => {\n\t\tstore.loadInProgress = false\n\t\t// Window may have been torn down between the import resolving and\n\t\t// us getting here. Don't start a recording into a destroyed plugin.\n\t\tif (store.cancelled || !record) {\n\t\t\tif (!record) ctx.logger.warn('rrweb failed to load, replay disabled')\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\tconst stop = record({\n\t\t\t\temit: event => {\n\t\t\t\t\tstore.events.push(event)\n\t\t\t\t\tevictOldEvents(store.events, store.options.bufferSeconds, event.timestamp)\n\t\t\t\t},\n\t\t\t\tmaskAllInputs: store.options.maskAllInputs,\n\t\t\t\tmaskTextSelector: store.options.maskTextSelector || undefined,\n\t\t\t\tinlineStylesheet: store.options.inlineStylesheet,\n\t\t\t\tblockSelector: store.options.blockSelector,\n\t\t\t\tsampling: store.options.sampling,\n\t\t\t})\n\t\t\tstore.stopRecording = stop\n\t\t} catch (err) {\n\t\t\tctx.logger.error('rrweb record() threw', err)\n\t\t}\n\t})\n}\n\nexport function sessionReplay(options: SessionReplayOptions = {}): UseroPlugin {\n\tconst merged = {\n\t\t...DEFAULT_OPTIONS,\n\t\t...options,\n\t\tsampling: { ...DEFAULT_OPTIONS.sampling, ...(options.sampling ?? {}) },\n\t}\n\n\treturn {\n\t\tname: 'session-replay',\n\t\tonInit(ctx) {\n\t\t\t// Lose the dice roll? Don't even prepare to load rrweb.\n\t\t\tif (merged.sampleRate < 1 && Math.random() >= merged.sampleRate) {\n\t\t\t\tctx.logger.debug('skipped by sampleRate')\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (typeof window === 'undefined') return\n\n\t\t\tconst store: ReplayStore = {\n\t\t\t\tevents: [],\n\t\t\t\tstopRecording: null,\n\t\t\t\tstartTimer: null,\n\t\t\t\tpageHideHandler: null,\n\t\t\t\tloadInProgress: false,\n\t\t\t\tcancelled: false,\n\t\t\t\toptions: merged,\n\t\t\t}\n\t\t\tctx.setStore(store)\n\n\t\t\tconst begin = (): void => {\n\t\t\t\tif (store.startTimer) {\n\t\t\t\t\tclearTimeout(store.startTimer)\n\t\t\t\t\tstore.startTimer = null\n\t\t\t\t}\n\t\t\t\tif (store.pageHideHandler) {\n\t\t\t\t\twindow.removeEventListener('pagehide', store.pageHideHandler)\n\t\t\t\t\twindow.removeEventListener('beforeunload', store.pageHideHandler)\n\t\t\t\t\tstore.pageHideHandler = null\n\t\t\t\t}\n\t\t\t\tstartRecording(store, ctx)\n\t\t\t}\n\n\t\t\tif (merged.startAfterMs > 0) {\n\t\t\t\t// Engagement gate: only load rrweb if the user is still on the\n\t\t\t\t// page after `startAfterMs`. If they navigate away first we\n\t\t\t\t// cancel and never pull the heavy module.\n\t\t\t\tconst cancelOnExit = (): void => {\n\t\t\t\t\tstore.cancelled = true\n\t\t\t\t\tif (store.startTimer) {\n\t\t\t\t\t\tclearTimeout(store.startTimer)\n\t\t\t\t\t\tstore.startTimer = null\n\t\t\t\t\t}\n\t\t\t\t\tif (store.pageHideHandler) {\n\t\t\t\t\t\twindow.removeEventListener('pagehide', store.pageHideHandler)\n\t\t\t\t\t\twindow.removeEventListener('beforeunload', store.pageHideHandler)\n\t\t\t\t\t\tstore.pageHideHandler = null\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tstore.pageHideHandler = cancelOnExit\n\t\t\t\twindow.addEventListener('pagehide', cancelOnExit, { once: true })\n\t\t\t\twindow.addEventListener('beforeunload', cancelOnExit, { once: true })\n\t\t\t\tstore.startTimer = setTimeout(begin, merged.startAfterMs)\n\t\t\t} else {\n\t\t\t\tbegin()\n\t\t\t}\n\t\t},\n\t\tasync onFeedbackSubmit(ctx): Promise<Partial<FeedbackSubmission> | undefined> {\n\t\t\tconst store = ctx.getStore<ReplayStore>()\n\t\t\tif (!store || store.cancelled || store.events.length === 0) return undefined\n\t\t\t// Snapshot the current buffer so concurrent emits can't mutate\n\t\t\t// what we're serializing.\n\t\t\tconst snapshot = store.events.slice()\n\t\t\ttry {\n\t\t\t\tconst json = JSON.stringify(snapshot)\n\t\t\t\tconst replayEvents = await gzipString(json)\n\t\t\t\treturn { replayEvents }\n\t\t\t} catch (err) {\n\t\t\t\tctx.logger.error('failed to encode replay buffer', err)\n\t\t\t\treturn undefined\n\t\t\t}\n\t\t},\n\t\tonDestroy(ctx) {\n\t\t\tconst store = ctx.getStore<ReplayStore>()\n\t\t\tif (!store) return\n\t\t\tstore.cancelled = true\n\t\t\tif (store.startTimer) clearTimeout(store.startTimer)\n\t\t\tif (store.pageHideHandler) {\n\t\t\t\twindow.removeEventListener('pagehide', store.pageHideHandler)\n\t\t\t\twindow.removeEventListener('beforeunload', store.pageHideHandler)\n\t\t\t}\n\t\t\tif (store.stopRecording) {\n\t\t\t\ttry {\n\t\t\t\t\tstore.stopRecording()\n\t\t\t\t} catch (err) {\n\t\t\t\t\tctx.logger.warn('rrweb stop threw', err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tstore.events.length = 0\n\t\t},\n\t}\n}\n\n// Internal helper exports for testing only. Not part of the public API.\nexport const __test__ = { evictOldEvents, gzipString, uint8ToBase64 }\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/session-replay.ts"],"names":[],"mappings":"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","file":"session-replay.js","sourcesContent":["// Session replay plugin for the Usero widget.\n//\n// Streams rrweb events to the SaaS side as gzipped chunks while the user\n// is on the page, instead of buffering in memory and attaching to a\n// feedback submission. This decouples session replay from feedback so we\n// capture every session (subject to bot-gate + sampling + engagement\n// gates), not just the ones that submit feedback.\n//\n// Lifecycle:\n// 1. onInit: dice-roll sample, optional engagement-time gate, mint a\n// stable per-tab `sdkSessionId` in sessionStorage, and POST to\n// /api/replay-sessions to create the row. If the server returns\n// `{accepted:false}` (bot-gated), the plugin no-ops the rest of the\n// session and getCurrentSession() returns null.\n// 2. Recording: lazy-load rrweb, append events to a buffer, flush a\n// chunk every `chunkSeconds` (or sooner if the buffer is large).\n// Each chunk is gzipped via CompressionStream and PUT to\n// /api/replay-sessions/:id/chunks/:seq with raw bytes + the three\n// X-Usero-* headers (Client-Id, Event-Count, Duration-Ms). Retries\n// with exponential backoff. R2 head-check makes retries idempotent\n// server-side. A chunk PUT returning 409 stops the session.\n// 3. onFeedbackSubmit: returns `{sessionReplayId, replayOffsetMs}` so\n// the feedback record can FK at the moment of submit. Does NOT\n// attach `replayEvents` (legacy field) — chunked uploads carry the\n// events out-of-band.\n// 4. onDestroy / pagehide: best-effort flush remaining buffer, then\n// sendBeacon to /api/replay-sessions/:id/finalise with the\n// end-timestamp. Idempotent server-side.\n//\n// Bundle hygiene: rrweb stays lazy via dynamic `import('rrweb')` behind\n// the engagement gate, so consumers who lose the dice roll or navigate\n// away inside the gate window pay zero rrweb bytes.\n\nimport type { UseroPlugin, PluginContext } from '../plugin'\n\nexport interface ReplaySampling {\n\tmousemove?: number\n\tscroll?: number\n\tmedia?: number\n\tinput?: number | 'last'\n}\n\nexport interface SessionReplayOptions {\n\t// Wait this many ms after page load before loading rrweb and creating\n\t// the session row. If the user navigates away first, rrweb is never\n\t// loaded and no session row is created. Default 0 (start immediately).\n\tstartAfterMs?: number\n\t// Probability (0..1) that this session records at all. Decided once\n\t// at init via Math.random(). Default 1.\n\tsampleRate?: number\n\t// rrweb sampling rates per event type.\n\tsampling?: ReplaySampling\n\t// Mask all <input>/<textarea> values in the recording. Default true.\n\tmaskAllInputs?: boolean\n\t// CSS selector for nodes whose text content should be masked. Default\n\t// `[data-usero-mask]`.\n\tmaskTextSelector?: string\n\t// Inline external stylesheets so the replay viewer renders correctly\n\t// without network access. Default true.\n\tinlineStylesheet?: boolean\n\t// Block (entirely skip) DOM subtrees matching this selector. Default\n\t// `[data-usero-block]`.\n\tblockSelector?: string\n\t// Flush a chunk every N seconds. Default 10. Smaller = more PUTs but\n\t// less data lost on tab crash.\n\tchunkSeconds?: number\n\t// Soft cap on buffered events before forcing a flush, regardless of\n\t// time. Default 5000.\n\tchunkMaxEvents?: number\n\t// Max attempts per chunk before giving up. Default 5.\n\tchunkMaxAttempts?: number\n\t// API origin. Override for self-hosted or local dev. Defaults to the\n\t// PluginContext baseUrl threaded through by the widget.\n\tapiUrl?: string\n}\n\ninterface RrwebEvent {\n\ttype: number\n\tdata: unknown\n\ttimestamp: number\n}\n\ninterface RrwebRecordOptions {\n\temit: (event: RrwebEvent) => void\n\tmaskAllInputs?: boolean\n\tmaskTextSelector?: string\n\tinlineStylesheet?: boolean\n\tblockSelector?: string\n\tsampling?: ReplaySampling\n}\n\ninterface RrwebRecordFn {\n\t(opts: RrwebRecordOptions): () => void\n\ttakeFullSnapshot?: (isCheckout?: boolean) => void\n}\n\ntype RrwebRecord = RrwebRecordFn\n\ninterface ResolvedOptions {\n\tstartAfterMs: number\n\tsampleRate: number\n\tsampling: ReplaySampling\n\tmaskAllInputs: boolean\n\tmaskTextSelector: string\n\tinlineStylesheet: boolean\n\tblockSelector: string\n\tchunkSeconds: number\n\tchunkMaxEvents: number\n\tchunkMaxAttempts: number\n\tapiUrl: string\n}\n\ninterface ReplayStore {\n\toptions: ResolvedOptions\n\tclientId: string\n\tsdkSessionId: string\n\tsessionReplayId: string | null\n\t// Wall-clock timestamp (ms) of the first event we ever recorded.\n\t// Used to compute replayOffsetMs at feedback-submit time.\n\trecordingStartedAt: number | null\n\tpendingEvents: RrwebEvent[]\n\tpendingFirstTs: number | null\n\tpendingLastTs: number | null\n\tnextChunkSeq: number\n\tuploadQueue: Promise<void>\n\tpendingUploads: number\n\tchunkFlushTimer: ReturnType<typeof setInterval> | null\n\tstartTimer: ReturnType<typeof setTimeout> | null\n\tpageHideHandler: (() => void) | null\n\tshadowUpdateHandler: ((event: Event) => void) | null\n\trecord: RrwebRecord | null\n\tstopRecording: (() => void) | null\n\tloadInProgress: boolean\n\tcancelled: boolean\n\t// True once the session is \"done\": bot-gated, finalised, or destroyed.\n\tstopped: boolean\n}\n\nconst DEFAULTS: ResolvedOptions = {\n\tstartAfterMs: 0,\n\tsampleRate: 1,\n\tsampling: { mousemove: 50, scroll: 100 },\n\tmaskAllInputs: true,\n\tmaskTextSelector: '[data-usero-mask]',\n\tinlineStylesheet: true,\n\tblockSelector: '[data-usero-block]',\n\tchunkSeconds: 10,\n\tchunkMaxEvents: 5000,\n\tchunkMaxAttempts: 5,\n\tapiUrl: '',\n}\n\nconst SDK_SESSION_STORAGE_KEY = 'usero:session-replay:sdk-session-id'\nconst HARD_CHUNK_BYTE_CAP = 4 * 1024 * 1024\n\nfunction uint8ToBase64(bytes: Uint8Array): string {\n\tlet binary = ''\n\tconst chunkSize = 0x8000\n\tfor (let i = 0; i < bytes.length; i += chunkSize) {\n\t\tconst slice = bytes.subarray(i, i + chunkSize)\n\t\tbinary += String.fromCharCode.apply(null, Array.from(slice))\n\t}\n\treturn typeof btoa === 'function' ? btoa(binary) : ''\n}\n\nasync function gzipBytes(input: string): Promise<Uint8Array> {\n\tif (typeof CompressionStream === 'undefined') {\n\t\t// Old browsers: send uncompressed JSON. Acceptable degradation;\n\t\t// the server endpoint accepts raw application/octet-stream.\n\t\treturn new TextEncoder().encode(input)\n\t}\n\tconst stream = new Blob([input]).stream().pipeThrough(new CompressionStream('gzip'))\n\tconst buf = await new Response(stream).arrayBuffer()\n\treturn new Uint8Array(buf)\n}\n\nfunction generateRandomId(): string {\n\tif (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n\t\treturn crypto.randomUUID()\n\t}\n\tconst bytes = new Uint8Array(16)\n\tif (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n\t\tcrypto.getRandomValues(bytes)\n\t} else {\n\t\tfor (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256)\n\t}\n\tlet out = ''\n\tfor (const b of bytes) out += b.toString(16).padStart(2, '0')\n\treturn out\n}\n\nfunction mintSdkSessionId(): string {\n\ttry {\n\t\tconst existing = window.sessionStorage?.getItem(SDK_SESSION_STORAGE_KEY)\n\t\tif (existing && /^[a-z0-9-]{8,}$/i.test(existing)) return existing\n\t} catch {\n\t\t// sessionStorage can throw in sandboxed iframes — fall through.\n\t}\n\tconst id = generateRandomId()\n\ttry {\n\t\twindow.sessionStorage?.setItem(SDK_SESSION_STORAGE_KEY, id)\n\t} catch {\n\t\t// Ignore: we still return the freshly minted id.\n\t}\n\treturn id\n}\n\nfunction joinUrl(apiUrl: string, path: string): string {\n\treturn `${apiUrl.replace(/\\/$/, '')}${path}`\n}\n\ninterface CreateSessionResult {\n\taccepted: boolean\n\tsessionReplayId?: string\n\tdropReason?: string\n}\n\nasync function createSession(\n\tapiUrl: string,\n\tclientId: string,\n\tsdkSessionId: string,\n): Promise<CreateSessionResult | null> {\n\ttry {\n\t\tconst startUrl =\n\t\t\ttypeof window !== 'undefined' && window.location ? window.location.href : undefined\n\t\tconst userAgent =\n\t\t\ttypeof navigator !== 'undefined' && navigator.userAgent ? navigator.userAgent : undefined\n\t\tconst res = await fetch(joinUrl(apiUrl, '/api/replay-sessions'), {\n\t\t\tmethod: 'POST',\n\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\tbody: JSON.stringify({\n\t\t\t\tclientId,\n\t\t\t\tsdkSessionId,\n\t\t\t\tstartUrl,\n\t\t\t\tuserAgent,\n\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t}),\n\t\t})\n\t\tif (!res.ok) return null\n\t\tconst json = (await res.json()) as {\n\t\t\taccepted?: unknown\n\t\t\tsessionReplayId?: unknown\n\t\t\tdropReason?: unknown\n\t\t}\n\t\tif (typeof json.accepted !== 'boolean') return null\n\t\tconst result: CreateSessionResult = { accepted: json.accepted }\n\t\tif (typeof json.sessionReplayId === 'string') result.sessionReplayId = json.sessionReplayId\n\t\tif (typeof json.dropReason === 'string') result.dropReason = json.dropReason\n\t\treturn result\n\t} catch {\n\t\treturn null\n\t}\n}\n\ninterface ChunkUploadResult {\n\tok: boolean\n\tstopSession: boolean\n}\n\nasync function uploadChunk(\n\tapiUrl: string,\n\tsessionReplayId: string,\n\tclientId: string,\n\tseq: number,\n\tbytes: Uint8Array,\n\teventCount: number,\n\tdurationMs: number,\n\tlogger: PluginContext['logger'],\n\tmaxAttempts: number,\n): Promise<ChunkUploadResult> {\n\tconst url = joinUrl(\n\t\tapiUrl,\n\t\t`/api/replay-sessions/${encodeURIComponent(sessionReplayId)}/chunks/${seq}`,\n\t)\n\tlet attempt = 0\n\twhile (attempt < maxAttempts) {\n\t\ttry {\n\t\t\t// Wrap in a Blob so the body type is unambiguously BodyInit; some\n\t\t\t// TS lib targets reject raw Uint8Array as fetch body. Slice off\n\t\t\t// the buffer to satisfy the BlobPart ArrayBuffer constraint\n\t\t\t// (Uint8Array<SharedArrayBuffer> is the alternative the lib\n\t\t\t// admits, which we never produce here).\n\t\t\tconst buffer = bytes.buffer.slice(\n\t\t\t\tbytes.byteOffset,\n\t\t\t\tbytes.byteOffset + bytes.byteLength,\n\t\t\t) as ArrayBuffer\n\t\t\tconst blob = new Blob([buffer], { type: 'application/octet-stream' })\n\t\t\tconst res = await fetch(url, {\n\t\t\t\tmethod: 'PUT',\n\t\t\t\tbody: blob,\n\t\t\t\theaders: {\n\t\t\t\t\t'Content-Type': 'application/octet-stream',\n\t\t\t\t\t'X-Usero-Client-Id': clientId,\n\t\t\t\t\t'X-Usero-Event-Count': String(eventCount),\n\t\t\t\t\t'X-Usero-Duration-Ms': String(Math.max(0, Math.round(durationMs))),\n\t\t\t\t},\n\t\t\t})\n\t\t\tif (res.ok) return { ok: true, stopSession: false }\n\t\t\t// 409: server told us to stop (bot-dropped, or session already\n\t\t\t// finalised). Don't retry, don't upload further chunks.\n\t\t\tif (res.status === 409) {\n\t\t\t\tlogger.warn(`chunk ${seq} rejected with 409, stopping session`)\n\t\t\t\treturn { ok: false, stopSession: true }\n\t\t\t}\n\t\t\t// Other 4xx (besides 408/429) won't get better with retry.\n\t\t\tif (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {\n\t\t\t\tlogger.error(`chunk ${seq} rejected with ${res.status}`)\n\t\t\t\treturn { ok: false, stopSession: false }\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tlogger.warn(`chunk ${seq} attempt ${attempt + 1} failed`, err)\n\t\t}\n\t\tattempt += 1\n\t\tconst backoff = Math.min(15_000, 500 * 2 ** attempt) + Math.floor(Math.random() * 250)\n\t\tawait new Promise(resolve => setTimeout(resolve, backoff))\n\t}\n\tlogger.error(`chunk ${seq} dropped after ${maxAttempts} attempts`)\n\treturn { ok: false, stopSession: false }\n}\n\nasync function loadRrwebRecord(): Promise<RrwebRecord | null> {\n\ttry {\n\t\tconst mod: unknown = await import(/* webpackChunkName: \"rrweb\" */ 'rrweb')\n\t\tif (\n\t\t\tmod &&\n\t\t\ttypeof mod === 'object' &&\n\t\t\t'record' in mod &&\n\t\t\ttypeof (mod as { record: unknown }).record === 'function'\n\t\t) {\n\t\t\treturn (mod as { record: RrwebRecord }).record\n\t\t}\n\t\treturn null\n\t} catch {\n\t\treturn null\n\t}\n}\n\nfunction scheduleChunkUpload(store: ReplayStore, ctx: PluginContext): void {\n\tif (!store.sessionReplayId) return\n\tif (store.pendingEvents.length === 0) return\n\tconst events = store.pendingEvents\n\tconst eventCount = events.length\n\tconst firstTs = store.pendingFirstTs ?? 0\n\tconst lastTs = store.pendingLastTs ?? firstTs\n\tconst durationMs = Math.max(0, lastTs - firstTs)\n\tconst seq = store.nextChunkSeq\n\tstore.nextChunkSeq += 1\n\tstore.pendingEvents = []\n\tstore.pendingFirstTs = null\n\tstore.pendingLastTs = null\n\n\tconst sessionReplayId = store.sessionReplayId\n\tconst apiUrl = store.options.apiUrl\n\tconst clientId = store.clientId\n\tconst maxAttempts = store.options.chunkMaxAttempts\n\n\tstore.pendingUploads += 1\n\tstore.uploadQueue = store.uploadQueue.then(async () => {\n\t\ttry {\n\t\t\tif (store.cancelled) return\n\t\t\tconst json = JSON.stringify(events)\n\t\t\tconst bytes = await gzipBytes(json)\n\t\t\tif (bytes.byteLength > HARD_CHUNK_BYTE_CAP) {\n\t\t\t\tctx.logger.error(\n\t\t\t\t\t`chunk ${seq} exceeds 4MB hard cap (${bytes.byteLength} bytes), dropping`,\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst result = await uploadChunk(\n\t\t\t\tapiUrl,\n\t\t\t\tsessionReplayId,\n\t\t\t\tclientId,\n\t\t\t\tseq,\n\t\t\t\tbytes,\n\t\t\t\teventCount,\n\t\t\t\tdurationMs,\n\t\t\t\tctx.logger,\n\t\t\t\tmaxAttempts,\n\t\t\t)\n\t\t\tif (result.stopSession) {\n\t\t\t\tstore.stopped = true\n\t\t\t\tstopRrweb(store)\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tctx.logger.error(`chunk ${seq} encode failed`, err)\n\t\t} finally {\n\t\t\tstore.pendingUploads -= 1\n\t\t}\n\t})\n}\n\nfunction flushPendingChunk(store: ReplayStore, ctx: PluginContext): void {\n\tif (store.stopped || store.cancelled) return\n\tif (store.pendingEvents.length === 0) return\n\tscheduleChunkUpload(store, ctx)\n}\n\nfunction stopRrweb(store: ReplayStore): void {\n\tif (store.stopRecording) {\n\t\ttry {\n\t\t\tstore.stopRecording()\n\t\t} catch {\n\t\t\t// Already stopped.\n\t\t}\n\t\tstore.stopRecording = null\n\t}\n\tif (store.chunkFlushTimer) {\n\t\tclearInterval(store.chunkFlushTimer)\n\t\tstore.chunkFlushTimer = null\n\t}\n}\n\nfunction startRecording(store: ReplayStore, ctx: PluginContext): void {\n\tif (store.cancelled || store.stopped || store.stopRecording || store.loadInProgress) return\n\tstore.loadInProgress = true\n\tvoid loadRrwebRecord().then(record => {\n\t\tstore.loadInProgress = false\n\t\tif (store.cancelled || store.stopped || !record) {\n\t\t\tif (!record) ctx.logger.warn('rrweb failed to load, replay disabled')\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\tconst stop = record({\n\t\t\t\temit: event => {\n\t\t\t\t\tif (store.stopped || store.cancelled) return\n\t\t\t\t\tif (store.recordingStartedAt === null) store.recordingStartedAt = event.timestamp\n\t\t\t\t\tstore.pendingEvents.push(event)\n\t\t\t\t\tif (store.pendingFirstTs === null) store.pendingFirstTs = event.timestamp\n\t\t\t\t\tstore.pendingLastTs = event.timestamp\n\t\t\t\t\tif (store.pendingEvents.length >= store.options.chunkMaxEvents) {\n\t\t\t\t\t\tscheduleChunkUpload(store, ctx)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tmaskAllInputs: store.options.maskAllInputs,\n\t\t\t\tmaskTextSelector: store.options.maskTextSelector || undefined,\n\t\t\t\tinlineStylesheet: store.options.inlineStylesheet,\n\t\t\t\tblockSelector: store.options.blockSelector,\n\t\t\t\tsampling: store.options.sampling,\n\t\t\t})\n\t\t\tstore.stopRecording = stop\n\t\t\tstore.record = record\n\t\t\tscheduleShadowSnapshot(store, ctx)\n\n\t\t\tstore.chunkFlushTimer = setInterval(\n\t\t\t\t() => flushPendingChunk(store, ctx),\n\t\t\t\tstore.options.chunkSeconds * 1000,\n\t\t\t)\n\t\t} catch (err) {\n\t\t\tctx.logger.error('rrweb record() threw', err)\n\t\t}\n\t})\n}\n\nfunction scheduleShadowSnapshot(store: ReplayStore, ctx: PluginContext): void {\n\tif (store.cancelled || store.stopped || !store.record || !store.stopRecording) return\n\tconst fn = store.record.takeFullSnapshot\n\tif (typeof fn !== 'function') return\n\ttry {\n\t\tfn(true)\n\t} catch (err) {\n\t\tctx.logger.warn('takeFullSnapshot threw', err)\n\t}\n}\n\nfunction finalise(store: ReplayStore, ctx: PluginContext, opts: { useBeacon: boolean }): void {\n\tif (!store.sessionReplayId) return\n\tif (store.pendingEvents.length > 0) flushPendingChunk(store, ctx)\n\tconst url = joinUrl(\n\t\tstore.options.apiUrl,\n\t\t`/api/replay-sessions/${encodeURIComponent(store.sessionReplayId)}/finalise`,\n\t)\n\tconst body = JSON.stringify({ clientId: store.clientId, endedAt: new Date().toISOString() })\n\tif (opts.useBeacon && typeof navigator !== 'undefined' && navigator.sendBeacon) {\n\t\ttry {\n\t\t\tconst blob = new Blob([body], { type: 'application/json' })\n\t\t\tnavigator.sendBeacon(url, blob)\n\t\t\treturn\n\t\t} catch (err) {\n\t\t\tctx.logger.warn('finalise sendBeacon threw', err)\n\t\t}\n\t}\n\tvoid fetch(url, {\n\t\tmethod: 'POST',\n\t\tbody,\n\t\theaders: { 'Content-Type': 'application/json' },\n\t\tkeepalive: true,\n\t}).catch(err => ctx.logger.warn('finalise fetch failed', err))\n}\n\nexport interface CurrentSessionHandle {\n\tid: string\n\toffsetMs: number\n}\n\nexport function sessionReplay(options: SessionReplayOptions = {}): UseroPlugin {\n\tconst merged: ResolvedOptions = {\n\t\t...DEFAULTS,\n\t\t...options,\n\t\tsampling: { ...DEFAULTS.sampling, ...(options.sampling ?? {}) },\n\t}\n\n\treturn {\n\t\tname: 'session-replay',\n\t\tonInit(ctx) {\n\t\t\tif (typeof window === 'undefined') return\n\t\t\tif (merged.sampleRate < 1 && Math.random() >= merged.sampleRate) {\n\t\t\t\tctx.logger.debug('skipped by sampleRate')\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst apiUrl = merged.apiUrl || ctx.baseUrl\n\t\t\tif (!apiUrl) {\n\t\t\t\tctx.logger.error('session-replay needs an apiUrl (via options or PluginContext)')\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst sdkSessionId = mintSdkSessionId()\n\n\t\t\tconst store: ReplayStore = {\n\t\t\t\toptions: { ...merged, apiUrl },\n\t\t\t\tclientId: ctx.clientId,\n\t\t\t\tsdkSessionId,\n\t\t\t\tsessionReplayId: null,\n\t\t\t\trecordingStartedAt: null,\n\t\t\t\tpendingEvents: [],\n\t\t\t\tpendingFirstTs: null,\n\t\t\t\tpendingLastTs: null,\n\t\t\t\tnextChunkSeq: 0,\n\t\t\t\tuploadQueue: Promise.resolve(),\n\t\t\t\tpendingUploads: 0,\n\t\t\t\tchunkFlushTimer: null,\n\t\t\t\tstartTimer: null,\n\t\t\t\tpageHideHandler: null,\n\t\t\t\tshadowUpdateHandler: null,\n\t\t\t\trecord: null,\n\t\t\t\tstopRecording: null,\n\t\t\t\tloadInProgress: false,\n\t\t\t\tcancelled: false,\n\t\t\t\tstopped: false,\n\t\t\t}\n\t\t\tctx.setStore(store)\n\n\t\t\tconst onShadowUpdate = (): void => scheduleShadowSnapshot(store, ctx)\n\t\t\tstore.shadowUpdateHandler = onShadowUpdate\n\t\t\twindow.addEventListener('usero:shadow-update', onShadowUpdate)\n\n\t\t\tconst onPageHide = (): void => {\n\t\t\t\tfinalise(store, ctx, { useBeacon: true })\n\t\t\t\tstore.stopped = true\n\t\t\t\tstopRrweb(store)\n\t\t\t}\n\t\t\tstore.pageHideHandler = onPageHide\n\t\t\twindow.addEventListener('pagehide', onPageHide)\n\n\t\t\tconst begin = async (): Promise<void> => {\n\t\t\t\tif (store.cancelled) return\n\t\t\t\tconst created = await createSession(apiUrl, ctx.clientId, sdkSessionId)\n\t\t\t\tif (!created) {\n\t\t\t\t\tctx.logger.warn('session create failed, replay disabled')\n\t\t\t\t\tstore.stopped = true\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (!created.accepted) {\n\t\t\t\t\tctx.logger.info(`session-replay declined: ${created.dropReason ?? 'unknown'}`)\n\t\t\t\t\tstore.stopped = true\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (!created.sessionReplayId) {\n\t\t\t\t\tctx.logger.error('server accepted but returned no sessionReplayId')\n\t\t\t\t\tstore.stopped = true\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tstore.sessionReplayId = created.sessionReplayId\n\t\t\t\tstore.recordingStartedAt = Date.now()\n\t\t\t\tstartRecording(store, ctx)\n\t\t\t}\n\n\t\t\tif (merged.startAfterMs > 0) {\n\t\t\t\tconst cancelOnExit = (): void => {\n\t\t\t\t\tstore.cancelled = true\n\t\t\t\t\tif (store.startTimer) {\n\t\t\t\t\t\tclearTimeout(store.startTimer)\n\t\t\t\t\t\tstore.startTimer = null\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\twindow.addEventListener('pagehide', cancelOnExit, { once: true })\n\t\t\t\twindow.addEventListener('beforeunload', cancelOnExit, { once: true })\n\t\t\t\tstore.startTimer = setTimeout(() => {\n\t\t\t\t\tvoid begin()\n\t\t\t\t}, merged.startAfterMs)\n\t\t\t} else {\n\t\t\t\tvoid begin()\n\t\t\t}\n\t\t},\n\t\tonFeedbackSubmit(ctx) {\n\t\t\tconst store = ctx.getStore<ReplayStore>()\n\t\t\tif (!store || store.cancelled || store.stopped) return undefined\n\t\t\tif (!store.sessionReplayId) return undefined\n\t\t\tconst offsetMs =\n\t\t\t\tstore.recordingStartedAt !== null\n\t\t\t\t\t? Math.max(0, Date.now() - store.recordingStartedAt)\n\t\t\t\t\t: 0\n\t\t\treturn { sessionReplayId: store.sessionReplayId, replayOffsetMs: offsetMs }\n\t\t},\n\t\tonDestroy(ctx) {\n\t\t\tconst store = ctx.getStore<ReplayStore>()\n\t\t\tif (!store) return\n\t\t\tstore.cancelled = true\n\t\t\tif (store.startTimer) {\n\t\t\t\tclearTimeout(store.startTimer)\n\t\t\t\tstore.startTimer = null\n\t\t\t}\n\t\t\tif (store.pageHideHandler) {\n\t\t\t\twindow.removeEventListener('pagehide', store.pageHideHandler)\n\t\t\t\tstore.pageHideHandler = null\n\t\t\t}\n\t\t\tif (store.shadowUpdateHandler) {\n\t\t\t\twindow.removeEventListener('usero:shadow-update', store.shadowUpdateHandler)\n\t\t\t\tstore.shadowUpdateHandler = null\n\t\t\t}\n\t\t\t// SPA route change / React unmount: send a finalise so the\n\t\t\t// server stamps endedAt. fetch+keepalive is fine here since we\n\t\t\t// aren't necessarily in a pagehide path.\n\t\t\tif (store.sessionReplayId && !store.stopped) {\n\t\t\t\tfinalise(store, ctx, { useBeacon: false })\n\t\t\t}\n\t\t\tstore.stopped = true\n\t\t\tstopRrweb(store)\n\t\t\tstore.pendingEvents.length = 0\n\t\t},\n\t}\n}\n\n// Returns the live session-replay handle for a given plugin context, or\n// null if the session was bot-dropped, sample-skipped, or not yet\n// created. Other plugins (e.g. user-test) can call this to attach the\n// replay FK + offset to their own server-side records.\nexport function getCurrentSession(ctx: PluginContext): CurrentSessionHandle | null {\n\tconst store = ctx.getStore<ReplayStore>()\n\tif (!store || store.cancelled || store.stopped || !store.sessionReplayId) return null\n\tconst offsetMs =\n\t\tstore.recordingStartedAt !== null\n\t\t\t? Math.max(0, Date.now() - store.recordingStartedAt)\n\t\t\t: 0\n\treturn { id: store.sessionReplayId, offsetMs }\n}\n\n// Internal helper exports for testing only. Not part of the public API.\nexport const __test__ = {\n\tuint8ToBase64,\n\tgzipBytes,\n\tmintSdkSessionId,\n\tuploadChunk,\n\tcreateSession,\n\tjoinUrl,\n\tHARD_CHUNK_BYTE_CAP,\n\tSDK_SESSION_STORAGE_KEY,\n}\n"]}
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/types.ts
|
|
4
|
+
var DEFAULT_API_URL = "https://usero.io";
|
|
5
|
+
|
|
6
|
+
// src/plugins/user-test.ts
|
|
7
|
+
var DEFAULT_OPTIONS = {
|
|
8
|
+
queryParam: "usero_test",
|
|
9
|
+
chunkSeconds: 30,
|
|
10
|
+
apiUrl: DEFAULT_API_URL,
|
|
11
|
+
testerName: "",
|
|
12
|
+
hideIndicator: false
|
|
13
|
+
};
|
|
14
|
+
var TESTER_NAME_STORAGE_KEY = "usero:user-test:tester-name";
|
|
15
|
+
var IDB_NAME = "usero-user-test";
|
|
16
|
+
var IDB_STORE = "pending-chunks";
|
|
17
|
+
function readTesterName(override) {
|
|
18
|
+
if (override) return override;
|
|
19
|
+
try {
|
|
20
|
+
const stored = window.localStorage?.getItem(TESTER_NAME_STORAGE_KEY);
|
|
21
|
+
if (stored && stored.trim()) return stored.trim().slice(0, 120);
|
|
22
|
+
} catch {
|
|
23
|
+
}
|
|
24
|
+
return void 0;
|
|
25
|
+
}
|
|
26
|
+
function getTestSlug(queryParam) {
|
|
27
|
+
if (typeof window === "undefined" || typeof window.location === "undefined") return null;
|
|
28
|
+
try {
|
|
29
|
+
const params = new URLSearchParams(window.location.search);
|
|
30
|
+
const slug = params.get(queryParam);
|
|
31
|
+
if (!slug) return null;
|
|
32
|
+
const cleaned = slug.trim().slice(0, 64);
|
|
33
|
+
if (!/^[a-z0-9-]+$/i.test(cleaned)) return null;
|
|
34
|
+
return cleaned;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function isMediaRecorderSupported() {
|
|
40
|
+
return typeof window !== "undefined" && typeof window.MediaRecorder !== "undefined" && typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia;
|
|
41
|
+
}
|
|
42
|
+
function pickMimeType() {
|
|
43
|
+
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported?.(candidate)) {
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return void 0;
|
|
50
|
+
}
|
|
51
|
+
function idbOpen() {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
if (typeof indexedDB === "undefined") {
|
|
54
|
+
resolve(null);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const req = indexedDB.open(IDB_NAME, 1);
|
|
59
|
+
req.onupgradeneeded = () => {
|
|
60
|
+
const db = req.result;
|
|
61
|
+
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
|
62
|
+
db.createObjectStore(IDB_STORE, { keyPath: "id" });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
req.onsuccess = () => resolve(req.result);
|
|
66
|
+
req.onerror = () => resolve(null);
|
|
67
|
+
} catch {
|
|
68
|
+
resolve(null);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async function idbStashChunk(chunk) {
|
|
73
|
+
const db = await idbOpen();
|
|
74
|
+
if (!db) return;
|
|
75
|
+
await new Promise((resolve) => {
|
|
76
|
+
try {
|
|
77
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
78
|
+
tx.objectStore(IDB_STORE).put(chunk);
|
|
79
|
+
tx.oncomplete = () => resolve();
|
|
80
|
+
tx.onerror = () => resolve();
|
|
81
|
+
tx.onabort = () => resolve();
|
|
82
|
+
} catch {
|
|
83
|
+
resolve();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
db.close();
|
|
87
|
+
}
|
|
88
|
+
async function idbDeleteChunk(id) {
|
|
89
|
+
const db = await idbOpen();
|
|
90
|
+
if (!db) return;
|
|
91
|
+
await new Promise((resolve) => {
|
|
92
|
+
try {
|
|
93
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
94
|
+
tx.objectStore(IDB_STORE).delete(id);
|
|
95
|
+
tx.oncomplete = () => resolve();
|
|
96
|
+
tx.onerror = () => resolve();
|
|
97
|
+
tx.onabort = () => resolve();
|
|
98
|
+
} catch {
|
|
99
|
+
resolve();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
db.close();
|
|
103
|
+
}
|
|
104
|
+
async function idbListChunks(sessionId) {
|
|
105
|
+
const db = await idbOpen();
|
|
106
|
+
if (!db) return [];
|
|
107
|
+
const items = await new Promise((resolve) => {
|
|
108
|
+
try {
|
|
109
|
+
const tx = db.transaction(IDB_STORE, "readonly");
|
|
110
|
+
const req = tx.objectStore(IDB_STORE).getAll();
|
|
111
|
+
req.onsuccess = () => {
|
|
112
|
+
const all = req.result ?? [];
|
|
113
|
+
resolve(all.filter((c) => c.sessionId === sessionId));
|
|
114
|
+
};
|
|
115
|
+
req.onerror = () => resolve([]);
|
|
116
|
+
} catch {
|
|
117
|
+
resolve([]);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
db.close();
|
|
121
|
+
return items;
|
|
122
|
+
}
|
|
123
|
+
async function uploadChunkWithRetry(apiUrl, sessionId, index, blob, logger, maxAttempts = 5) {
|
|
124
|
+
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/chunk?index=${index}`;
|
|
125
|
+
let attempt = 0;
|
|
126
|
+
while (attempt < maxAttempts) {
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetch(url, {
|
|
129
|
+
method: "PUT",
|
|
130
|
+
body: blob,
|
|
131
|
+
headers: { "Content-Type": blob.type || "audio/webm" },
|
|
132
|
+
keepalive: blob.size <= 60 * 1024
|
|
133
|
+
// browsers cap keepalive bodies
|
|
134
|
+
});
|
|
135
|
+
if (res.ok) return true;
|
|
136
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
|
|
137
|
+
logger.error(`chunk ${index} rejected with ${res.status}`);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
logger.warn(`chunk ${index} upload attempt ${attempt + 1} failed`, err);
|
|
142
|
+
}
|
|
143
|
+
attempt += 1;
|
|
144
|
+
const backoff = Math.min(15e3, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
function buildIndicator(host, store, onFinish) {
|
|
150
|
+
const root = host.attachShadow({ mode: "closed" });
|
|
151
|
+
const style = document.createElement("style");
|
|
152
|
+
style.textContent = `
|
|
153
|
+
:host { all: initial; }
|
|
154
|
+
.bar {
|
|
155
|
+
position: fixed;
|
|
156
|
+
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
|
|
157
|
+
left: 50%;
|
|
158
|
+
transform: translateX(-50%);
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 10px;
|
|
162
|
+
padding: 8px 14px 8px 12px;
|
|
163
|
+
background: rgba(17, 17, 17, 0.78);
|
|
164
|
+
color: #fff;
|
|
165
|
+
border-radius: 999px;
|
|
166
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
167
|
+
font-size: 13px;
|
|
168
|
+
line-height: 1;
|
|
169
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
170
|
+
backdrop-filter: blur(8px);
|
|
171
|
+
-webkit-backdrop-filter: blur(8px);
|
|
172
|
+
z-index: 2147483646;
|
|
173
|
+
max-width: calc(100vw - 32px);
|
|
174
|
+
}
|
|
175
|
+
.dot {
|
|
176
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
177
|
+
background: #ef4444;
|
|
178
|
+
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
|
|
179
|
+
animation: pulse 1.6s ease-out infinite;
|
|
180
|
+
}
|
|
181
|
+
.dot[data-state="no-audio"] { background: #fbbf24; animation: none; }
|
|
182
|
+
.dot[data-state="finishing"] { background: #fbbf24; animation: none; }
|
|
183
|
+
.dot[data-state="done"] { background: #10b981; animation: none; }
|
|
184
|
+
.dot[data-state="error"] { background: #ef4444; animation: none; }
|
|
185
|
+
.label { font-weight: 500; letter-spacing: 0.01em; }
|
|
186
|
+
.spacer { width: 1px; height: 16px; background: rgba(255,255,255,0.18); margin: 0 2px; }
|
|
187
|
+
.btn {
|
|
188
|
+
appearance: none; border: 0; background: rgba(255,255,255,0.12);
|
|
189
|
+
color: #fff; font: inherit; font-weight: 600;
|
|
190
|
+
padding: 6px 12px; border-radius: 999px; cursor: pointer;
|
|
191
|
+
transition: background 0.15s ease;
|
|
192
|
+
}
|
|
193
|
+
.btn:hover { background: rgba(255,255,255,0.22); }
|
|
194
|
+
.btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
195
|
+
.btn[disabled] { opacity: 0.5; cursor: progress; }
|
|
196
|
+
.thanks {
|
|
197
|
+
position: fixed; inset: 0;
|
|
198
|
+
display: grid; place-items: center;
|
|
199
|
+
background: rgba(15, 15, 17, 0.78);
|
|
200
|
+
backdrop-filter: blur(6px);
|
|
201
|
+
-webkit-backdrop-filter: blur(6px);
|
|
202
|
+
color: #fff;
|
|
203
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
204
|
+
z-index: 2147483647;
|
|
205
|
+
padding: 24px;
|
|
206
|
+
text-align: center;
|
|
207
|
+
}
|
|
208
|
+
.thanks-card {
|
|
209
|
+
background: #fff; color: #111;
|
|
210
|
+
border-radius: 16px; padding: 28px 24px;
|
|
211
|
+
max-width: 360px; width: 100%;
|
|
212
|
+
box-shadow: 0 20px 50px rgba(0,0,0,0.25);
|
|
213
|
+
}
|
|
214
|
+
.thanks h2 { margin: 0 0 8px; font-size: 20px; }
|
|
215
|
+
.thanks p { margin: 0; font-size: 14px; line-height: 1.45; color: #4b5563; }
|
|
216
|
+
.thanks .check {
|
|
217
|
+
width: 44px; height: 44px; border-radius: 50%;
|
|
218
|
+
background: #10b981; color: #fff;
|
|
219
|
+
display: grid; place-items: center;
|
|
220
|
+
margin: 0 auto 12px;
|
|
221
|
+
font-size: 22px;
|
|
222
|
+
}
|
|
223
|
+
@keyframes pulse {
|
|
224
|
+
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.55); }
|
|
225
|
+
70% { box-shadow: 0 0 0 10px rgba(239,68,68,0); }
|
|
226
|
+
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
|
227
|
+
}
|
|
228
|
+
@media (prefers-reduced-motion: reduce) {
|
|
229
|
+
.dot { animation: none; }
|
|
230
|
+
}
|
|
231
|
+
`;
|
|
232
|
+
const bar = document.createElement("div");
|
|
233
|
+
bar.className = "bar";
|
|
234
|
+
bar.setAttribute("role", "status");
|
|
235
|
+
bar.setAttribute("aria-live", "polite");
|
|
236
|
+
const dot = document.createElement("span");
|
|
237
|
+
dot.className = "dot";
|
|
238
|
+
dot.setAttribute("data-state", store.indicatorState);
|
|
239
|
+
const label = document.createElement("span");
|
|
240
|
+
label.className = "label";
|
|
241
|
+
label.textContent = "Recording";
|
|
242
|
+
const spacer = document.createElement("span");
|
|
243
|
+
spacer.className = "spacer";
|
|
244
|
+
const btn = document.createElement("button");
|
|
245
|
+
btn.type = "button";
|
|
246
|
+
btn.className = "btn";
|
|
247
|
+
btn.textContent = "Finish";
|
|
248
|
+
btn.addEventListener("click", onFinish);
|
|
249
|
+
bar.appendChild(dot);
|
|
250
|
+
bar.appendChild(label);
|
|
251
|
+
bar.appendChild(spacer);
|
|
252
|
+
bar.appendChild(btn);
|
|
253
|
+
root.appendChild(style);
|
|
254
|
+
root.appendChild(bar);
|
|
255
|
+
return root;
|
|
256
|
+
}
|
|
257
|
+
function renderIndicatorState(store) {
|
|
258
|
+
const root = store.indicatorRoot;
|
|
259
|
+
if (!root) return;
|
|
260
|
+
const dot = root.querySelector(".dot");
|
|
261
|
+
const label = root.querySelector(".label");
|
|
262
|
+
const btn = root.querySelector(".btn");
|
|
263
|
+
if (!(dot instanceof HTMLElement) || !(label instanceof HTMLElement) || !btn) return;
|
|
264
|
+
dot.setAttribute("data-state", store.indicatorState);
|
|
265
|
+
switch (store.indicatorState) {
|
|
266
|
+
case "recording":
|
|
267
|
+
label.textContent = "Recording";
|
|
268
|
+
btn.textContent = "Finish";
|
|
269
|
+
btn.disabled = false;
|
|
270
|
+
break;
|
|
271
|
+
case "no-audio":
|
|
272
|
+
label.textContent = "No mic, replay only";
|
|
273
|
+
btn.textContent = "Finish";
|
|
274
|
+
btn.disabled = false;
|
|
275
|
+
break;
|
|
276
|
+
case "finishing":
|
|
277
|
+
label.textContent = "Saving";
|
|
278
|
+
btn.textContent = "Saving";
|
|
279
|
+
btn.disabled = true;
|
|
280
|
+
break;
|
|
281
|
+
case "done":
|
|
282
|
+
label.textContent = "Saved";
|
|
283
|
+
btn.textContent = "Done";
|
|
284
|
+
btn.disabled = true;
|
|
285
|
+
break;
|
|
286
|
+
case "error":
|
|
287
|
+
label.textContent = "Save failed";
|
|
288
|
+
btn.textContent = "Retry";
|
|
289
|
+
btn.disabled = false;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function showThanksScreen(root) {
|
|
294
|
+
const overlay = document.createElement("div");
|
|
295
|
+
overlay.className = "thanks";
|
|
296
|
+
overlay.innerHTML = `
|
|
297
|
+
<div class="thanks-card">
|
|
298
|
+
<div class="check" aria-hidden="true">✓</div>
|
|
299
|
+
<h2>Thanks for testing</h2>
|
|
300
|
+
<p>Your session was saved. You can close this tab.</p>
|
|
301
|
+
</div>
|
|
302
|
+
`;
|
|
303
|
+
root.appendChild(overlay);
|
|
304
|
+
}
|
|
305
|
+
async function createSession(apiUrl, slug, testerName) {
|
|
306
|
+
try {
|
|
307
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions`, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: { "Content-Type": "application/json" },
|
|
310
|
+
body: JSON.stringify({ slug, ...testerName ? { testerName } : {} })
|
|
311
|
+
});
|
|
312
|
+
if (!res.ok) return null;
|
|
313
|
+
const json = await res.json();
|
|
314
|
+
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
|
|
315
|
+
return { sessionId: json.sessionId, clientId: json.clientId };
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function finaliseSession(apiUrl, sessionId, durationSeconds) {
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/finalise`, {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: { "Content-Type": "application/json" },
|
|
325
|
+
body: JSON.stringify({ durationSeconds: Math.max(0, Math.round(durationSeconds)) }),
|
|
326
|
+
keepalive: true
|
|
327
|
+
});
|
|
328
|
+
return res.ok;
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function flushPendingFromIdb(store, ctx) {
|
|
334
|
+
if (!store.sessionId) return;
|
|
335
|
+
const pending = await idbListChunks(store.sessionId);
|
|
336
|
+
for (const chunk of pending) {
|
|
337
|
+
const ok = await uploadChunkWithRetry(chunk.apiUrl, chunk.sessionId, chunk.chunkIndex, chunk.blob, ctx.logger, 3);
|
|
338
|
+
if (ok) await idbDeleteChunk(chunk.id);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function enqueueChunk(store, ctx, blob) {
|
|
342
|
+
if (store.cancelled || !store.sessionId || blob.size === 0) return;
|
|
343
|
+
const index = store.chunkIndex;
|
|
344
|
+
store.chunkIndex += 1;
|
|
345
|
+
store.pendingUploads += 1;
|
|
346
|
+
const sessionId = store.sessionId;
|
|
347
|
+
const apiUrl = store.options.apiUrl;
|
|
348
|
+
store.uploadQueue = store.uploadQueue.then(async () => {
|
|
349
|
+
const ok = await uploadChunkWithRetry(apiUrl, sessionId, index, blob, ctx.logger);
|
|
350
|
+
if (!ok) {
|
|
351
|
+
ctx.logger.warn(`chunk ${index} stashed for offline retry`);
|
|
352
|
+
await idbStashChunk({
|
|
353
|
+
id: `${sessionId}:${index}:${Date.now()}`,
|
|
354
|
+
sessionId,
|
|
355
|
+
apiUrl,
|
|
356
|
+
chunkIndex: index,
|
|
357
|
+
blob,
|
|
358
|
+
createdAt: Date.now()
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
store.pendingUploads -= 1;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
async function startRecording(store, ctx) {
|
|
365
|
+
if (!isMediaRecorderSupported()) {
|
|
366
|
+
ctx.logger.warn("MediaRecorder not supported, continuing without audio");
|
|
367
|
+
store.indicatorState = "no-audio";
|
|
368
|
+
renderIndicatorState(store);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
let stream;
|
|
372
|
+
try {
|
|
373
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
374
|
+
} catch (err) {
|
|
375
|
+
ctx.logger.warn("mic permission denied or unavailable", err);
|
|
376
|
+
store.indicatorState = "no-audio";
|
|
377
|
+
renderIndicatorState(store);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
store.stream = stream;
|
|
381
|
+
const mimeType = pickMimeType();
|
|
382
|
+
let recorder;
|
|
383
|
+
try {
|
|
384
|
+
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
ctx.logger.error("MediaRecorder construction failed", err);
|
|
387
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
388
|
+
store.stream = null;
|
|
389
|
+
store.indicatorState = "no-audio";
|
|
390
|
+
renderIndicatorState(store);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
store.recorder = recorder;
|
|
394
|
+
recorder.addEventListener("dataavailable", (event) => {
|
|
395
|
+
if (event.data && event.data.size > 0) {
|
|
396
|
+
enqueueChunk(store, ctx, event.data);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
recorder.addEventListener("error", (event) => {
|
|
400
|
+
ctx.logger.error("MediaRecorder error", event);
|
|
401
|
+
});
|
|
402
|
+
recorder.start(store.options.chunkSeconds * 1e3);
|
|
403
|
+
}
|
|
404
|
+
function stopRecording(store) {
|
|
405
|
+
const recorder = store.recorder;
|
|
406
|
+
if (recorder && recorder.state !== "inactive") {
|
|
407
|
+
try {
|
|
408
|
+
recorder.requestData();
|
|
409
|
+
} catch {
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
recorder.stop();
|
|
413
|
+
} catch {
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
store.recorder = null;
|
|
417
|
+
if (store.stream) {
|
|
418
|
+
store.stream.getTracks().forEach((t) => t.stop());
|
|
419
|
+
store.stream = null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async function finishFlow(store, ctx, opts) {
|
|
423
|
+
if (store.cancelled) return;
|
|
424
|
+
if (store.indicatorState === "finishing" || store.indicatorState === "done") return;
|
|
425
|
+
store.indicatorState = "finishing";
|
|
426
|
+
renderIndicatorState(store);
|
|
427
|
+
stopRecording(store);
|
|
428
|
+
await store.uploadQueue;
|
|
429
|
+
await flushPendingFromIdb(store, ctx);
|
|
430
|
+
const durationSeconds = (Date.now() - store.startedAt) / 1e3;
|
|
431
|
+
if (store.sessionId) {
|
|
432
|
+
const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds);
|
|
433
|
+
store.indicatorState = ok ? "done" : "error";
|
|
434
|
+
} else {
|
|
435
|
+
store.indicatorState = "error";
|
|
436
|
+
}
|
|
437
|
+
renderIndicatorState(store);
|
|
438
|
+
if (opts.showThanks && store.indicatorRoot && store.indicatorState === "done") {
|
|
439
|
+
showThanksScreen(store.indicatorRoot);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function userTest(options = {}) {
|
|
443
|
+
const merged = {
|
|
444
|
+
queryParam: options.queryParam ?? DEFAULT_OPTIONS.queryParam,
|
|
445
|
+
chunkSeconds: options.chunkSeconds ?? DEFAULT_OPTIONS.chunkSeconds,
|
|
446
|
+
apiUrl: options.apiUrl ?? DEFAULT_OPTIONS.apiUrl,
|
|
447
|
+
testerName: options.testerName ?? DEFAULT_OPTIONS.testerName,
|
|
448
|
+
hideIndicator: options.hideIndicator ?? DEFAULT_OPTIONS.hideIndicator
|
|
449
|
+
};
|
|
450
|
+
return {
|
|
451
|
+
name: "user-test",
|
|
452
|
+
onInit(ctx) {
|
|
453
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
454
|
+
const slug = getTestSlug(merged.queryParam);
|
|
455
|
+
if (!slug) return;
|
|
456
|
+
const apiUrl = merged.apiUrl || ctx.baseUrl || DEFAULT_API_URL;
|
|
457
|
+
const store = {
|
|
458
|
+
cancelled: false,
|
|
459
|
+
slug,
|
|
460
|
+
sessionId: null,
|
|
461
|
+
clientId: null,
|
|
462
|
+
recorder: null,
|
|
463
|
+
stream: null,
|
|
464
|
+
chunkIndex: 0,
|
|
465
|
+
uploadQueue: Promise.resolve(),
|
|
466
|
+
pendingUploads: 0,
|
|
467
|
+
startedAt: Date.now(),
|
|
468
|
+
indicator: null,
|
|
469
|
+
indicatorRoot: null,
|
|
470
|
+
indicatorState: "recording",
|
|
471
|
+
pageHideHandler: null,
|
|
472
|
+
options: { ...merged, apiUrl }
|
|
473
|
+
};
|
|
474
|
+
ctx.setStore(store);
|
|
475
|
+
const onFinish = () => {
|
|
476
|
+
void finishFlow(store, ctx, { showThanks: true });
|
|
477
|
+
};
|
|
478
|
+
if (!merged.hideIndicator) {
|
|
479
|
+
const host = document.createElement("div");
|
|
480
|
+
host.setAttribute("data-usero-user-test", "true");
|
|
481
|
+
document.body.appendChild(host);
|
|
482
|
+
store.indicator = host;
|
|
483
|
+
store.indicatorRoot = buildIndicator(host, store, onFinish);
|
|
484
|
+
renderIndicatorState(store);
|
|
485
|
+
}
|
|
486
|
+
const pageHide = () => {
|
|
487
|
+
void finishFlow(store, ctx, { showThanks: false });
|
|
488
|
+
};
|
|
489
|
+
store.pageHideHandler = pageHide;
|
|
490
|
+
window.addEventListener("pagehide", pageHide);
|
|
491
|
+
void (async () => {
|
|
492
|
+
const created = await createSession(apiUrl, slug, readTesterName(merged.testerName));
|
|
493
|
+
if (store.cancelled) return;
|
|
494
|
+
if (!created) {
|
|
495
|
+
ctx.logger.error("failed to create user-test session");
|
|
496
|
+
store.indicatorState = "error";
|
|
497
|
+
renderIndicatorState(store);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
store.sessionId = created.sessionId;
|
|
501
|
+
store.clientId = created.clientId;
|
|
502
|
+
await startRecording(store, ctx);
|
|
503
|
+
renderIndicatorState(store);
|
|
504
|
+
})();
|
|
505
|
+
},
|
|
506
|
+
onDestroy(ctx) {
|
|
507
|
+
const store = ctx.getStore();
|
|
508
|
+
if (!store) return;
|
|
509
|
+
store.cancelled = true;
|
|
510
|
+
if (store.pageHideHandler) {
|
|
511
|
+
window.removeEventListener("pagehide", store.pageHideHandler);
|
|
512
|
+
store.pageHideHandler = null;
|
|
513
|
+
}
|
|
514
|
+
stopRecording(store);
|
|
515
|
+
if (store.indicator && store.indicator.parentNode) {
|
|
516
|
+
store.indicator.parentNode.removeChild(store.indicator);
|
|
517
|
+
}
|
|
518
|
+
store.indicator = null;
|
|
519
|
+
store.indicatorRoot = null;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
var __test__ = { getTestSlug, pickMimeType, isMediaRecorderSupported };
|
|
524
|
+
|
|
525
|
+
exports.__test__ = __test__;
|
|
526
|
+
exports.userTest = userTest;
|
|
527
|
+
//# sourceMappingURL=user-test.cjs.map
|
|
528
|
+
//# sourceMappingURL=user-test.cjs.map
|