@diegotsi/flint-core 0.1.2 → 0.2.0

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/index.cjs CHANGED
@@ -20,8 +20,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ Flint: () => Flint,
24
+ _setFormErrorCollector: () => _setFormErrorCollector,
23
25
  collectEnvironment: () => collectEnvironment,
24
26
  createConsoleCollector: () => createConsoleCollector,
27
+ createFormErrorCollector: () => createFormErrorCollector,
25
28
  createFrustrationCollector: () => createFrustrationCollector,
26
29
  createNetworkCollector: () => createNetworkCollector,
27
30
  flint: () => flint,
@@ -35,10 +38,10 @@ module.exports = __toCommonJS(index_exports);
35
38
 
36
39
  // src/api.ts
37
40
  var import_fflate = require("fflate");
38
- async function fetchWithRetry(url, init, retries = 3, baseDelay = 1e3) {
41
+ async function fetchWithRetry(url, init2, retries = 3, baseDelay = 1e3) {
39
42
  for (let attempt = 0; attempt <= retries; attempt++) {
40
43
  try {
41
- const res = await fetch(url, init);
44
+ const res = await fetch(url, init2);
42
45
  if (res.ok || attempt === retries) return res;
43
46
  if (res.status >= 400 && res.status < 500 && res.status !== 429) return res;
44
47
  } catch (err) {
@@ -215,6 +218,236 @@ function collectEnvironment() {
215
218
  };
216
219
  }
217
220
 
221
+ // src/collectors/formErrors.ts
222
+ var MAX_ENTRIES2 = 30;
223
+ var POST_SUBMIT_CHECK_MS = 300;
224
+ var SILENT_SUBMIT_WINDOW_MS = 400;
225
+ function getFormId(form) {
226
+ if (form.id) return `#${form.id}`;
227
+ if (form.getAttribute("name")) return `form[name="${form.getAttribute("name")}"]`;
228
+ if (form.action && form.action !== location.href) return form.action;
229
+ return `form:${Array.from(document.forms).indexOf(form)}`;
230
+ }
231
+ function getFieldLabel(field, root) {
232
+ const ariaLabel = field.getAttribute("aria-label");
233
+ if (ariaLabel) return ariaLabel;
234
+ const id = field.id;
235
+ if (id) {
236
+ const label = root.querySelector(`label[for="${id}"]`);
237
+ if (label?.textContent) return label.textContent.trim();
238
+ }
239
+ const parentLabel = field.closest("label");
240
+ if (parentLabel?.textContent) {
241
+ const text = parentLabel.textContent.trim();
242
+ if (text.length < 60) return text;
243
+ }
244
+ return field.name || field.placeholder || field.tagName.toLowerCase();
245
+ }
246
+ function getErrorMessage(field) {
247
+ const errId = field.getAttribute("aria-errormessage");
248
+ if (errId) {
249
+ const el = document.getElementById(errId);
250
+ if (el?.textContent) return el.textContent.trim();
251
+ }
252
+ const descId = field.getAttribute("aria-describedby");
253
+ if (descId) {
254
+ for (const id of descId.split(/\s+/)) {
255
+ const el = document.getElementById(id);
256
+ if (el?.textContent?.trim()) return el.textContent.trim();
257
+ }
258
+ }
259
+ if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement || field instanceof HTMLSelectElement) {
260
+ if (field.validationMessage) return field.validationMessage;
261
+ }
262
+ const next = field.nextElementSibling;
263
+ if (next?.getAttribute("role") === "alert" && next.textContent) {
264
+ return next.textContent.trim();
265
+ }
266
+ return void 0;
267
+ }
268
+ function collectInvalidFields(root) {
269
+ const fields = [];
270
+ const invalidEls = root.querySelectorAll('[aria-invalid="true"], :invalid');
271
+ for (const el of invalidEls) {
272
+ if (el instanceof HTMLFormElement) continue;
273
+ if (el.tagName === "FIELDSET") continue;
274
+ fields.push({
275
+ name: getFieldLabel(el, root),
276
+ message: getErrorMessage(el)
277
+ });
278
+ }
279
+ return fields;
280
+ }
281
+ function collectVisibleErrors(root) {
282
+ const fields = [];
283
+ const seen = /* @__PURE__ */ new Set();
284
+ const alerts = root.querySelectorAll('[role="alert"]');
285
+ for (const el of alerts) {
286
+ const text = el.textContent?.trim();
287
+ if (!text || text.length > 200) continue;
288
+ if (seen.has(text)) continue;
289
+ seen.add(text);
290
+ fields.push({ name: "alert", message: text });
291
+ }
292
+ const errorEls = root.querySelectorAll(
293
+ "[data-error], [data-field-error], .error-message, .field-error, .form-error"
294
+ );
295
+ for (const el of errorEls) {
296
+ const text = el.textContent?.trim();
297
+ if (!text || text.length > 200) continue;
298
+ if (seen.has(text)) continue;
299
+ seen.add(text);
300
+ fields.push({ name: "error", message: text });
301
+ }
302
+ return fields;
303
+ }
304
+ function isSubmitButton(el) {
305
+ if (el.tagName === "BUTTON") {
306
+ const type = el.getAttribute("type");
307
+ return type === "submit" || type === null || type === "";
308
+ }
309
+ if (el.tagName === "INPUT") {
310
+ return el.type === "submit";
311
+ }
312
+ return false;
313
+ }
314
+ function findClosestForm(el) {
315
+ if ("form" in el && el.form) {
316
+ return el.form;
317
+ }
318
+ return el.closest("form");
319
+ }
320
+ function createFormErrorCollector() {
321
+ const entries = [];
322
+ const attemptCounts = /* @__PURE__ */ new Map();
323
+ let active = false;
324
+ let submitHandler = null;
325
+ let invalidHandler = null;
326
+ let clickHandler = null;
327
+ let lastSubmitTime = 0;
328
+ const recentRecords = /* @__PURE__ */ new Map();
329
+ const DEDUP_MS = 500;
330
+ function push(entry) {
331
+ const key = `${entry.formId}:${entry.timestamp}`;
332
+ const last = recentRecords.get(entry.formId);
333
+ if (last && entry.timestamp - last < DEDUP_MS) return;
334
+ recentRecords.set(entry.formId, entry.timestamp);
335
+ entries.push(entry);
336
+ if (entries.length > MAX_ENTRIES2) entries.shift();
337
+ }
338
+ function recordFormErrors(form, type) {
339
+ const formId = getFormId(form);
340
+ let fields = collectInvalidFields(form);
341
+ if (fields.length === 0) {
342
+ fields = collectVisibleErrors(form);
343
+ }
344
+ if (fields.length === 0) return;
345
+ const count = (attemptCounts.get(formId) ?? 0) + 1;
346
+ attemptCounts.set(formId, count);
347
+ push({
348
+ type,
349
+ formId,
350
+ fields,
351
+ attemptNumber: count,
352
+ url: location.href,
353
+ timestamp: Date.now()
354
+ });
355
+ }
356
+ function handleSubmitButtonClick(button) {
357
+ const form = findClosestForm(button);
358
+ setTimeout(() => {
359
+ if (!active) return;
360
+ if (Date.now() - lastSubmitTime < SILENT_SUBMIT_WINDOW_MS) return;
361
+ const root = form ?? document.body;
362
+ const formId = form ? getFormId(form) : `page:${location.pathname}`;
363
+ let fields = form ? collectInvalidFields(form) : [];
364
+ if (fields.length === 0) {
365
+ fields = collectVisibleErrors(root);
366
+ }
367
+ if (fields.length === 0 && form) {
368
+ fields = collectVisibleErrors(document.body);
369
+ }
370
+ if (fields.length === 0) return;
371
+ const count = (attemptCounts.get(formId) ?? 0) + 1;
372
+ attemptCounts.set(formId, count);
373
+ push({
374
+ type: "silent_submit",
375
+ formId,
376
+ fields,
377
+ attemptNumber: count,
378
+ url: location.href,
379
+ timestamp: Date.now()
380
+ });
381
+ }, SILENT_SUBMIT_WINDOW_MS);
382
+ }
383
+ return {
384
+ start() {
385
+ if (active) return;
386
+ active = true;
387
+ submitHandler = (e) => {
388
+ lastSubmitTime = Date.now();
389
+ const form = e.target;
390
+ if (!(form instanceof HTMLFormElement)) return;
391
+ setTimeout(() => {
392
+ if (!active) return;
393
+ recordFormErrors(form, "validation_failed");
394
+ }, POST_SUBMIT_CHECK_MS);
395
+ };
396
+ document.addEventListener("submit", submitHandler, true);
397
+ invalidHandler = (e) => {
398
+ lastSubmitTime = Date.now();
399
+ const field = e.target;
400
+ const form = field.closest("form");
401
+ if (!form) return;
402
+ setTimeout(() => {
403
+ if (!active) return;
404
+ recordFormErrors(form, "validation_failed");
405
+ }, POST_SUBMIT_CHECK_MS);
406
+ };
407
+ document.addEventListener("invalid", invalidHandler, true);
408
+ clickHandler = (e) => {
409
+ const target = e.target;
410
+ if (!target) return;
411
+ const button = target.closest("button, input[type=submit]");
412
+ if (!button) return;
413
+ if (!isSubmitButton(button)) return;
414
+ handleSubmitButtonClick(button);
415
+ };
416
+ document.addEventListener("click", clickHandler, true);
417
+ },
418
+ stop() {
419
+ if (!active) return;
420
+ active = false;
421
+ if (submitHandler) document.removeEventListener("submit", submitHandler, true);
422
+ if (invalidHandler) document.removeEventListener("invalid", invalidHandler, true);
423
+ if (clickHandler) document.removeEventListener("click", clickHandler, true);
424
+ attemptCounts.clear();
425
+ recentRecords.clear();
426
+ },
427
+ getEntries() {
428
+ return [...entries];
429
+ },
430
+ report(formId, errors) {
431
+ const fields = [];
432
+ for (const [name, err] of Object.entries(errors)) {
433
+ if (!err) continue;
434
+ fields.push({ name, message: err.message });
435
+ }
436
+ if (fields.length === 0) return;
437
+ const count = (attemptCounts.get(formId) ?? 0) + 1;
438
+ attemptCounts.set(formId, count);
439
+ push({
440
+ type: "validation_failed",
441
+ formId,
442
+ fields,
443
+ attemptNumber: count,
444
+ url: location.href,
445
+ timestamp: Date.now()
446
+ });
447
+ }
448
+ };
449
+ }
450
+
218
451
  // src/collectors/frustration.ts
219
452
  function createFrustrationCollector(opts) {
220
453
  const threshold = opts?.rageClickThreshold ?? 3;
@@ -328,7 +561,7 @@ function createFrustrationCollector(opts) {
328
561
  }
329
562
 
330
563
  // src/collectors/network.ts
331
- var MAX_ENTRIES2 = 50;
564
+ var MAX_ENTRIES3 = 50;
332
565
  var BLOCKED_HOSTS = /* @__PURE__ */ new Set([
333
566
  "browser-intake-datadoghq.com",
334
567
  "rum.browser-intake-datadoghq.com",
@@ -361,18 +594,18 @@ function createNetworkCollector(extraBlockedHosts = []) {
361
594
  let active = false;
362
595
  function push(entry) {
363
596
  entries.push(entry);
364
- if (entries.length > MAX_ENTRIES2) entries.shift();
597
+ if (entries.length > MAX_ENTRIES3) entries.shift();
365
598
  }
366
599
  return {
367
600
  start() {
368
601
  if (active) return;
369
602
  active = true;
370
603
  origFetch = window.fetch;
371
- window.fetch = async (input, init) => {
372
- const method = (init?.method ?? "GET").toUpperCase();
604
+ window.fetch = async (input, init2) => {
605
+ const method = (init2?.method ?? "GET").toUpperCase();
373
606
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
374
607
  const startTime = Date.now();
375
- const res = await origFetch.call(window, input, init);
608
+ const res = await origFetch.call(window, input, init2);
376
609
  if (res.status >= 400 && !isBlockedUrl(url, blocked)) {
377
610
  push({
378
611
  method,
@@ -415,6 +648,10 @@ function createNetworkCollector(extraBlockedHosts = []) {
415
648
  }
416
649
 
417
650
  // src/store.ts
651
+ var formErrorCollectorRef = null;
652
+ function _setFormErrorCollector(collector) {
653
+ formErrorCollectorRef = collector;
654
+ }
418
655
  var state = { user: void 0, sessionReplay: void 0 };
419
656
  var listeners = /* @__PURE__ */ new Set();
420
657
  function emit() {
@@ -435,7 +672,155 @@ var flint = {
435
672
  setSessionReplay(url) {
436
673
  state = { ...state, sessionReplay: url ?? void 0 };
437
674
  emit();
675
+ },
676
+ /**
677
+ * Report a form validation failure with exact field-level errors.
678
+ * Call this from your form library's error callback.
679
+ *
680
+ * @example
681
+ * // React Hook Form + Zod
682
+ * const onSubmit = handleSubmit(onValid, (errors) => {
683
+ * flint.reportFormError('checkout-form', errors);
684
+ * });
685
+ */
686
+ reportFormError(formId, errors) {
687
+ formErrorCollectorRef?.report(formId, errors);
688
+ }
689
+ };
690
+
691
+ // src/singleton.ts
692
+ var DEFAULT_REPLAY_BUFFER_MS = 6e4;
693
+ var instance = null;
694
+ function init(config) {
695
+ if (instance) {
696
+ console.warn("[Flint] Already initialized. Call Flint.shutdown() first to re-initialize.");
697
+ return;
438
698
  }
699
+ const {
700
+ enableConsole = true,
701
+ enableNetwork = true,
702
+ enableFormErrors = true,
703
+ enableFrustration = false,
704
+ autoReportFrustration = false,
705
+ enableReplay = false,
706
+ replayBufferMs = DEFAULT_REPLAY_BUFFER_MS,
707
+ blockedHosts = [],
708
+ frustration: frustrationOpts,
709
+ onFrustration,
710
+ _replayRecorder
711
+ } = config;
712
+ const flintHost = (() => {
713
+ try {
714
+ return new URL(config.serverUrl).hostname;
715
+ } catch {
716
+ return "";
717
+ }
718
+ })();
719
+ const consoleCol = enableConsole ? createConsoleCollector() : null;
720
+ consoleCol?.start();
721
+ const networkCol = enableNetwork ? createNetworkCollector([...blockedHosts, ...flintHost ? [flintHost] : []]) : null;
722
+ networkCol?.start();
723
+ const formErrorsCol = enableFormErrors ? createFormErrorCollector() : null;
724
+ if (formErrorsCol) {
725
+ formErrorsCol.start();
726
+ _setFormErrorCollector(formErrorsCol);
727
+ }
728
+ const frustrationCol = enableFrustration ? createFrustrationCollector(frustrationOpts) : null;
729
+ frustrationCol?.start();
730
+ if (config.user) {
731
+ flint.setUser(config.user);
732
+ }
733
+ const replayEvents = [];
734
+ let stopReplay = null;
735
+ instance = {
736
+ config,
737
+ console: consoleCol,
738
+ network: networkCol,
739
+ formErrors: formErrorsCol,
740
+ frustration: frustrationCol,
741
+ replayEvents,
742
+ stopReplay: null
743
+ };
744
+ if (enableReplay && _replayRecorder) {
745
+ _replayRecorder((event) => {
746
+ replayEvents.push(event);
747
+ const cutoff = Date.now() - replayBufferMs;
748
+ while (replayEvents.length > 0 && replayEvents[0].timestamp < cutoff) {
749
+ replayEvents.shift();
750
+ }
751
+ }).then((stop) => {
752
+ stopReplay = stop ?? null;
753
+ if (instance) instance.stopReplay = stopReplay;
754
+ });
755
+ }
756
+ if (frustrationCol && autoReportFrustration) {
757
+ frustrationCol.onFrustration(async (event) => {
758
+ onFrustration?.(event);
759
+ const user = getSnapshot().user ?? config.user;
760
+ await submitReport(config.serverUrl, config.projectKey, {
761
+ reporterId: user?.id ?? "anonymous",
762
+ reporterName: user?.name ?? "Anonymous",
763
+ reporterEmail: user?.email,
764
+ description: `[Auto-detected] ${event.type.replace(/_/g, " ")}: ${event.details}`,
765
+ severity: event.type === "error_loop" ? "P1" : event.type === "rage_click" ? "P2" : "P3",
766
+ url: event.url,
767
+ meta: {
768
+ ...config.meta,
769
+ environment: collectEnvironment(),
770
+ consoleLogs: consoleCol?.getEntries() ?? [],
771
+ networkErrors: networkCol?.getEntries() ?? [],
772
+ formErrors: formErrorsCol?.getEntries() ?? [],
773
+ frustrationEvent: event
774
+ }
775
+ }).catch(() => {
776
+ });
777
+ });
778
+ } else if (frustrationCol && onFrustration) {
779
+ frustrationCol.onFrustration(onFrustration);
780
+ }
781
+ }
782
+ function shutdown() {
783
+ if (!instance) return;
784
+ instance.console?.stop();
785
+ instance.network?.stop();
786
+ instance.formErrors?.stop();
787
+ _setFormErrorCollector(null);
788
+ instance.frustration?.stop();
789
+ instance.stopReplay?.();
790
+ instance = null;
791
+ }
792
+ function isInitialized() {
793
+ return instance !== null;
794
+ }
795
+ function getInstance() {
796
+ return instance;
797
+ }
798
+ function getMeta(extraMeta) {
799
+ return {
800
+ ...extraMeta,
801
+ environment: collectEnvironment(),
802
+ consoleLogs: instance?.console?.getEntries() ?? [],
803
+ networkErrors: instance?.network?.getEntries() ?? [],
804
+ formErrors: instance?.formErrors?.getEntries() ?? []
805
+ };
806
+ }
807
+ function getReplayEvents() {
808
+ return instance ? [...instance.replayEvents] : [];
809
+ }
810
+ function getConfig() {
811
+ return instance?.config ?? null;
812
+ }
813
+ var Flint = {
814
+ init,
815
+ shutdown,
816
+ isInitialized,
817
+ getInstance,
818
+ getMeta,
819
+ getReplayEvents,
820
+ getConfig,
821
+ setUser: flint.setUser,
822
+ setSessionReplay: flint.setSessionReplay,
823
+ reportFormError: flint.reportFormError
439
824
  };
440
825
 
441
826
  // src/theme.ts
@@ -478,8 +863,11 @@ function resolveTheme(theme) {
478
863
  }
479
864
  // Annotate the CommonJS export names for ESM import in node:
480
865
  0 && (module.exports = {
866
+ Flint,
867
+ _setFormErrorCollector,
481
868
  collectEnvironment,
482
869
  createConsoleCollector,
870
+ createFormErrorCollector,
483
871
  createFrustrationCollector,
484
872
  createNetworkCollector,
485
873
  flint,