@mobileaidev/ai-app-bridge-web 0.1.0 → 0.3.0-rc.2

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 (5) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +82 -3
  4. package/package.json +4 -2
  5. package/src/index.js +1014 -318
package/src/index.js CHANGED
@@ -5,273 +5,376 @@
5
5
  }
6
6
  root.AiAppBridgeWeb = factory();
7
7
  }(typeof globalThis !== 'undefined' ? globalThis : this, function factory() {
8
- const defaultControlSelector = 'a,button,input,textarea,select,[role],[onclick],[aria-label]';
8
+ const defaultControlSelector = 'a,button,input,textarea,select,[contenteditable="true"],[role],[onclick],[aria-label]';
9
+ const dialogLikeSelector = 'dialog,[role="dialog"],[role="alertdialog"],[aria-modal="true"]';
9
10
  const maxCaptureText = 12000;
10
11
 
12
+ const webProtocol = 'aab.web/v2';
13
+ const executionSchema = 'aab.web-execution/v1';
14
+ const domTargetSchema = 'aab.web-dom-target/v1';
15
+ const captureSchema = 'aab.web-capture/v1';
16
+ const captureStreams = ['logs', 'network', 'state', 'events'];
17
+ const documentTargets = new WeakMap();
18
+ const elementKeys = ['elementId', 'tag', 'id', 'name', 'type', 'role', 'ariaLabel', 'placeholder', 'href', 'text'];
19
+ const textInputTypes = ['text', 'search', 'url', 'tel', 'email', 'password', 'number'];
20
+ const checkedRoles = ['checkbox', 'menuitemcheckbox', 'radio', 'menuitemradio', 'switch', 'option', 'treeitem'];
21
+ const transportLimits = { messageBytes: 256 * 1024, queueBytes: 1024 * 1024, queueCount: 100, queueTtlMs: 60000 };
22
+
11
23
  function createAiAppBridge(options = {}) {
12
24
  const state = {
13
- options,
14
- socket: null,
15
- queue: [],
16
- started: false,
17
- connected: false,
18
- sessionId: options.sessionId || storedSessionId(options.storageKey || 'ai_app_bridge_web_session_id'),
19
- actions: new Map(),
20
- stateProviders: new Map(),
21
- restores: [],
25
+ options, socket: null, binding: null, reconnectTimer: null, queue: [], queueBytes: 0,
26
+ started: false, connected: false, runtimeEpoch: newIdentity(),
27
+ sessionId: options.sessionId ?? storedSessionId(options.storageKey ?? 'ai_app_bridge_web_session_id'),
28
+ actions: new Map(), stateProviders: new Map(), restores: [], active: null, completion: null,
29
+ dropped: 0, rejected: 0, lastReceipt: null, lastError: null, reconnectDelay: 1000,
30
+ captureActionId: null, flushUi: null,
31
+ captures: Object.fromEntries(captureStreams.map(stream => [stream, { sequence: 0, losses: 0, pending: 0 }])),
22
32
  };
23
-
24
- const api = {
25
- start,
26
- disconnect,
27
- recordLog,
28
- recordNetwork,
29
- recordState,
30
- recordEvent,
31
- registerAction,
32
- unregisterAction,
33
- registerStateProvider,
34
- snapshotDom: (snapshotOptions) => snapshotDom(snapshotOptions),
35
- sessionId: () => state.sessionId,
33
+ if (typeof state.sessionId !== 'string' || !state.sessionId || state.sessionId.length > 1024)
34
+ throw new Error('invalid_web_session_id');
35
+ const api = { start, stop: disconnect, disconnect, recordLog, recordNetwork, recordState, recordEvent,
36
+ registerAction, unregisterAction, registerStateProvider, snapshotDom: observeDom,
37
+ sessionId: () => state.sessionId, runtimeEpoch: () => state.runtimeEpoch,
36
38
  isConnected: () => state.connected,
39
+ transportStatus: () => ({ connected: state.connected, queuedRecords: state.queue.length, queuedBytes: state.queueBytes,
40
+ droppedRecords: state.dropped, rejectedRecords: state.rejected, lastReceipt: state.lastReceipt, lastError: state.lastError,
41
+ activeActionId: state.active?.actionId ?? null, completionPending: Boolean(state.completion) }),
37
42
  };
38
43
 
39
44
  function start() {
40
45
  if (state.started) return api;
41
- state.started = true;
42
- installCaptures();
43
- connect();
44
- return api;
46
+ if (typeof options.endpoint !== 'string' || !options.endpoint) throw new Error('AiAppBridge endpoint is required');
47
+ state.started = true; state.restores.push(trackDocument()); installCaptures(); connect(); return api;
45
48
  }
46
-
47
49
  function disconnect() {
48
- state.started = false;
49
- state.connected = false;
50
- for (const restore of state.restores.splice(0)) {
51
- try {
52
- restore();
53
- } catch (_) {
54
- // Best-effort cleanup only.
55
- }
56
- }
57
- if (state.socket) {
58
- try {
59
- state.socket.close();
60
- } catch (_) {
61
- // Ignore close failures.
62
- }
50
+ state.active?.cancel('web_sdk_stopped');
51
+ for (const restore of state.restores.splice(0).reverse()) {
52
+ try { restore(); } catch (error) { state.lastError = error.message; }
63
53
  }
64
- state.socket = null;
54
+ state.started = false; state.connected = false; state.binding = null;
55
+ clearTimeout(state.reconnectTimer); state.reconnectTimer = null;
56
+ const socket = state.socket; state.socket = null;
57
+ if (socket) socket.close();
65
58
  }
66
-
67
59
  function connect() {
68
- const endpoint = options.endpoint;
69
- if (!endpoint) throw new Error('AiAppBridge endpoint is required');
70
- const WebSocketCtor = options.WebSocket || globalValue('WebSocket');
60
+ if (!state.started) return;
61
+ const WebSocketCtor = options.WebSocket ?? globalValue('WebSocket');
71
62
  if (!WebSocketCtor) throw new Error('WebSocket is not available');
72
- const socket = new WebSocketCtor(withToken(endpoint, options.token));
73
- state.socket = socket;
63
+ const socket = new WebSocketCtor(withToken(options.endpoint, options.token)); state.socket = socket;
74
64
  socket.onopen = () => {
75
- state.connected = true;
76
- send({
77
- type: 'hello',
78
- sessionId: state.sessionId,
79
- appName: options.appName || documentTitle(),
80
- url: locationHref(),
81
- origin: locationOrigin(),
82
- route: locationPath(),
83
- targetId: 'main',
84
- capabilities: {
85
- logs: true,
86
- network: true,
87
- state: true,
88
- events: true,
89
- dom: true,
90
- command: true,
91
- },
92
- });
93
- flushQueue();
65
+ if (state.socket !== socket || !state.started) return;
66
+ socket.send(JSON.stringify({ type: 'hello', schemaVersion: webProtocol, sessionId: state.sessionId,
67
+ runtimeEpoch: state.runtimeEpoch, targetId: 'main', executionSchema, domTargetSchema, captureSchema,
68
+ appName: options.appName ?? documentTitle(), url: locationHref(), origin: locationOrigin(), route: locationPath() }));
94
69
  };
95
- socket.onmessage = (event) => handleServerMessage(event.data);
96
- socket.onclose = () => {
97
- state.connected = false;
98
- if (state.started && options.reconnect !== false) {
99
- setTimeout(connect, Number(options.reconnectDelayMs || 1000));
100
- }
70
+ socket.onmessage = event => {
71
+ if (state.socket !== socket || !state.started) return;
72
+ void handleServerMessage(event.data).catch(error => { state.lastError = error.message; disconnect(); });
101
73
  };
102
- socket.onerror = () => {
103
- state.connected = false;
74
+ socket.onclose = event => {
75
+ if (state.socket !== socket) return;
76
+ state.connected = false; state.binding = null; state.socket = null;
77
+ if (event?.code === 1008) { state.lastError = 'web_protocol_rejected'; disconnect(); return; }
78
+ if (state.started && options.reconnect !== false && state.reconnectTimer === null) {
79
+ state.reconnectTimer = setTimeout(() => { state.reconnectTimer = null; connect(); }, state.reconnectDelay);
80
+ state.reconnectDelay = Math.min(state.reconnectDelay * 2, 30000);
81
+ }
104
82
  };
83
+ socket.onerror = () => { if (state.socket === socket) state.lastError = 'web_transport_error'; };
105
84
  }
106
-
107
- function send(payload) {
108
- const text = JSON.stringify(payload);
109
- if (state.socket && state.socket.readyState === 1) {
110
- state.socket.send(text);
111
- return;
112
- }
113
- state.queue.push(text);
114
- while (state.queue.length > 100) state.queue.shift();
85
+ function sendWire(payload) {
86
+ if (!state.connected || !state.binding || state.socket?.readyState !== 1) return false;
87
+ const wire = JSON.stringify({ ...payload, binding: state.binding });
88
+ if (utf8Bytes(wire) > transportLimits.messageBytes) throw new Error('web_message_too_large');
89
+ if ((state.socket.bufferedAmount ?? 0) > transportLimits.queueBytes) { state.lastError = 'web_socket_backpressure'; state.socket.close(); return false; }
90
+ state.socket.send(wire); return true;
115
91
  }
116
-
117
92
  function flushQueue() {
118
- while (state.queue.length && state.socket && state.socket.readyState === 1) {
119
- state.socket.send(state.queue.shift());
93
+ while (state.queue.length) {
94
+ const entry = state.queue[0];
95
+ if (Date.now() - entry.atMs >= transportLimits.queueTtlMs) {
96
+ state.dropped++; state.captures[entry.payload.stream].losses++;
97
+ }
98
+ else if (!sendWire(entry.payload)) break;
99
+ state.queue.shift(); state.queueBytes -= entry.bytes;
120
100
  }
121
101
  }
122
-
123
- function capture(stream, item) {
124
- send({
125
- type: 'capture',
126
- stream,
127
- item: {
128
- source: 'web-sdk',
129
- timestampMs: Date.now(),
130
- url: locationHref(),
131
- route: locationPath(),
132
- ...item,
133
- },
134
- });
102
+ function capture(stream, item, context) {
103
+ const captureId = newIdentity();
104
+ const explicit = Object.hasOwn(context, 'actionId');
105
+ const actionId = explicit ? context.actionId : state.captureActionId;
106
+ const association = actionId == null ? 'unattributed' : context.association ?? (explicit ? 'explicit' : 'synchronous');
107
+ const counters = state.captures[stream];
108
+ const payload = { type: 'capture', captureId, stream, sequence: ++counters.sequence, actionId,
109
+ item: { source: 'web-sdk', timestampMs: Date.now(), url: locationHref(), route: locationPath(), ...item,
110
+ ...(context.pageRef === undefined ? {} : { pageRef: context.pageRef }), association } };
111
+ let bytes;
112
+ try { bytes = utf8Bytes(JSON.stringify(payload)); }
113
+ catch { state.rejected++; counters.losses++; return { accepted: false, stored: false, captureId, error: 'invalid_web_capture_json' }; }
114
+ if (bytes > 128 * 1024 - 4096) { state.rejected++; counters.losses++; return { accepted: false, stored: false, captureId, error: 'web_record_too_large' }; }
115
+ while (state.queue.length && (state.queue.length >= transportLimits.queueCount || state.queueBytes + bytes > transportLimits.queueBytes)) {
116
+ const dropped = state.queue.shift();
117
+ state.queueBytes -= dropped.bytes; state.dropped++; state.captures[dropped.payload.stream].losses++;
118
+ }
119
+ state.queue.push({ payload, bytes, atMs: Date.now() }); state.queueBytes += bytes; flushQueue();
120
+ return { accepted: true, stored: false, captureId, runtimeEpoch: state.runtimeEpoch };
135
121
  }
136
-
137
- function recordLog(level, tag, message, data) {
138
- capture('logs', {
139
- level: level || 'info',
140
- tag: tag || 'web',
141
- message: trimText(message),
142
- data,
143
- source: 'web-sdk',
144
- });
122
+ function recordLog(level, tag, message, data, context = {}) {
123
+ return capture('logs', { level: level ?? 'info', tag: tag ?? 'web', message: trimText(message), data }, context);
145
124
  }
146
-
147
- function recordNetwork(record) {
148
- capture('network', {
149
- method: record.method || 'GET',
150
- url: record.url || '',
151
- statusCode: record.statusCode === undefined ? -1 : record.statusCode,
152
- durationMs: record.durationMs === undefined ? -1 : record.durationMs,
153
- requestHeaders: record.requestHeaders,
154
- responseHeaders: record.responseHeaders,
155
- requestBody: trimText(record.requestBody),
156
- responseBody: trimText(record.responseBody),
157
- error: record.error,
158
- redacted: true,
159
- source: record.source || 'web-sdk',
160
- });
125
+ function recordNetwork(record, context = {}) {
126
+ return capture('network', { method: record.method ?? 'GET', url: record.url ?? '', statusCode: record.statusCode ?? -1,
127
+ durationMs: record.durationMs ?? -1, requestHeaders: record.requestHeaders, responseHeaders: record.responseHeaders,
128
+ requestBody: captureText(record.requestBody, record.requestBodyEncoding), responseBody: captureText(record.responseBody, record.responseBodyEncoding), error: record.error,
129
+ requestBodyState: record.requestBodyState, responseBodyState: record.responseBodyState,
130
+ requestBodyEncoding: record.requestBodyEncoding, responseBodyEncoding: record.responseBodyEncoding,
131
+ redacted: record.redacted === true, source: record.source ?? 'web-sdk' }, context);
161
132
  }
162
-
163
- function recordState(namespace, key, value) {
164
- capture('state', {
165
- namespace: namespace || 'app',
166
- key: key || 'value',
167
- value,
168
- });
133
+ function recordState(namespace, key, value, context = {}) {
134
+ return capture('state', { namespace, key, value }, context);
169
135
  }
170
-
171
- function recordEvent(category, name, data) {
172
- capture('events', {
173
- category: category || 'app',
174
- name: name || 'event',
175
- data,
176
- });
136
+ function recordEvent(category, name, data, context = {}) {
137
+ return capture('events', { category: category ?? 'app', name: name ?? 'event', data }, context);
177
138
  }
178
-
179
139
  function registerAction(name, handler) {
180
- if (!name || typeof handler !== 'function') {
181
- throw new Error('registerAction requires a name and handler');
182
- }
183
- state.actions.set(String(name), handler);
184
- return api;
185
- }
186
-
187
- function unregisterAction(name) {
188
- state.actions.delete(String(name));
189
- return api;
140
+ if (typeof name !== 'string' || !name || name.length > 1024 || typeof handler !== 'function') throw new Error('registerAction requires a name and handler');
141
+ if (!state.actions.has(name) && state.actions.size >= 128) throw new Error('web_action_registration_limit');
142
+ state.actions.set(name, handler); return api;
190
143
  }
191
-
144
+ function unregisterAction(name) { state.actions.delete(name); return api; }
192
145
  function registerStateProvider(name, provider) {
193
- if (!name || typeof provider !== 'function') {
194
- throw new Error('registerStateProvider requires a name and provider');
195
- }
196
- state.stateProviders.set(String(name), provider);
197
- return api;
146
+ if (typeof name !== 'string' || !name || name.length > 1024 || typeof provider !== 'function') throw new Error('registerStateProvider requires a name and provider');
147
+ if (!state.stateProviders.has(name) && state.stateProviders.size >= 128) throw new Error('web_state_provider_limit');
148
+ state.stateProviders.set(name, provider); return api;
198
149
  }
199
-
150
+ function target() { return { sessionId: state.sessionId, runtimeEpoch: state.runtimeEpoch, targetId: 'main' }; }
151
+ function validBinding(binding) {
152
+ return binding && binding.schemaVersion === webProtocol && binding.sessionId === state.sessionId
153
+ && binding.runtimeEpoch === state.runtimeEpoch && binding.targetId === 'main'
154
+ && typeof binding.providerEpoch === 'string' && typeof binding.connectionId === 'string';
155
+ }
156
+ function reply(request, result) { sendWire({ type: 'response', requestId: request.requestId, result }); }
157
+ function sendCompletion() { if (state.completion) sendWire({ type: 'completion', result: state.completion }); }
200
158
  async function handleServerMessage(raw) {
201
- let message;
202
- try {
203
- message = JSON.parse(raw);
204
- } catch (_) {
159
+ if (typeof raw !== 'string' || utf8Bytes(raw) > transportLimits.messageBytes) throw new Error('invalid_web_message');
160
+ const message = JSON.parse(raw);
161
+ if (message.type === 'helloAck') {
162
+ if (state.binding || message.ok !== true || !validBinding(message.binding)) throw new Error('invalid_web_binding');
163
+ state.binding = message.binding; state.connected = true; state.reconnectDelay = 1000;
164
+ sendCompletion(); flushQueue(); return;
165
+ }
166
+ if (!state.binding || JSON.stringify(message.binding) !== JSON.stringify(state.binding)) throw new Error('web_binding_mismatch');
167
+ if (message.type === 'captureAck') {
168
+ state.lastReceipt = { captureId: message.captureId, ...message.receipt };
169
+ if (!message.receipt?.stored) { state.rejected++; state.lastError = message.error; } return;
170
+ }
171
+ if (message.type === 'completionAck') {
172
+ if (state.completion?.actionId === message.actionId && message.stored === true) state.completion = null;
173
+ else if (message.stored !== true) state.lastError = message.error;
205
174
  return;
206
175
  }
207
- if (message.type !== 'command') return;
208
- const command = message.command || {};
209
- try {
210
- const result = await runCommand(command);
211
- send({
212
- type: 'commandResult',
213
- commandId: message.commandId,
214
- ok: result && result.ok === false ? false : true,
215
- result,
216
- });
217
- } catch (error) {
218
- send({
219
- type: 'commandResult',
220
- commandId: message.commandId,
221
- ok: false,
222
- error: error.message || String(error),
223
- });
176
+ if (message.type === 'read') {
177
+ if (message.payload?.name === 'captureBarrier') {
178
+ const stream = message.payload.args?.stream;
179
+ if (!captureStreams.includes(stream)) { reply(message, { ok: false, error: 'invalid_web_capture_stream' }); return; }
180
+ if (stream === 'events' && state.flushUi) state.flushUi();
181
+ flushQueue();
182
+ reply(message, { ok: true, schemaVersion: captureSchema, stream,
183
+ ...state.captures[stream], target: target() });
184
+ return;
185
+ }
186
+ if (message.payload?.name !== 'domSnapshot') { reply(message, { ok: false, error: 'unknown_web_read' }); return; }
187
+ const dom = observeDom(message.payload.args ?? {});
188
+ reply(message, dom.ok ? { ok: true, dom } : dom); return;
224
189
  }
225
- }
226
-
227
- async function runCommand(command) {
228
- const args = command.args || {};
229
- switch (command.name) {
230
- case 'domSnapshot': {
231
- const dom = snapshotDom(args);
232
- send({ type: 'dom', targetId: command.targetId || 'main', dom });
233
- return { ok: true, dom };
190
+ if (message.type === 'cancel') {
191
+ const identity = message.payload;
192
+ if (identity?.runtimeEpoch !== state.runtimeEpoch) { reply(message, { ok: false, error: 'web_target_changed' }); return; }
193
+ if (state.active?.actionId === identity.actionId) state.active.cancel('web_action_cancelled');
194
+ sendCompletion(); reply(message, { ok: true, actionId: identity.actionId, runtimeEpoch: state.runtimeEpoch }); return;
195
+ }
196
+ if (message.type !== 'command') throw new Error('unknown_web_message');
197
+ const request = message.payload, execution = request?.execution;
198
+ const reject = error => reply(message, { ok: false, error, dispatched: false, ambiguous: false });
199
+ if (execution?.schemaVersion !== executionSchema || execution.runtimeEpoch !== state.runtimeEpoch
200
+ || typeof execution.actionId !== 'string' || request.actionId !== execution.actionId
201
+ || !Number.isInteger(execution.timeoutMs) || execution.timeoutMs < 1 || execution.timeoutMs > 2147483647
202
+ || JSON.stringify(request.target) !== JSON.stringify(target())) { reject('invalid_web_execution'); return; }
203
+ if (state.active || state.completion) { sendCompletion(); reject('web_action_busy'); return; }
204
+ const controller = new AbortController();
205
+ const task = { actionId: request.actionId, issued: false, stopReason: null, timer: null, queued: null,
206
+ deadline: performance.now() + execution.timeoutMs,
207
+ cancel(reason) {
208
+ if (state.active !== task || task.stopReason) return;
209
+ task.stopReason = reason; controller.abort(reason);
210
+ if (!task.issued) { clearTimeout(task.queued); finish({ ok: false, error: reason }); }
211
+ },
212
+ check() {
213
+ if (performance.now() >= task.deadline) task.cancel('web_action_timeout');
214
+ if (state.active !== task || task.stopReason || !state.started) throw new Error(task.stopReason ?? 'web_action_not_active');
215
+ },
216
+ permit() { task.check(); task.issued = true; },
217
+ pageRef: () => documentPageRef(target()),
218
+ signal: controller.signal,
219
+ };
220
+ function finish(outcome) {
221
+ if (state.active !== task) return;
222
+ clearTimeout(task.timer); clearTimeout(task.queued);
223
+ let result = { ...outcome, ...(task.stopReason ? { ok: false, error: task.stopReason } : {}),
224
+ actionId: task.actionId, runtimeEpoch: state.runtimeEpoch, settled: true, dispatched: task.issued, ambiguous: false,
225
+ execution: { schemaVersion: executionSchema, actionId: task.actionId, runtimeEpoch: state.runtimeEpoch, target: target(), settled: true } };
226
+ try { if (typeof outcome?.ok !== 'boolean' || (outcome.ok === false && typeof outcome.error !== 'string')
227
+ || utf8Bytes(JSON.stringify(result)) > 64 * 1024) throw new Error(); }
228
+ catch { result = { ok: false, error: 'invalid_web_action_result', actionId: task.actionId,
229
+ runtimeEpoch: state.runtimeEpoch, settled: true, dispatched: task.issued, ambiguous: false,
230
+ execution: { schemaVersion: executionSchema, actionId: task.actionId, runtimeEpoch: state.runtimeEpoch, target: target(), settled: true } }; }
231
+ state.active = null; state.completion = result; sendCompletion();
232
+ }
233
+ state.active = task;
234
+ task.timer = setTimeout(() => task.cancel('web_action_timeout'), execution.timeoutMs);
235
+ task.queued = setTimeout(async () => {
236
+ try {
237
+ task.check();
238
+ let result;
239
+ // This scope covers the synchronous dispatch stack only. Native
240
+ // async/await continuations do not inherit it in current browsers.
241
+ state.captureActionId = task.actionId;
242
+ try { result = runCommand(request.command, task); }
243
+ finally { state.captureActionId = null; }
244
+ finish(await result);
234
245
  }
235
- case 'click':
236
- return clickElement(args);
237
- case 'input':
238
- return inputElement(args);
239
- case 'waitFor':
240
- return waitFor(args);
241
- case 'scroll':
242
- return scrollTarget(args);
246
+ catch (error) { finish({ ok: false, error: error.message || 'web_action_failed',
247
+ ...(error.details === undefined ? {} : { details: error.details }) }); }
248
+ }, 0);
249
+ }
250
+ async function runCommand(command, task) {
251
+ const args = command?.args ?? {};
252
+ switch (command?.name) {
253
+ case 'click': return clickElement(args, task);
254
+ case 'input': return inputElement(args, task);
255
+ case 'key': return keyEvent(args, task);
256
+ case 'waitFor': return waitFor(args, task);
257
+ case 'scroll': return scrollTarget(args, task);
243
258
  case 'state': {
244
259
  const values = {};
245
- for (const [name, provider] of state.stateProviders.entries()) {
246
- values[name] = await provider();
247
- }
260
+ for (const [name, provider] of state.stateProviders) { task.permit(); values[name] = await provider({ signal: task.signal }); task.check(); }
248
261
  return { ok: true, values };
249
262
  }
250
263
  case 'action': {
251
- const actionName = args.name || args.action;
252
- const handler = state.actions.get(String(actionName || ''));
253
- if (!handler) return { ok: false, error: 'action_not_registered', action: actionName || '' };
254
- return { ok: true, value: await handler(args.arguments || args) };
255
- }
256
- default: {
257
- const handler = state.actions.get(String(command.name || ''));
258
- if (!handler) return { ok: false, error: 'unknown_command', command: command.name || '' };
259
- return { ok: true, value: await handler(args) };
264
+ if (typeof args.name !== 'string' || !state.actions.has(args.name)) return { ok: false, error: 'action_not_registered' };
265
+ const context = { signal: task.signal, actionId: task.actionId, runtimeEpoch: state.runtimeEpoch,
266
+ recordState: (namespace, key, value) => recordState(namespace, key, value, { actionId: task.actionId }),
267
+ recordEvent: (category, name, data) => recordEvent(category, name, data, { actionId: task.actionId }) };
268
+ task.permit(); const value = await state.actions.get(args.name)(args.arguments, context);
269
+ return { ok: true, value };
260
270
  }
271
+ default: return { ok: false, error: 'unknown_web_command' };
261
272
  }
262
273
  }
263
-
274
+ function observeDom(options = {}) {
275
+ const dom = snapshotDom(options);
276
+ return dom.ok ? { ...dom, pageRef: documentPageRef(target()) } : dom;
277
+ }
264
278
  function installCaptures() {
265
- const captureOptions = options.capture || {};
279
+ const captureOptions = options.capture ?? {};
266
280
  if (captureOptions.console) installConsoleCapture(state, recordLog);
267
281
  if (captureOptions.errors) installErrorCapture(state, recordLog);
268
282
  if (captureOptions.fetch) installFetchCapture(state, recordNetwork, options);
269
- if (captureOptions.xhr) installXhrCapture(state, recordNetwork);
283
+ if (captureOptions.xhr) installXhrCapture(state, recordNetwork, options);
284
+ if (captureOptions.ui) installUiCapture(state, recordEvent, captureOptions.ui);
270
285
  }
271
-
272
286
  return api;
273
287
  }
274
288
 
289
+ function utf8Bytes(value) { return new TextEncoder().encode(value).byteLength; }
290
+ function newIdentity() {
291
+ const bytes = new Uint8Array(16); globalValue('crypto').getRandomValues(bytes);
292
+ return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
293
+ }
294
+
295
+ function documentTarget(doc) {
296
+ if (!documentTargets.has(doc)) documentTargets.set(doc, { navigationId: newIdentity(), available: true, elements: new WeakMap(), nextElement: 0 });
297
+ return documentTargets.get(doc);
298
+ }
299
+ function documentPageRef(binding) {
300
+ const doc = globalValue('document');
301
+ if (!doc) throw new Error('document_unavailable');
302
+ const state = documentTarget(doc);
303
+ if (!state.available) throw new Error('web_document_inactive');
304
+ return { schemaVersion: domTargetSchema, ...binding, navigationId: state.navigationId, url: locationHref() };
305
+ }
306
+ function trackDocument() {
307
+ const doc = globalValue('document'), win = globalValue('window');
308
+ if (!doc || !win) return () => {};
309
+ const state = documentTarget(doc), restores = [];
310
+ const advance = () => { state.navigationId = newIdentity(); };
311
+ for (const name of ['pushState', 'replaceState']) {
312
+ const original = win.history?.[name];
313
+ if (typeof original !== 'function') continue;
314
+ const wrapper = function (...args) { const result = original.apply(this, args); advance(); return result; };
315
+ win.history[name] = wrapper;
316
+ restores.push(() => { if (win.history[name] === wrapper) win.history[name] = original; });
317
+ }
318
+ const events = { popstate: advance, hashchange: advance,
319
+ pagehide: () => { state.available = false; advance(); }, pageshow: () => { state.available = true; advance(); } };
320
+ if (typeof win.addEventListener === 'function') for (const [name, callback] of Object.entries(events)) {
321
+ win.addEventListener(name, callback); restores.push(() => win.removeEventListener(name, callback));
322
+ }
323
+ return () => { for (const restore of restores) restore(); };
324
+ }
325
+ function elementIdentity(element, doc = element.ownerDocument || globalValue('document')) {
326
+ const state = documentTarget(doc);
327
+ if (!state.elements.has(element)) state.elements.set(element, `e${++state.nextElement}`);
328
+ const sensitive = isSensitiveInput(element);
329
+ return { elementId: state.elements.get(element), tag: text(element.tagName).toLowerCase(), id: text(element.id),
330
+ name: attr(element, 'name'), type: attr(element, 'type'), role: attr(element, 'role'), ariaLabel: attr(element, 'aria-label'),
331
+ placeholder: attr(element, 'placeholder'), href: trimText(element.href, 500),
332
+ text: trimText(sensitive ? attr(element, 'aria-label') || attr(element, 'placeholder')
333
+ : element.innerText || element.value || element.title || attr(element, 'aria-label'), 300) };
334
+ }
335
+ function elementVisible(element) {
336
+ const win = element.ownerDocument?.defaultView || globalValue('window');
337
+ if (!element.isConnected || !win || typeof win.getComputedStyle !== 'function') return false;
338
+ const style = win.getComputedStyle(element), rect = bounds(element);
339
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.visibility !== 'collapse'
340
+ && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
341
+ }
342
+ function elementDisabled(element) {
343
+ return Boolean(element.disabled || attr(element, 'aria-disabled') === 'true' || element.closest?.('[inert]'));
344
+ }
345
+ function elementEditable(element) {
346
+ return !elementDisabled(element) && !element.readOnly && (element.isContentEditable === true
347
+ || element.tagName === 'TEXTAREA' || element.tagName === 'INPUT' && textInputTypes.includes(element.type));
348
+ }
349
+ function elementChecked(element) {
350
+ if (element.tagName === 'INPUT' && ['checkbox', 'radio'].includes(element.type)) {
351
+ return element.type === 'checkbox' && element.indeterminate ? 'mixed' : element.checked;
352
+ }
353
+ const role = attr(element, 'role'), value = element.getAttribute('aria-checked');
354
+ if (!checkedRoles.includes(role)) return null;
355
+ if (value === 'true') return true;
356
+ if (value === 'false') return false;
357
+ // WAI-ARIA defines mixed on radio/menuitemradio/switch as false. The raw
358
+ // attribute is retained separately, including invalid or missing tokens.
359
+ if (value === 'mixed') {
360
+ if (['radio', 'menuitemradio', 'switch'].includes(role)) return false;
361
+ if (['checkbox', 'menuitemcheckbox'].includes(role)) return 'mixed';
362
+ }
363
+ return null;
364
+ }
365
+ function interactionState(element) {
366
+ if (elementDisabled(element)) return { status: 'disabled' };
367
+ if (!elementVisible(element)) return { status: 'hidden' };
368
+ const doc = element.ownerDocument, win = doc.defaultView, rect = bounds(element);
369
+ const left = Math.max(0, rect.left), top = Math.max(0, rect.top), right = Math.min(win.innerWidth, rect.right), bottom = Math.min(win.innerHeight, rect.bottom);
370
+ if (right <= left || bottom <= top) return { status: 'outside-viewport' };
371
+ const point = { x: (left + right) / 2, y: (top + bottom) / 2 };
372
+ const hit = doc.elementFromPoint(point.x, point.y);
373
+ if (hit === element || element.contains(hit)) return { status: 'ready', point };
374
+ return { status: 'obscured', point, hit: hit ? { tag: text(hit.tagName).toLowerCase(), id: text(hit.id),
375
+ role: attr(hit, 'role'), className: typeof hit.className === 'string' ? trimText(hit.className, 300) : '' } : null };
376
+ }
377
+
275
378
  function snapshotDom(options = {}) {
276
379
  const doc = options.document || globalValue('document');
277
380
  if (!doc || !doc.querySelectorAll) {
@@ -280,113 +383,186 @@
280
383
  const selector = options.selector || defaultControlSelector;
281
384
  const maxControls = Number(options.maxControls || 200);
282
385
  const controls = [];
283
- const elements = Array.prototype.slice.call(doc.querySelectorAll(selector), 0, maxControls);
386
+ const matched = doc.querySelectorAll(selector);
387
+ const elements = Array.prototype.slice.call(matched, 0, maxControls);
284
388
  elements.forEach((element, index) => {
389
+ const sensitive = isSensitiveInput(element);
390
+ const elementValue = text(element.value);
285
391
  controls.push({
392
+ ...elementIdentity(element, doc),
286
393
  index,
287
- tag: text(element.tagName).toLowerCase(),
288
- id: text(element.id),
289
- name: attr(element, 'name'),
290
- type: attr(element, 'type'),
291
- role: attr(element, 'role'),
292
- ariaLabel: attr(element, 'aria-label'),
293
- placeholder: attr(element, 'placeholder'),
294
- text: trimText(element.innerText || element.value || element.title || attr(element, 'aria-label'), 300),
295
- href: trimText(element.href, 500),
296
- disabled: Boolean(element.disabled),
394
+ sensitive,
395
+ ...(sensitive ? { valueLength: elementValue.length } : {}),
396
+ disabled: elementDisabled(element),
397
+ visible: elementVisible(element),
398
+ editable: elementEditable(element),
399
+ checked: elementChecked(element),
400
+ ariaChecked: element.getAttribute('aria-checked'),
401
+ interaction: interactionState(element),
297
402
  bounds: bounds(element),
298
403
  });
299
404
  });
300
405
  return {
301
406
  ok: true,
407
+ targetSchema: domTargetSchema,
302
408
  title: text(doc.title),
303
409
  url: locationHref(),
304
410
  readyState: text(doc.readyState),
305
411
  bodyText: trimText(doc.body && doc.body.innerText, 20000),
412
+ bodyTextTruncated: text(doc.body && doc.body.innerText).length > 20000,
306
413
  controls,
307
414
  controlCount: controls.length,
415
+ truncated: matched.length > maxControls,
308
416
  updatedAtMs: Date.now(),
309
417
  };
310
418
  }
311
419
 
312
- function clickElement(args = {}) {
313
- const element = findElement(args);
314
- if (!element) return { ok: false, error: 'element_not_found' };
315
- element.click();
316
- return { ok: true };
420
+ function clickElement(args, task) {
421
+ const selected = actionTarget(args, task);
422
+ ensureTarget(selected, task, { hit: true }); task.permit(); selected.element.click();
423
+ return { ok: true, resolved: selected.ref };
424
+ }
425
+
426
+ function inputElement(args, task) {
427
+ const selected = actionTarget(args, task), element = selected.element;
428
+ if (typeof args.value !== 'string') return { ok: false, error: 'invalid_web_input_value' };
429
+ const richText = element.isContentEditable === true;
430
+ if (!elementEditable(element))
431
+ return { ok: false, error: 'web_element_not_editable' };
432
+ const value = args.value;
433
+ const doc = element.ownerDocument, win = doc.defaultView;
434
+ ensureTarget(selected, task, { hit: true }); task.permit(); element.focus();
435
+ ensureTarget(selected, task, { focus: true });
436
+ if (richText) {
437
+ const range = doc.createRange(); range.selectNodeContents(element);
438
+ const selection = win.getSelection(); task.permit(); selection.removeAllRanges(); selection.addRange(range);
439
+ ensureTarget(selected, task, { focus: true }); task.permit();
440
+ if (!doc.execCommand('insertText', false, value)) return { ok: false, error: 'web_input_rejected', resolved: selected.ref };
441
+ } else {
442
+ const prototype = element.tagName === 'INPUT' ? win.HTMLInputElement.prototype : win.HTMLTextAreaElement.prototype;
443
+ const setter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
444
+ task.permit(); setter.call(element, value);
445
+ ensureTarget(selected, task, { focus: true, edited: true }); task.permit();
446
+ element.dispatchEvent(new win.InputEvent('input', { bubbles: true, inputType: 'insertReplacementText', data: value }));
447
+ ensureTarget(selected, task, { focus: true, edited: true }); task.permit(); element.dispatchEvent(new win.Event('change', { bubbles: true }));
448
+ }
449
+ ensureTarget(selected, task, { edited: true });
450
+ const sensitive = isSensitiveInput(element);
451
+ return {
452
+ ok: true,
453
+ resolved: selected.ref,
454
+ sensitive,
455
+ valueLength: (richText ? element.innerText : element.value).length,
456
+ ...(sensitive ? {} : { value: richText ? element.innerText : element.value }),
457
+ };
317
458
  }
318
459
 
319
- function inputElement(args = {}) {
320
- const element = findElement(args);
321
- if (!element) return { ok: false, error: 'element_not_found' };
322
- const value = args.value === undefined ? '' : String(args.value);
323
- element.focus && element.focus();
324
- element.value = value;
325
- dispatchInputEvents(element);
326
- return { ok: true, value };
460
+ function keyEvent(args, task) {
461
+ if (!['Enter', 'Escape'].includes(args.key)) return { ok: false, error: 'invalid_web_key' };
462
+ const selected = actionTarget(args, task), element = selected.element;
463
+ ensureTarget(selected, task, { hit: true }); task.permit(); element.focus();
464
+ ensureTarget(selected, task, { focus: true });
465
+ const event = new element.ownerDocument.defaultView.KeyboardEvent('keydown', {
466
+ key: args.key, code: args.key, bubbles: true, cancelable: true, composed: true,
467
+ });
468
+ task.permit(); element.dispatchEvent(event);
469
+ return { ok: true, resolved: selected.ref, key: args.key, eventType: 'keydown',
470
+ trusted: event.isTrusted, defaultPrevented: event.defaultPrevented };
327
471
  }
328
472
 
329
- function waitFor(args = {}) {
330
- const timeoutMs = Number(args.timeoutMs || 5000);
331
- const startedAtMs = Date.now();
332
- return new Promise((resolve) => {
473
+ function waitFor(args, task) {
474
+ const timeoutMs = args.timeoutMs ?? 5000;
475
+ const startedAtMs = performance.now();
476
+ return new Promise((resolve, reject) => {
477
+ let timer;
478
+ const finish = (error, result) => { clearTimeout(timer); task.signal.removeEventListener('abort', abort); error ? reject(error) : resolve(result); };
479
+ const abort = () => finish(new Error(task.signal.reason));
333
480
  const tick = () => {
334
- if (args.selector && findElement({ selector: args.selector })) {
335
- resolve({ ok: true, matched: 'selector' });
481
+ try { task.check();
482
+ if (args.selector && findElement(args.selector)) {
483
+ finish(null, { ok: true, matched: 'selector' });
336
484
  return;
337
485
  }
338
486
  if (args.targetText && bodyText().includes(String(args.targetText))) {
339
- resolve({ ok: true, matched: 'text' });
487
+ finish(null, { ok: true, matched: 'text' });
340
488
  return;
341
489
  }
342
- if (Date.now() - startedAtMs >= timeoutMs) {
343
- resolve({ ok: false, error: 'wait_timeout' });
490
+ if (performance.now() - startedAtMs >= timeoutMs) {
491
+ finish(null, { ok: false, error: 'wait_timeout' });
344
492
  return;
345
493
  }
346
- setTimeout(tick, Number(args.intervalMs || 250));
494
+ timer = setTimeout(tick, args.intervalMs ?? 250);
495
+ } catch (error) { finish(error); }
347
496
  };
497
+ task.signal.addEventListener('abort', abort, { once: true });
348
498
  tick();
349
499
  });
350
500
  }
351
501
 
352
- function scrollTarget(args = {}) {
353
- const element = args.selector ? findElement({ selector: args.selector }) : null;
354
- const deltaX = Number(args.deltaX || 0);
355
- const deltaY = Number(args.deltaY === undefined ? args.delta || 400 : args.deltaY);
356
- if (element && element.scrollBy) {
357
- element.scrollBy(deltaX, deltaY);
358
- return { ok: true, target: 'element' };
502
+ function scrollTarget(args, task) {
503
+ const selected = args.selector ? actionTarget(args, task) : null;
504
+ const deltaX = args.deltaX ?? 0;
505
+ const deltaY = args.deltaY ?? 0;
506
+ if (args.mode === 'into-view') {
507
+ if (!selected) return { ok: false, error: 'web_selector_required' };
508
+ ensureTarget(selected, task); task.permit(); selected.element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
509
+ return { ok: true, resolved: selected.ref };
510
+ }
511
+ if (args.mode !== 'by') return { ok: false, error: 'invalid_web_scroll_mode' };
512
+ if (selected) {
513
+ ensureTarget(selected, task); task.permit(); selected.element.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' });
514
+ return { ok: true, resolved: selected.ref };
359
515
  }
360
516
  const win = globalValue('window');
361
517
  if (win && win.scrollBy) {
362
- win.scrollBy(deltaX, deltaY);
518
+ task.permit(); win.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' });
363
519
  return { ok: true, target: 'window' };
364
520
  }
365
521
  return { ok: false, error: 'scroll_unavailable' };
366
522
  }
367
523
 
368
- function findElement(args = {}) {
369
- const doc = args.document || globalValue('document');
524
+ function findElement(selector) {
525
+ const doc = globalValue('document');
370
526
  if (!doc) return null;
371
- if (args.selector) return doc.querySelector(args.selector);
372
- const targetText = args.targetText === undefined ? '' : String(args.targetText);
373
- if (!targetText) return null;
374
- const elements = Array.prototype.slice.call(doc.querySelectorAll(defaultControlSelector), 0, 500);
375
- return elements.find((element) => elementMatchesText(element, targetText, Boolean(args.exact))) || null;
527
+ if (!selector || typeof selector !== 'object') throw new Error('invalid_web_selector');
528
+ const identities = ['elementId', 'text', 'ariaLabel', 'css'].filter(key => typeof selector[key] === 'string' && selector[key].length > 0);
529
+ if (identities.length !== 1) throw new Error('invalid_web_selector');
530
+ const elements = doc.querySelectorAll(selector.css ?? defaultControlSelector);
531
+ if (elements.length > 1000) throw new Error('web_selector_scan_limit');
532
+ const matches = Array.from(elements).filter(element => {
533
+ if (!elementVisible(element)) return false;
534
+ const ref = elementIdentity(element);
535
+ return ['elementId', 'text', 'ariaLabel', 'tag', 'role'].every(key => selector[key] === undefined || selector[key] === ref[key]);
536
+ });
537
+ if (matches.length > 1) throw new Error('web_element_ambiguous');
538
+ return matches[0] ?? null;
376
539
  }
377
540
 
378
- function elementMatchesText(element, targetText, exact) {
379
- const values = [
380
- element.innerText,
381
- element.value,
382
- element.title,
383
- element.id,
384
- attr(element, 'name'),
385
- attr(element, 'role'),
386
- attr(element, 'aria-label'),
387
- attr(element, 'placeholder'),
388
- ].map((value) => text(value)).filter(Boolean);
389
- return values.some((value) => exact ? value === targetText : value.includes(targetText));
541
+ function actionTarget(args, task) {
542
+ const element = findElement(args.selector);
543
+ if (!element) throw new Error('web_element_not_found');
544
+ const ref = { pageRef: task.pageRef(), element: elementIdentity(element) };
545
+ if (args.expectedTarget && (!samePage(ref.pageRef, args.expectedTarget.pageRef)
546
+ || !elementKeys.every(key => ref.element[key] === args.expectedTarget.element[key]))) throw new Error('reobserve_required');
547
+ return { element, ref };
548
+ }
549
+ function samePage(a, b) {
550
+ return ['schemaVersion', 'sessionId', 'runtimeEpoch', 'targetId', 'navigationId', 'url'].every(key => a[key] === b?.[key]);
551
+ }
552
+ function ensureTarget(selected, task, { focus = false, hit = false, edited = false } = {}) {
553
+ const { element, ref } = selected;
554
+ const doc = globalValue('document');
555
+ task.check();
556
+ if (!samePage(task.pageRef(), ref.pageRef)) throw new Error('reobserve_required');
557
+ if (!element.isConnected || element.ownerDocument !== doc || elementDisabled(element) || !elementVisible(element)
558
+ || (focus && doc.activeElement !== element)) throw new Error('web_element_changed');
559
+ const current = elementIdentity(element);
560
+ if (!elementKeys.filter(key => !edited || key !== 'text').every(key => current[key] === ref.element[key])) throw new Error('reobserve_required');
561
+ if (hit) {
562
+ const interaction = interactionState(element);
563
+ if (interaction.status !== 'ready') throw Object.assign(new Error(interaction.status === 'outside-viewport'
564
+ ? 'web_element_outside_viewport' : 'web_element_obscured'), { details: { interaction } });
565
+ }
390
566
  }
391
567
 
392
568
  function installConsoleCapture(state, recordLog) {
@@ -431,38 +607,34 @@
431
607
  const win = globalValue('window');
432
608
  if (!win || typeof win.fetch !== 'function') return;
433
609
  const original = win.fetch;
434
- win.fetch = async function capturedFetch(input, init = {}) {
610
+ win.fetch = function capturedFetch(input, init = {}) {
435
611
  const startedAtMs = Date.now();
436
612
  const method = (init && init.method) || (input && input.method) || 'GET';
437
613
  const url = typeof input === 'string' ? input : (input && input.url) || '';
614
+ const context = captureContext(state);
615
+ state.captures.network.pending++;
616
+ const requestBody = captureRequestBody(input, init, options.captureRequestBodies === true);
617
+ const finish = async (response, error) => {
618
+ try {
619
+ const [request, body] = await Promise.all([requestBody,
620
+ response && options.captureResponseBodies === true
621
+ ? captureResponseBody(response) : Promise.resolve({ state: 'disabled' })]);
622
+ recordNetwork({ source: 'fetch-auto', method, url,
623
+ statusCode: response ? response.status : -1, durationMs: Date.now() - startedAtMs,
624
+ requestBody: request.text, requestBodyState: request.state,
625
+ responseBody: body.text, responseBodyState: body.state,
626
+ requestBodyEncoding: request.encoding, responseBodyEncoding: body.encoding,
627
+ ...(error ? { error: error.message || String(error) } : {}) }, context);
628
+ } finally { state.captures.network.pending--; }
629
+ };
438
630
  try {
439
- const response = await original.apply(this, arguments);
440
- let responseBody;
441
- if (options.captureResponseBodies && response.clone) {
442
- try {
443
- responseBody = await response.clone().text();
444
- } catch (_) {
445
- responseBody = undefined;
446
- }
447
- }
448
- recordNetwork({
449
- source: 'fetch-auto',
450
- method,
451
- url,
452
- statusCode: response.status,
453
- durationMs: Date.now() - startedAtMs,
454
- responseBody,
455
- });
456
- return response;
631
+ return original.apply(this, arguments).then(response => {
632
+ // Body capture cannot delay the App's response or consume its body.
633
+ void finish(response, null);
634
+ return response;
635
+ }, error => { void finish(null, error); throw error; });
457
636
  } catch (error) {
458
- recordNetwork({
459
- source: 'fetch-auto',
460
- method,
461
- url,
462
- statusCode: -1,
463
- durationMs: Date.now() - startedAtMs,
464
- error: error.message || String(error),
465
- });
637
+ void finish(null, error);
466
638
  throw error;
467
639
  }
468
640
  };
@@ -471,7 +643,79 @@
471
643
  });
472
644
  }
473
645
 
474
- function installXhrCapture(state, recordNetwork) {
646
+ function captureContext(state) {
647
+ return { ...actionContext(state),
648
+ pageRef: documentPageRef({ sessionId: state.sessionId, runtimeEpoch: state.runtimeEpoch, targetId: 'main' }) };
649
+ }
650
+
651
+ function actionContext(state) {
652
+ return { actionId: state.captureActionId, association: state.captureActionId === null ? 'unattributed' : 'synchronous' };
653
+ }
654
+
655
+ function captureText(value, encoding) {
656
+ if (value === undefined) return undefined;
657
+ if (typeof value !== 'string') throw new TypeError('Web capture bodies must be strings when supplied.');
658
+ if (encoding === 'base64') {
659
+ if (value.length > Math.ceil(maxCaptureText / 3) * 4) throw new TypeError('Encoded Web bodies exceed the capture limit.');
660
+ return value;
661
+ }
662
+ return trimText(value);
663
+ }
664
+
665
+ async function captureRequestBody(input, init, enabled) {
666
+ if (!enabled) return { state: 'disabled' };
667
+ if (Object.hasOwn(init, 'body')) {
668
+ const body = init.body;
669
+ if (body === null || body === undefined) return { state: 'empty', text: '', encoding: 'utf8' };
670
+ if (typeof body === 'string') return { state: body.length > maxCaptureText ? 'truncated' : 'complete', text: trimText(body), encoding: 'utf8' };
671
+ if (body instanceof URLSearchParams) {
672
+ const text = body.toString();
673
+ return { state: text.length > maxCaptureText ? 'truncated' : 'complete', text: trimText(text), encoding: 'utf8' };
674
+ }
675
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
676
+ const bytes = body instanceof ArrayBuffer ? new Uint8Array(body) : new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
677
+ return { ...encodeCapturedBytes(bytes.subarray(0, maxCaptureText)), state: bytes.length > maxCaptureText ? 'truncated' : 'complete' };
678
+ }
679
+ return { state: 'unsupported-body-type' };
680
+ }
681
+ const Request = globalValue('Request');
682
+ if (typeof Request === 'function' && input instanceof Request) return captureResponseBody(input);
683
+ return { state: 'empty', text: '', encoding: 'utf8' };
684
+ }
685
+
686
+ function encodeCapturedBytes(bytes) {
687
+ // Preserve arbitrary protocol bytes. Encoding is explicit so consumers never
688
+ // mistake a printable Base64 value for the App's original text body.
689
+ let text;
690
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch {}
691
+ if (text !== undefined && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
692
+ return { text, encoding: 'utf8' };
693
+ return { text: btoa(String.fromCharCode(...bytes)), encoding: 'base64' };
694
+ }
695
+
696
+ async function captureResponseBody(response) {
697
+ let reader, timer;
698
+ try {
699
+ const copy = response.clone();
700
+ if (copy.body === null) return { state: 'empty', text: '', encoding: 'utf8' };
701
+ reader = copy.body.getReader();
702
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('capture-body-timeout')), 1000); });
703
+ const chunks = []; let length = 0;
704
+ while (true) {
705
+ const next = await Promise.race([reader.read(), timeout]);
706
+ if (next.done) {
707
+ const bytes = new Uint8Array(length); let offset = 0;
708
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
709
+ return { state: 'complete', ...encodeCapturedBytes(bytes) };
710
+ }
711
+ if (length + next.value.length > maxCaptureText) return { state: 'too-large' };
712
+ chunks.push(next.value); length += next.value.length;
713
+ }
714
+ } catch (error) { return { state: error.message === 'capture-body-timeout' ? 'timeout' : 'unavailable' }; }
715
+ finally { clearTimeout(timer); if (reader) void reader.cancel().catch(() => {}); }
716
+ }
717
+
718
+ function installXhrCapture(state, recordNetwork, options) {
475
719
  const win = globalValue('window');
476
720
  if (!win || !win.XMLHttpRequest) return;
477
721
  const Xhr = win.XMLHttpRequest;
@@ -484,18 +728,29 @@
484
728
  Xhr.prototype.send = function capturedSend(body) {
485
729
  const meta = this.__aiAppBridge || {};
486
730
  const startedAtMs = Date.now();
487
- this.addEventListener('loadend', () => {
488
- recordNetwork({
731
+ const context = captureContext(state);
732
+ state.captures.network.pending++;
733
+ const requestBody = captureRequestBody(null, { body }, options.captureRequestBodies === true);
734
+ const onEnd = () => {
735
+ const response = options.captureResponseBodies !== true ? { state: 'disabled' }
736
+ : this.responseType !== '' && this.responseType !== 'text' ? { state: 'unsupported-body-type' }
737
+ : { text: trimText(this.responseText), state: this.responseText.length > maxCaptureText ? 'truncated' : 'complete', encoding: 'utf8' };
738
+ const record = {
489
739
  source: 'xhr-auto',
490
740
  method: meta.method || 'GET',
491
741
  url: meta.url || '',
492
742
  statusCode: this.status,
493
743
  durationMs: Date.now() - startedAtMs,
494
- requestBody: trimText(body),
495
- responseBody: trimText(this.responseText),
496
- });
497
- });
498
- return originalSend.apply(this, arguments);
744
+ responseBody: response.text, responseBodyState: response.state,
745
+ responseBodyEncoding: response.encoding,
746
+ };
747
+ void requestBody.then(request => {
748
+ recordNetwork({ ...record, requestBody: request.text, requestBodyState: request.state, requestBodyEncoding: request.encoding }, context);
749
+ }).finally(() => { state.captures.network.pending--; });
750
+ };
751
+ this.addEventListener('loadend', onEnd, { once: true });
752
+ try { return originalSend.apply(this, arguments); }
753
+ catch (error) { this.removeEventListener('loadend', onEnd); state.captures.network.pending--; throw error; }
499
754
  };
500
755
  state.restores.push(() => {
501
756
  Xhr.prototype.open = originalOpen;
@@ -503,6 +758,453 @@
503
758
  });
504
759
  }
505
760
 
761
+ function installUiCapture(state, recordEvent, captureOption) {
762
+ const options = captureOption === true ? {} : captureOption;
763
+ if (!options || options.enabled === false) return;
764
+ const doc = options.document || globalValue('document');
765
+ if (!doc || typeof doc.addEventListener !== 'function') return;
766
+ const win = options.window || globalValue('window');
767
+ const pending = [];
768
+ const debounceMs = nonNegativeNumber(options.debounceMs, 80);
769
+ const maxBatchSize = positiveInteger(options.maxBatchSize, 50);
770
+ const maxPendingEvents = positiveInteger(options.maxPendingEvents, 200);
771
+ const fingerprintSalt = randomFingerprintSalt();
772
+ let fingerprintSequence = 0;
773
+ let timer = null;
774
+ let droppedEvents = 0;
775
+ let lastRoute = routeFromLocation(win && win.location);
776
+ let domFingerprint = fingerprintDom(doc, win, options, fingerprintSalt);
777
+ domFingerprint.ref = `ui-fp-${++fingerprintSequence}`;
778
+ const dialogStates = new Map();
779
+ for (const dialog of queryElements(doc, dialogLikeSelector)) {
780
+ dialogStates.set(dialog, isDialogActive(dialog));
781
+ }
782
+
783
+ const scheduleFlush = () => {
784
+ if (timer === null) timer = setTimeout(flush, debounceMs);
785
+ };
786
+ const flush = (drain = false) => {
787
+ if (timer !== null) {
788
+ clearTimeout(timer);
789
+ timer = null;
790
+ }
791
+ if (!pending.length) return;
792
+ do {
793
+ const origin = pending[0];
794
+ const changedOrigin = pending.findIndex(event => event.actionId !== origin.actionId || event.association !== origin.association);
795
+ const count = changedOrigin < 0 ? maxBatchSize : Math.min(maxBatchSize, changedOrigin);
796
+ const events = pending.splice(0, count);
797
+ state.captures.events.pending = pending.length;
798
+ const dropped = droppedEvents;
799
+ droppedEvents = 0;
800
+ const signals = uiBatchSignals(events);
801
+ recordEvent('ui', 'batch', {
802
+ batchSize: events.length,
803
+ droppedEvents: dropped,
804
+ ...signals,
805
+ events,
806
+ }, { actionId: origin.actionId, association: origin.association });
807
+ } while (drain && pending.length);
808
+ if (pending.length) scheduleFlush();
809
+ };
810
+ const enqueue = (event) => {
811
+ if (!state.started) return;
812
+ if (pending.length >= maxPendingEvents) {
813
+ pending.shift();
814
+ droppedEvents += 1;
815
+ state.captures.events.losses++;
816
+ }
817
+ pending.push({
818
+ timestampMs: Date.now(),
819
+ route: routeFromLocation(win && win.location),
820
+ ...event,
821
+ ...actionContext(state),
822
+ });
823
+ state.captures.events.pending = pending.length;
824
+ scheduleFlush();
825
+ };
826
+ state.flushUi = () => flush(true);
827
+ state.restores.push(() => { state.flushUi = null; });
828
+ const onClick = (event) => {
829
+ enqueue({
830
+ type: 'interaction.click',
831
+ target: uiTarget(event && event.target),
832
+ });
833
+ };
834
+ const enqueueValueEvent = (type, event) => {
835
+ const target = event && event.target;
836
+ const value = text(target && target.value);
837
+ const sensitive = isSensitiveInput(target);
838
+ const item = {
839
+ type,
840
+ target: uiTarget(target),
841
+ changed: true,
842
+ length: value.length,
843
+ sensitive,
844
+ };
845
+ if (!sensitive) item.value = trimText(value, 300);
846
+ enqueue(item);
847
+ };
848
+ const onInput = (event) => enqueueValueEvent('interaction.input', event);
849
+ const onChange = (event) => enqueueValueEvent('interaction.change', event);
850
+ const onFocus = (event) => {
851
+ enqueue({
852
+ type: 'interaction.focus',
853
+ target: uiTarget(event && event.target),
854
+ });
855
+ };
856
+ const onRoute = (navigationType) => {
857
+ const nextRoute = routeFromLocation(win && win.location);
858
+ if (nextRoute === lastRoute) return;
859
+ const previousRoute = lastRoute;
860
+ lastRoute = nextRoute;
861
+ enqueue({
862
+ type: 'route.change',
863
+ navigationType,
864
+ fromRoute: previousRoute,
865
+ toRoute: nextRoute,
866
+ });
867
+ };
868
+ const onPopState = () => onRoute('popstate');
869
+ const onHashChange = () => onRoute('hashchange');
870
+ const onMutations = (records) => {
871
+ if (!state.started) return;
872
+ const previousFingerprint = domFingerprint;
873
+ const nextFingerprint = fingerprintDom(doc, win, options, fingerprintSalt);
874
+ const fingerprintChanged = previousFingerprint.hash !== nextFingerprint.hash;
875
+ nextFingerprint.ref = fingerprintChanged
876
+ ? `ui-fp-${++fingerprintSequence}`
877
+ : previousFingerprint.ref;
878
+ domFingerprint = nextFingerprint;
879
+ const mutations = summarizeMutations(records);
880
+ const dialogTransitions = detectDialogTransitions(records, dialogStates);
881
+ enqueue({
882
+ type: 'dom.mutation',
883
+ mutations,
884
+ fingerprint: {
885
+ // Runtime-local opaque references preserve correlation without
886
+ // publishing a content-derived hash that can be dictionary-enumerated.
887
+ beforeHash: previousFingerprint.ref,
888
+ afterHash: nextFingerprint.ref,
889
+ changed: fingerprintChanged,
890
+ },
891
+ diff: {
892
+ elementCount: nextFingerprint.elementCount - previousFingerprint.elementCount,
893
+ controlCount: nextFingerprint.controlCount - previousFingerprint.controlCount,
894
+ dialogCount: nextFingerprint.dialogCount - previousFingerprint.dialogCount,
895
+ activeDialogCount: nextFingerprint.activeDialogCount - previousFingerprint.activeDialogCount,
896
+ bodyTextLength: nextFingerprint.bodyTextLength - previousFingerprint.bodyTextLength,
897
+ },
898
+ });
899
+ for (const transition of dialogTransitions) {
900
+ enqueue({
901
+ type: transition.type,
902
+ dialog: uiTarget(transition.dialog),
903
+ fingerprint: nextFingerprint.ref,
904
+ });
905
+ }
906
+ };
907
+ const historyObject = win && win.history;
908
+ const originalPushState = historyObject && historyObject.pushState;
909
+ const originalReplaceState = historyObject && historyObject.replaceState;
910
+ let capturedPushState;
911
+ let capturedReplaceState;
912
+ if (historyObject && typeof originalPushState === 'function') {
913
+ capturedPushState = function uiCapturedPushState(...args) {
914
+ const result = originalPushState.apply(this, args);
915
+ onRoute('pushState');
916
+ return result;
917
+ };
918
+ historyObject.pushState = capturedPushState;
919
+ }
920
+ if (historyObject && typeof originalReplaceState === 'function') {
921
+ capturedReplaceState = function uiCapturedReplaceState(...args) {
922
+ const result = originalReplaceState.apply(this, args);
923
+ onRoute('replaceState');
924
+ return result;
925
+ };
926
+ historyObject.replaceState = capturedReplaceState;
927
+ }
928
+ const MutationObserverCtor = options.MutationObserver
929
+ || (win && win.MutationObserver)
930
+ || globalValue('MutationObserver');
931
+ let mutationObserver = null;
932
+ if (typeof MutationObserverCtor === 'function' && (doc.documentElement || doc.body)) {
933
+ mutationObserver = new MutationObserverCtor(onMutations);
934
+ mutationObserver.observe(doc.documentElement || doc.body, {
935
+ subtree: true,
936
+ childList: true,
937
+ attributes: true,
938
+ characterData: true,
939
+ });
940
+ }
941
+
942
+ doc.addEventListener('click', onClick, true);
943
+ doc.addEventListener('input', onInput, true);
944
+ doc.addEventListener('change', onChange, true);
945
+ doc.addEventListener('focus', onFocus, true);
946
+ if (win && typeof win.addEventListener === 'function') {
947
+ win.addEventListener('popstate', onPopState);
948
+ win.addEventListener('hashchange', onHashChange);
949
+ }
950
+ state.restores.push(() => {
951
+ doc.removeEventListener('click', onClick, true);
952
+ doc.removeEventListener('input', onInput, true);
953
+ doc.removeEventListener('change', onChange, true);
954
+ doc.removeEventListener('focus', onFocus, true);
955
+ if (win && typeof win.removeEventListener === 'function') {
956
+ win.removeEventListener('popstate', onPopState);
957
+ win.removeEventListener('hashchange', onHashChange);
958
+ }
959
+ if (historyObject && historyObject.pushState === capturedPushState) {
960
+ historyObject.pushState = originalPushState;
961
+ }
962
+ if (historyObject && historyObject.replaceState === capturedReplaceState) {
963
+ historyObject.replaceState = originalReplaceState;
964
+ }
965
+ if (mutationObserver) mutationObserver.disconnect();
966
+ flush(true);
967
+ });
968
+ }
969
+
970
+ function fingerprintDom(doc, win, options, salt) {
971
+ const maxElements = positiveInteger(options.maxFingerprintElements, 200);
972
+ const maxText = positiveInteger(options.maxFingerprintText, 2000);
973
+ const elements = queryElements(doc, '*').slice(0, maxElements);
974
+ const controls = queryElements(doc, defaultControlSelector);
975
+ const dialogs = queryElements(doc, dialogLikeSelector);
976
+ const activeDialogCount = dialogs.filter(isDialogActive).length;
977
+ const bodyTextValue = text(doc && doc.body && doc.body.innerText);
978
+ const structure = elements.map((element) => [
979
+ text(element && element.tagName).toLowerCase(),
980
+ text(element && element.id),
981
+ attr(element, 'role'),
982
+ attr(element, 'aria-label'),
983
+ attr(element, 'class'),
984
+ trimText(attr(element, 'style'), 300),
985
+ attr(element, 'aria-hidden'),
986
+ element && element.hidden ? 'hidden' : '',
987
+ element && element.open ? 'open' : '',
988
+ ].join(':')).join('|');
989
+ const material = [
990
+ text(doc && doc.title),
991
+ routeFromLocation(win && win.location),
992
+ bodyTextValue.slice(0, maxText),
993
+ structure,
994
+ elements.length,
995
+ controls.length,
996
+ dialogs.length,
997
+ activeDialogCount,
998
+ ].join('\n');
999
+ return {
1000
+ hash: hashText(material, salt),
1001
+ elementCount: elements.length,
1002
+ controlCount: controls.length,
1003
+ dialogCount: dialogs.length,
1004
+ activeDialogCount,
1005
+ bodyTextLength: bodyTextValue.length,
1006
+ };
1007
+ }
1008
+
1009
+ function queryElements(doc, selector) {
1010
+ if (!doc || typeof doc.querySelectorAll !== 'function') return [];
1011
+ try {
1012
+ return Array.prototype.slice.call(doc.querySelectorAll(selector));
1013
+ } catch (_) {
1014
+ return [];
1015
+ }
1016
+ }
1017
+
1018
+ function summarizeMutations(records) {
1019
+ const attributeNames = new Set();
1020
+ const summary = {
1021
+ records: 0,
1022
+ addedNodes: 0,
1023
+ removedNodes: 0,
1024
+ attributeChanges: 0,
1025
+ textChanges: 0,
1026
+ };
1027
+ for (const record of Array.prototype.slice.call(records || [])) {
1028
+ summary.records += 1;
1029
+ if (record.type === 'attributes') {
1030
+ summary.attributeChanges += 1;
1031
+ if (record.attributeName) attributeNames.add(trimText(record.attributeName, 80));
1032
+ }
1033
+ if (record.type === 'characterData') summary.textChanges += 1;
1034
+ const added = Array.prototype.slice.call(record.addedNodes || []);
1035
+ const removed = Array.prototype.slice.call(record.removedNodes || []);
1036
+ summary.addedNodes += added.length;
1037
+ summary.removedNodes += removed.length;
1038
+ }
1039
+ const allAttributeNames = Array.from(attributeNames).sort();
1040
+ summary.attributeNames = allAttributeNames.slice(0, 20);
1041
+ summary.attributeNamesTruncated = Math.max(0, allAttributeNames.length - summary.attributeNames.length);
1042
+ return summary;
1043
+ }
1044
+
1045
+ function uiBatchSignals(events) {
1046
+ let semanticChanged = false;
1047
+ let renderChanged = false;
1048
+ let interactionObserved = false;
1049
+ for (const event of events) {
1050
+ const type = text(event && event.type).toLowerCase();
1051
+ if (type.startsWith('interaction.')) interactionObserved = true;
1052
+ if (type === 'route.change') semanticChanged = true;
1053
+ if (type.startsWith('dialog.')) {
1054
+ semanticChanged = true;
1055
+ renderChanged = true;
1056
+ }
1057
+ if (type === 'dom.mutation') {
1058
+ const mutations = event.mutations || {};
1059
+ const attributes = Array.isArray(mutations.attributeNames)
1060
+ ? mutations.attributeNames.map((name) => text(name).toLowerCase())
1061
+ : [];
1062
+ const structural = Number(mutations.addedNodes || 0) > 0
1063
+ || Number(mutations.removedNodes || 0) > 0
1064
+ || Number(mutations.textChanges || 0) > 0;
1065
+ const hasSemanticAttribute = attributes.some((name) => !['style', 'class'].includes(name));
1066
+ if (structural || hasSemanticAttribute) semanticChanged = true;
1067
+ if (event.fingerprint?.changed || Number(mutations.attributeChanges || 0) > 0 || structural) {
1068
+ renderChanged = true;
1069
+ }
1070
+ }
1071
+ }
1072
+ return { semanticChanged, renderChanged, interactionObserved };
1073
+ }
1074
+
1075
+ function dialogLikeNodes(nodes) {
1076
+ const found = [];
1077
+ const seen = new Set();
1078
+ const visit = (node) => {
1079
+ if (!node || seen.has(node)) return;
1080
+ seen.add(node);
1081
+ if (isDialogLike(node)) found.push(node);
1082
+ if (typeof node.querySelectorAll === 'function') {
1083
+ for (const child of queryElements(node, dialogLikeSelector)) visit(child);
1084
+ }
1085
+ };
1086
+ for (const node of nodes || []) visit(node);
1087
+ return found;
1088
+ }
1089
+
1090
+ function detectDialogTransitions(records, dialogStates) {
1091
+ const transitions = [];
1092
+ for (const record of Array.prototype.slice.call(records || [])) {
1093
+ if (record.type === 'childList') {
1094
+ for (const dialog of dialogLikeNodes(Array.prototype.slice.call(record.removedNodes || []))) {
1095
+ const wasActive = dialogStates.has(dialog)
1096
+ ? dialogStates.get(dialog)
1097
+ : isDialogActive(dialog);
1098
+ if (wasActive) transitions.push({ type: 'dialog.close', dialog });
1099
+ dialogStates.delete(dialog);
1100
+ }
1101
+ for (const dialog of dialogLikeNodes(Array.prototype.slice.call(record.addedNodes || []))) {
1102
+ const active = isDialogActive(dialog);
1103
+ if (active) transitions.push({ type: 'dialog.open', dialog });
1104
+ dialogStates.set(dialog, active);
1105
+ }
1106
+ }
1107
+ if (record.type === 'attributes') {
1108
+ const dialog = record.target;
1109
+ const dialogLike = isDialogLike(dialog);
1110
+ if (!dialogLike && !dialogStates.has(dialog)) continue;
1111
+ const wasActive = dialogStates.has(dialog) ? dialogStates.get(dialog) : false;
1112
+ const active = dialogLike && isDialogActive(dialog);
1113
+ if (active !== wasActive) {
1114
+ transitions.push({ type: active ? 'dialog.open' : 'dialog.close', dialog });
1115
+ }
1116
+ if (dialogLike) dialogStates.set(dialog, active);
1117
+ else dialogStates.delete(dialog);
1118
+ }
1119
+ }
1120
+ return transitions;
1121
+ }
1122
+
1123
+ function isDialogLike(element) {
1124
+ const tag = text(element && element.tagName).toLowerCase();
1125
+ const role = attr(element, 'role').toLowerCase();
1126
+ const ariaModal = attr(element, 'aria-modal').toLowerCase();
1127
+ const className = attr(element, 'class').toLowerCase();
1128
+ return tag === 'dialog'
1129
+ || role === 'dialog'
1130
+ || role === 'alertdialog'
1131
+ || ariaModal === 'true'
1132
+ || /(^|\s)(modal|dialog)(\s|$)/.test(className);
1133
+ }
1134
+
1135
+ function isDialogActive(element) {
1136
+ if (!element || element.hidden || attr(element, 'aria-hidden').toLowerCase() === 'true') return false;
1137
+ const style = attr(element, 'style').toLowerCase();
1138
+ if (/display\s*:\s*none|visibility\s*:\s*hidden/.test(style)) return false;
1139
+ if (text(element.tagName).toLowerCase() === 'dialog' && typeof element.open === 'boolean') {
1140
+ return element.open;
1141
+ }
1142
+ return true;
1143
+ }
1144
+
1145
+ function hashText(value, salt = '') {
1146
+ const raw = `${salt}\u0000${text(value)}`;
1147
+ let first = 2166136261;
1148
+ let second = 2246822507;
1149
+ for (let index = 0; index < raw.length; index += 1) {
1150
+ const code = raw.charCodeAt(index);
1151
+ first ^= code;
1152
+ first = Math.imul(first, 16777619);
1153
+ second ^= code;
1154
+ second = Math.imul(second, 3266489909);
1155
+ }
1156
+ return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
1157
+ }
1158
+
1159
+ function randomFingerprintSalt() {
1160
+ const cryptoObject = globalValue('crypto');
1161
+ if (cryptoObject && typeof cryptoObject.getRandomValues === 'function') {
1162
+ const values = new Uint32Array(4);
1163
+ cryptoObject.getRandomValues(values);
1164
+ return Array.from(values, (value) => value.toString(16).padStart(8, '0')).join('');
1165
+ }
1166
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
1167
+ }
1168
+
1169
+ function positiveInteger(value, fallback) {
1170
+ const number = value === undefined ? fallback : Number(value);
1171
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
1172
+ }
1173
+
1174
+ function nonNegativeNumber(value, fallback) {
1175
+ const number = value === undefined ? fallback : Number(value);
1176
+ return Number.isFinite(number) && number >= 0 ? number : fallback;
1177
+ }
1178
+
1179
+ function uiTarget(element) {
1180
+ return {
1181
+ tag: text(element && element.tagName).toLowerCase(),
1182
+ id: text(element && element.id),
1183
+ name: attr(element, 'name'),
1184
+ type: attr(element, 'type'),
1185
+ role: attr(element, 'role'),
1186
+ ariaLabel: attr(element, 'aria-label'),
1187
+ text: trimText(element && (element.innerText || element.title || attr(element, 'aria-label')), 160),
1188
+ disabled: Boolean(element && element.disabled),
1189
+ bounds: bounds(element),
1190
+ };
1191
+ }
1192
+
1193
+ function isSensitiveInput(element) {
1194
+ const values = [
1195
+ attr(element, 'type'),
1196
+ attr(element, 'autocomplete'),
1197
+ attr(element, 'name'),
1198
+ text(element && element.id),
1199
+ ].join(' ').toLowerCase();
1200
+ return /password|passwd|pwd|passcode|current-password|new-password/.test(values);
1201
+ }
1202
+
1203
+ function routeFromLocation(location) {
1204
+ if (!location) return '';
1205
+ return `${location.pathname || ''}${location.search || ''}${location.hash || ''}`;
1206
+ }
1207
+
506
1208
  function withToken(endpoint, token) {
507
1209
  if (!token) return endpoint;
508
1210
  const base = locationHref() || 'http://127.0.0.1';
@@ -513,20 +1215,14 @@
513
1215
 
514
1216
  function storedSessionId(key) {
515
1217
  const storage = globalValue('sessionStorage');
516
- const existing = storage && storage.getItem(key);
1218
+ if (!storage) throw new Error('web_session_storage_unavailable: supply sessionId');
1219
+ const existing = storage.getItem(key);
517
1220
  if (existing) return existing;
518
- const next = `web-${Math.random().toString(36).slice(2)}-${Date.now()}`;
519
- if (storage) storage.setItem(key, next);
1221
+ const next = newIdentity();
1222
+ storage.setItem(key, next);
520
1223
  return next;
521
1224
  }
522
1225
 
523
- function dispatchInputEvents(element) {
524
- const win = globalValue('window');
525
- if (!win || typeof win.Event !== 'function') return;
526
- element.dispatchEvent(new win.Event('input', { bubbles: true }));
527
- element.dispatchEvent(new win.Event('change', { bubbles: true }));
528
- }
529
-
530
1226
  function bounds(element) {
531
1227
  if (!element || !element.getBoundingClientRect) {
532
1228
  return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };