tempest-react-sdk 0.75.0 → 0.77.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/README.md +4 -2
- package/bin/create-tempest-app.e2e.test.mjs +112 -0
- package/bin/create-tempest-app.mjs +69 -28
- package/bin/lib/component/generate.mjs +191 -0
- package/bin/lib/component/generate.test.mjs +336 -0
- package/bin/lib/openapi/escape.mjs +210 -0
- package/bin/lib/openapi/generate.hostile.test.mjs +592 -0
- package/bin/lib/openapi/generate.mjs +93 -17
- package/bin/lib/openapi/schema-to-zod.mjs +41 -4
- package/bin/tempest.mjs +184 -3
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +127 -55
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/refresh-queue.cjs +1 -1
- package/dist/auth/refresh-queue.cjs.map +1 -1
- package/dist/auth/refresh-queue.js +13 -6
- package/dist/auth/refresh-queue.js.map +1 -1
- package/dist/br/pix.cjs +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js +72 -62
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +12 -4
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +46 -38
- package/dist/components/Chat/ChatAttachments.js.map +1 -1
- package/dist/components/Markdown/markdown-parse.cjs +7 -5
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js +151 -87
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/components/Toast/Toast.module.cjs +1 -1
- package/dist/components/Toast/Toast.module.cjs.map +1 -1
- package/dist/components/Toast/Toast.module.js +7 -3
- package/dist/components/Toast/Toast.module.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js +77 -51
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +32 -21
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +81 -73
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/errors.cjs +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js +39 -35
- package/dist/http/errors.js.map +1 -1
- package/dist/neural.cjs +2 -0
- package/dist/neural.cjs.map +1 -0
- package/dist/neural.d.ts +110 -0
- package/dist/neural.js +48 -0
- package/dist/neural.js.map +1 -0
- package/dist/oauth/callback.cjs +2 -0
- package/dist/oauth/callback.cjs.map +1 -0
- package/dist/oauth/callback.js +42 -0
- package/dist/oauth/callback.js.map +1 -0
- package/dist/oauth/use-oauth-callback.cjs +1 -1
- package/dist/oauth/use-oauth-callback.cjs.map +1 -1
- package/dist/oauth/use-oauth-callback.js +13 -20
- package/dist/oauth/use-oauth-callback.js.map +1 -1
- package/dist/push/use-push-subscription.cjs +1 -1
- package/dist/push/use-push-subscription.cjs.map +1 -1
- package/dist/push/use-push-subscription.js +42 -42
- package/dist/push/use-push-subscription.js.map +1 -1
- package/dist/push/web-push-client.cjs +1 -1
- package/dist/push/web-push-client.cjs.map +1 -1
- package/dist/push/web-push-client.js +23 -3
- package/dist/push/web-push-client.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +68 -23
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/styles/component/Toast.css +6 -1
- package/dist/styles/feedback.css +6 -1
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +271 -60
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache-inspect.cjs.map +1 -1
- package/dist/sw/cache-inspect.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +91 -64
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/credential-headers.cjs +2 -0
- package/dist/sw/credential-headers.cjs.map +1 -0
- package/dist/sw/credential-headers.js +17 -0
- package/dist/sw/credential-headers.js.map +1 -0
- package/dist/sw/session-data.cjs +2 -0
- package/dist/sw/session-data.cjs.map +1 -0
- package/dist/sw/session-data.js +17 -0
- package/dist/sw/session-data.js.map +1 -0
- package/dist/sw.cjs +1 -1
- package/dist/sw.d.ts +271 -23
- package/dist/sw.js +7 -5
- package/dist/telemetry/app-error-owner.cjs +2 -0
- package/dist/telemetry/app-error-owner.cjs.map +1 -0
- package/dist/telemetry/app-error-owner.js +18 -0
- package/dist/telemetry/app-error-owner.js.map +1 -0
- package/dist/telemetry/app-error-queue.cjs +1 -1
- package/dist/telemetry/app-error-queue.cjs.map +1 -1
- package/dist/telemetry/app-error-queue.js +19 -14
- package/dist/telemetry/app-error-queue.js.map +1 -1
- package/dist/telemetry/app-error-reporter.cjs +1 -1
- package/dist/telemetry/app-error-reporter.cjs.map +1 -1
- package/dist/telemetry/app-error-reporter.js +75 -34
- package/dist/telemetry/app-error-reporter.js.map +1 -1
- package/dist/telemetry/app-error-transport.cjs +1 -1
- package/dist/telemetry/app-error-transport.cjs.map +1 -1
- package/dist/telemetry/app-error-transport.js +16 -3
- package/dist/telemetry/app-error-transport.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +979 -62
- package/dist/tempest-react-sdk.js +160 -156
- package/dist/utils/csv.cjs +3 -3
- package/dist/utils/csv.cjs.map +1 -1
- package/dist/utils/csv.js +16 -13
- package/dist/utils/csv.js.map +1 -1
- package/dist/utils/safe-url.cjs +2 -0
- package/dist/utils/safe-url.cjs.map +1 -0
- package/dist/utils/safe-url.js +39 -0
- package/dist/utils/safe-url.js.map +1 -0
- package/dist/vite/tempest-csp.cjs +2 -2
- package/dist/vite/tempest-csp.cjs.map +1 -1
- package/dist/vite/tempest-csp.js +118 -63
- package/dist/vite/tempest-csp.js.map +1 -1
- package/dist/vite.d.ts +21 -5
- package/dist/webrtc/peer-link.cjs +1 -1
- package/dist/webrtc/peer-link.cjs.map +1 -1
- package/dist/webrtc/peer-link.js +2 -10
- package/dist/webrtc/peer-link.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +110 -72
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/package.json +11 -1
- package/template-pwa/src/pwa.tsx +22 -0
- package/template-pwa/src/sw.ts +27 -14
- package/dist/components/Markdown/markdown-url.cjs +0 -2
- package/dist/components/Markdown/markdown-url.cjs.map +0 -1
- package/dist/components/Markdown/markdown-url.js +0 -28
- package/dist/components/Markdown/markdown-url.js.map +0 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/telemetry/app-error-owner.ts
|
|
2
|
+
var e = 2166136261, t = 16777619;
|
|
3
|
+
function n(e, n) {
|
|
4
|
+
let r = n;
|
|
5
|
+
for (let n = 0; n < e.length; n += 1) r ^= e.charCodeAt(n), r = Math.imul(r, t);
|
|
6
|
+
return r >>> 0;
|
|
7
|
+
}
|
|
8
|
+
function r(t) {
|
|
9
|
+
return `${n(t, e).toString(16).padStart(8, "0")}${n(t, 2128831034).toString(16).padStart(8, "0")}`;
|
|
10
|
+
}
|
|
11
|
+
function i(e, t) {
|
|
12
|
+
let n = typeof t == "string" ? t : typeof t == "number" && Number.isFinite(t) ? String(t) : "";
|
|
13
|
+
return n ? `${e}:${r(n)}` : null;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { e as FNV_OFFSET_BASIS_32, t as FNV_PRIME_32, i as appErrorOwner, r as fingerprintAppErrorOwner };
|
|
17
|
+
|
|
18
|
+
//# sourceMappingURL=app-error-owner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app-error-owner.js","names":[],"sources":["../../src/telemetry/app-error-owner.ts"],"sourcesContent":["/**\n * FNV-1a 32-bit offset basis, the seed of the first half of the fingerprint.\n *\n * Ported from the FNV specification (Fowler/Noll/Vo, `offset_basis` for 32 bits).\n */\nexport const FNV_OFFSET_BASIS_32 = 0x811c9dc5;\n\n/**\n * FNV-1a 32-bit prime.\n *\n * Ported from the FNV specification (Fowler/Noll/Vo, `FNV_prime` for 32 bits).\n */\nexport const FNV_PRIME_32 = 0x01000193;\n\n/**\n * Seed of the second half of the fingerprint: the basis with every bit\n * flipped, so the two halves are independent hashes of the same text.\n */\nconst SECOND_SEED = ~FNV_OFFSET_BASIS_32 >>> 0;\n\n/**\n * Hash `text` with FNV-1a from `seed`.\n *\n * @param text - What to hash, read as UTF-16 code units.\n * @param seed - Starting value.\n * @returns The 32-bit hash as an unsigned integer.\n */\nfunction fnv1a(text: string, seed: number): number {\n let hash = seed;\n for (let index = 0; index < text.length; index += 1) {\n hash ^= text.charCodeAt(index);\n hash = Math.imul(hash, FNV_PRIME_32);\n }\n return hash >>> 0;\n}\n\n/**\n * A 64-bit one-way fingerprint of `text`, as 16 hex characters.\n *\n * Not a cryptographic hash, and it does not need to be: it only has to tell\n * two signed-in identities apart on one device, and it must not let anyone\n * holding the stored queue send as the user. Sixty-four bits cannot be turned\n * back into a bearer token, and two different users colliding on one browser\n * is a one-in-2^64 event. A synchronous hash is what lets `report()` stamp the\n * owner without awaiting `crypto.subtle`.\n *\n * @param text - The principal or token to fingerprint.\n * @returns Sixteen lowercase hex characters.\n */\nexport function fingerprintAppErrorOwner(text: string): string {\n const first = fnv1a(text, FNV_OFFSET_BASIS_32).toString(16).padStart(8, \"0\");\n const second = fnv1a(text, SECOND_SEED).toString(16).padStart(8, \"0\");\n return `${first}${second}`;\n}\n\n/**\n * Turn a principal or a token into the fingerprint a queue entry stores.\n *\n * The prefix keeps the two sources from ever matching each other: a report\n * stamped from a principal is never taken for one stamped from a token.\n *\n * Only a non-empty string or a finite number is an identity. Anything else —\n * notably the `Promise` an `async` getter returns from untyped JS — reads as\n * nobody, because stringifying it would give every user the same\n * `\"[object Promise]\"` fingerprint.\n *\n * @param source - `\"p\"` for a principal from `getPrincipal`, `\"t\"` for a token.\n * @param value - The identity, or nothing when nobody is signed in.\n * @returns `\"<source>:<fingerprint>\"`, or `null` when `value` is no identity.\n */\nexport function appErrorOwner(source: \"p\" | \"t\", value: unknown): string | null {\n const text =\n typeof value === \"string\"\n ? value\n : typeof value === \"number\" && Number.isFinite(value)\n ? String(value)\n : \"\";\n return text ? `${source}:${fingerprintAppErrorOwner(text)}` : null;\n}\n"],"mappings":";AAKA,IAAa,IAAsB,YAOtB,IAAe;AAe5B,SAAS,EAAM,GAAc,GAAsB;CAC/C,IAAI,IAAO;CACX,KAAK,IAAI,IAAQ,GAAG,IAAQ,EAAK,QAAQ,KAAS,GAE9C,AADA,KAAQ,EAAK,WAAW,CAAK,GAC7B,IAAO,KAAK,KAAK,GAAM,CAAY;CAEvC,OAAO,MAAS;AACpB;AAeA,SAAgB,EAAyB,GAAsB;CAG3D,OAAO,GAFO,EAAM,GAAM,CAAmB,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAE9D,IADK,EAAM,GAAM,UAAW,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAC/C;AACtB;AAiBA,SAAgB,EAAc,GAAmB,GAA+B;CAC5E,IAAM,IACF,OAAO,KAAU,WACX,IACA,OAAO,KAAU,YAAY,OAAO,SAAS,CAAK,IAChD,OAAO,CAAK,IACZ;CACZ,OAAO,IAAO,GAAG,EAAO,GAAG,EAAyB,CAAI,MAAM;AAClE"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
function e(e,t){return e.code===t.code&&e.message===t.message&&e.app_version===t.app_version}function t(e,t){if(!e)return[];try{let n=e.getItem(t),r=n?JSON.parse(n):[];return Array.isArray(r)?r:[]}catch{return[]}}function
|
|
1
|
+
function e(e,t){return e.code===t.code&&e.message===t.message&&e.app_version===t.app_version}function t(t,n){return e(t.report,n.report)&&t.session===n.session&&t.owner===n.owner}function n(e,t){if(!e)return[];try{let n=e.getItem(t),r=n?JSON.parse(n):[];return Array.isArray(r)?r:[]}catch{return[]}}function r(e,r,i){let a=n(e,r),o=()=>{if(e)try{a.length===0?e.removeItem(r):e.setItem(r,JSON.stringify(a))}catch{return}};return{entries:()=>[...a],push(e,n){let r={report:e,count:1,...n},s=a.find(e=>t(e,r)),c=0;return s?s.count+=1:(a.push(r),a.length>i&&(a=a.slice(a.length-i),c=1)),o(),c},has:e=>a.some(n=>t(n,e)),remove(e){a=a.filter(n=>!t(n,e)),o()},clear(){a=[],o()}}}exports.createAppErrorQueue=r;
|
|
2
2
|
//# sourceMappingURL=app-error-queue.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-error-queue.cjs","names":[],"sources":["../../src/telemetry/app-error-queue.ts"],"sourcesContent":["import type { AppErrorReportSchema } from \"./app-error-schema\";\n\n/**\n * The `localStorage` subset the queue needs, so a test or a non-browser host\n * can hand in its own.\n */\nexport interface AppErrorQueueStorage {\n getItem: (key: string) => string | null;\n setItem: (key: string, value: string) => void;\n removeItem: (key: string) => void;\n}\n\n/** One queued report and how many identical ones were folded into it. */\nexport interface AppErrorQueueEntry {\n report: AppErrorReportSchema;\n /** How many times this exact report was raised while it waited. */\n count: number;\n}\n\n/** The persisted queue the reporter drains. */\nexport interface AppErrorQueue {\n /** Every waiting entry, oldest first. */\n entries: () => AppErrorQueueEntry[];\n /**\n * Add a report, folding it into an identical waiting one.\n *\n * @returns How many entries the size ceiling evicted (0 or 1).\n */\n push: (report: AppErrorReportSchema) => number;\n /**\n * Drop
|
|
1
|
+
{"version":3,"file":"app-error-queue.cjs","names":[],"sources":["../../src/telemetry/app-error-queue.ts"],"sourcesContent":["import type { AppErrorReportSchema } from \"./app-error-schema\";\n\n/**\n * The `localStorage` subset the queue needs, so a test or a non-browser host\n * can hand in its own.\n */\nexport interface AppErrorQueueStorage {\n getItem: (key: string) => string | null;\n setItem: (key: string, value: string) => void;\n removeItem: (key: string) => void;\n}\n\n/** One queued report and how many identical ones were folded into it. */\nexport interface AppErrorQueueEntry {\n report: AppErrorReportSchema;\n /** How many times this exact report was raised while it waited. */\n count: number;\n /**\n * The token in force when the report was raised, as a one-way fingerprint\n * (`\"t:<hex>\"`) — never the token itself. `null` means there was none;\n * the field is missing on entries persisted before it existed (≤ 0.76.0).\n *\n * The bearer is resolved when the queue drains, not when the error happens,\n * so this is what keeps a report user A raised offline from leaving with\n * user B's token: the bearer only goes out when it is the very token whose\n * fingerprint is stored here.\n */\n session?: string | null;\n /**\n * Who `getPrincipal` said was signed in when the report was raised, as a\n * one-way fingerprint (`\"p:<hex>\"`). Only set when the reporter has a\n * `getPrincipal`; `null` means it named nobody. It never grants the bearer\n * — `session` does — it only decides whether `foreignReports: \"drop\"`\n * discards the entry.\n */\n owner?: string | null;\n}\n\n/** Who a report is stamped with when it is raised. */\nexport interface AppErrorStamp {\n /** Token fingerprint, or `null`. */\n session: string | null;\n /** Principal fingerprint; absent without `getPrincipal`. */\n owner?: string | null;\n}\n\n/** The persisted queue the reporter drains. */\nexport interface AppErrorQueue {\n /** Every waiting entry, oldest first. */\n entries: () => AppErrorQueueEntry[];\n /**\n * Add a report, folding it into an identical waiting one with the same stamp.\n *\n * @param report - The fitted report.\n * @param stamp - Who was signed in when it was raised.\n * @returns How many entries the size ceiling evicted (0 or 1).\n */\n push: (report: AppErrorReportSchema, stamp: AppErrorStamp) => number;\n /** Whether `entry` is still waiting — `false` once sent, dropped or cleared. */\n has: (entry: AppErrorQueueEntry) => boolean;\n /**\n * Drop `entry` — the one just sent, or refused for good.\n *\n * Matched by content and owner, not by position: a report raised while the\n * send was in flight may have evicted the head, and dropping \"the first\n * entry\" would then throw away a report nobody sent.\n */\n remove: (entry: AppErrorQueueEntry) => void;\n /** Drop everything. */\n clear: () => void;\n}\n\n/**\n * Whether two reports describe the same failure on the same build.\n *\n * @param a - A waiting report.\n * @param b - The incoming report.\n * @returns `true` when `code`, `message` and `app_version` all match.\n */\nfunction sameReport(a: AppErrorReportSchema, b: AppErrorReportSchema): boolean {\n return a.code === b.code && a.message === b.message && a.app_version === b.app_version;\n}\n\n/**\n * Whether a waiting entry holds this report with this stamp.\n *\n * The stamp is part of the identity on purpose: folding B's report into A's\n * waiting one would send B's occurrences under A's name, which is the\n * misattribution the stamp exists to prevent.\n *\n * @param entry - A waiting entry.\n * @param other - The report and stamp to match; a missing field matches only a\n * missing field, so legacy entries keep to themselves.\n * @returns `true` when report, session and owner all match.\n */\nfunction holds(\n entry: AppErrorQueueEntry,\n other: Pick<AppErrorQueueEntry, \"report\" | \"session\" | \"owner\">,\n): boolean {\n return (\n sameReport(entry.report, other.report) &&\n entry.session === other.session &&\n entry.owner === other.owner\n );\n}\n\n/**\n * Read the queue back, treating anything unreadable as empty.\n *\n * @param storage - Where the queue lives.\n * @param key - Its storage key.\n * @returns The waiting entries.\n */\nfunction load(storage: AppErrorQueueStorage | null, key: string): AppErrorQueueEntry[] {\n if (!storage) return [];\n try {\n const raw = storage.getItem(key);\n const parsed: unknown = raw ? JSON.parse(raw) : [];\n return Array.isArray(parsed) ? (parsed as AppErrorQueueEntry[]) : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Build the persisted queue of error reports waiting to be sent.\n *\n * Two rules bound it, because it fills exactly when the device is offline and\n * something is failing repeatedly:\n *\n * - **Identical reports from the same owner fold.** A failure in a loop raises the same report a\n * hundred times; it is stored once with a `count`, instead of pushing every\n * other failure out of the queue.\n * - **A ceiling evicts the oldest.** The newest report is the one closest to\n * what the user is looking at.\n *\n * Every write is best-effort: with storage unavailable or full, the queue keeps\n * working in memory for the session.\n *\n * @param storage - Backing store, or `null` to keep the queue in memory only.\n * @param key - Storage key.\n * @param maxEntries - Ceiling on distinct waiting reports.\n * @returns The queue.\n *\n * @tempest-limits empty-catch — `setItem` throws on quota exhaustion and in\n * Safari private mode; the in-memory copy is the fallback, and a reporter has no\n * one to tell.\n */\nexport function createAppErrorQueue(\n storage: AppErrorQueueStorage | null,\n key: string,\n maxEntries: number,\n): AppErrorQueue {\n let memory: AppErrorQueueEntry[] = load(storage, key);\n const save = (): void => {\n if (!storage) return;\n try {\n if (memory.length === 0) storage.removeItem(key);\n else storage.setItem(key, JSON.stringify(memory));\n } catch {\n return;\n }\n };\n return {\n entries: () => [...memory],\n push(report, stamp) {\n const incoming: AppErrorQueueEntry = { report, count: 1, ...stamp };\n const existing = memory.find((entry) => holds(entry, incoming));\n let evicted = 0;\n if (existing) {\n existing.count += 1;\n } else {\n memory.push(incoming);\n if (memory.length > maxEntries) {\n memory = memory.slice(memory.length - maxEntries);\n evicted = 1;\n }\n }\n save();\n return evicted;\n },\n has: (target) => memory.some((entry) => holds(entry, target)),\n remove(target) {\n memory = memory.filter((entry) => !holds(entry, target));\n save();\n },\n clear() {\n memory = [];\n save();\n },\n };\n}\n"],"mappings":"AA+EA,SAAS,EAAW,EAAyB,EAAkC,CAC3E,OAAO,EAAE,OAAS,EAAE,MAAQ,EAAE,UAAY,EAAE,SAAW,EAAE,cAAgB,EAAE,WAC/E,CAcA,SAAS,EACL,EACA,EACO,CACP,OACI,EAAW,EAAM,OAAQ,EAAM,MAAM,GACrC,EAAM,UAAY,EAAM,SACxB,EAAM,QAAU,EAAM,KAE9B,CASA,SAAS,EAAK,EAAsC,EAAmC,CACnF,GAAI,CAAC,EAAS,MAAO,CAAC,EACtB,GAAI,CACA,IAAM,EAAM,EAAQ,QAAQ,CAAG,EACzB,EAAkB,EAAM,KAAK,MAAM,CAAG,EAAI,CAAC,EACjD,OAAO,MAAM,QAAQ,CAAM,EAAK,EAAkC,CAAC,CACvE,MAAQ,CACJ,MAAO,CAAC,CACZ,CACJ,CA0BA,SAAgB,EACZ,EACA,EACA,EACa,CACb,IAAI,EAA+B,EAAK,EAAS,CAAG,EAC9C,MAAmB,CAChB,KACL,GAAI,CACI,EAAO,SAAW,EAAG,EAAQ,WAAW,CAAG,EAC1C,EAAQ,QAAQ,EAAK,KAAK,UAAU,CAAM,CAAC,CACpD,MAAQ,CACJ,MACJ,CACJ,EACA,MAAO,CACH,YAAe,CAAC,GAAG,CAAM,EACzB,KAAK,EAAQ,EAAO,CAChB,IAAM,EAA+B,CAAE,SAAQ,MAAO,EAAG,GAAG,CAAM,EAC5D,EAAW,EAAO,KAAM,GAAU,EAAM,EAAO,CAAQ,CAAC,EAC1D,EAAU,EAWd,OAVI,EACA,EAAS,OAAS,GAElB,EAAO,KAAK,CAAQ,EAChB,EAAO,OAAS,IAChB,EAAS,EAAO,MAAM,EAAO,OAAS,CAAU,EAChD,EAAU,IAGlB,EAAK,EACE,CACX,EACA,IAAM,GAAW,EAAO,KAAM,GAAU,EAAM,EAAO,CAAM,CAAC,EAC5D,OAAO,EAAQ,CACX,EAAS,EAAO,OAAQ,GAAU,CAAC,EAAM,EAAO,CAAM,CAAC,EACvD,EAAK,CACT,EACA,OAAQ,CACJ,EAAS,CAAC,EACV,EAAK,CACT,CACJ,CACJ"}
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
function e(e, t) {
|
|
3
3
|
return e.code === t.code && e.message === t.message && e.app_version === t.app_version;
|
|
4
4
|
}
|
|
5
|
-
function t(
|
|
5
|
+
function t(t, n) {
|
|
6
|
+
return e(t.report, n.report) && t.session === n.session && t.owner === n.owner;
|
|
7
|
+
}
|
|
8
|
+
function n(e, t) {
|
|
6
9
|
if (!e) return [];
|
|
7
10
|
try {
|
|
8
11
|
let n = e.getItem(t), r = n ? JSON.parse(n) : [];
|
|
@@ -11,25 +14,27 @@ function t(e, t) {
|
|
|
11
14
|
return [];
|
|
12
15
|
}
|
|
13
16
|
}
|
|
14
|
-
function
|
|
15
|
-
let a =
|
|
16
|
-
if (
|
|
17
|
-
a.length === 0 ?
|
|
17
|
+
function r(e, r, i) {
|
|
18
|
+
let a = n(e, r), o = () => {
|
|
19
|
+
if (e) try {
|
|
20
|
+
a.length === 0 ? e.removeItem(r) : e.setItem(r, JSON.stringify(a));
|
|
18
21
|
} catch {
|
|
19
22
|
return;
|
|
20
23
|
}
|
|
21
24
|
};
|
|
22
25
|
return {
|
|
23
26
|
entries: () => [...a],
|
|
24
|
-
push(
|
|
25
|
-
let
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
27
|
+
push(e, n) {
|
|
28
|
+
let r = {
|
|
29
|
+
report: e,
|
|
30
|
+
count: 1,
|
|
31
|
+
...n
|
|
32
|
+
}, s = a.find((e) => t(e, r)), c = 0;
|
|
33
|
+
return s ? s.count += 1 : (a.push(r), a.length > i && (a = a.slice(a.length - i), c = 1)), o(), c;
|
|
30
34
|
},
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
has: (e) => a.some((n) => t(n, e)),
|
|
36
|
+
remove(e) {
|
|
37
|
+
a = a.filter((n) => !t(n, e)), o();
|
|
33
38
|
},
|
|
34
39
|
clear() {
|
|
35
40
|
a = [], o();
|
|
@@ -37,6 +42,6 @@ function n(n, r, i) {
|
|
|
37
42
|
};
|
|
38
43
|
}
|
|
39
44
|
//#endregion
|
|
40
|
-
export {
|
|
45
|
+
export { r as createAppErrorQueue };
|
|
41
46
|
|
|
42
47
|
//# sourceMappingURL=app-error-queue.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-error-queue.js","names":[],"sources":["../../src/telemetry/app-error-queue.ts"],"sourcesContent":["import type { AppErrorReportSchema } from \"./app-error-schema\";\n\n/**\n * The `localStorage` subset the queue needs, so a test or a non-browser host\n * can hand in its own.\n */\nexport interface AppErrorQueueStorage {\n getItem: (key: string) => string | null;\n setItem: (key: string, value: string) => void;\n removeItem: (key: string) => void;\n}\n\n/** One queued report and how many identical ones were folded into it. */\nexport interface AppErrorQueueEntry {\n report: AppErrorReportSchema;\n /** How many times this exact report was raised while it waited. */\n count: number;\n}\n\n/** The persisted queue the reporter drains. */\nexport interface AppErrorQueue {\n /** Every waiting entry, oldest first. */\n entries: () => AppErrorQueueEntry[];\n /**\n * Add a report, folding it into an identical waiting one.\n *\n * @returns How many entries the size ceiling evicted (0 or 1).\n */\n push: (report: AppErrorReportSchema) => number;\n /**\n * Drop
|
|
1
|
+
{"version":3,"file":"app-error-queue.js","names":[],"sources":["../../src/telemetry/app-error-queue.ts"],"sourcesContent":["import type { AppErrorReportSchema } from \"./app-error-schema\";\n\n/**\n * The `localStorage` subset the queue needs, so a test or a non-browser host\n * can hand in its own.\n */\nexport interface AppErrorQueueStorage {\n getItem: (key: string) => string | null;\n setItem: (key: string, value: string) => void;\n removeItem: (key: string) => void;\n}\n\n/** One queued report and how many identical ones were folded into it. */\nexport interface AppErrorQueueEntry {\n report: AppErrorReportSchema;\n /** How many times this exact report was raised while it waited. */\n count: number;\n /**\n * The token in force when the report was raised, as a one-way fingerprint\n * (`\"t:<hex>\"`) — never the token itself. `null` means there was none;\n * the field is missing on entries persisted before it existed (≤ 0.76.0).\n *\n * The bearer is resolved when the queue drains, not when the error happens,\n * so this is what keeps a report user A raised offline from leaving with\n * user B's token: the bearer only goes out when it is the very token whose\n * fingerprint is stored here.\n */\n session?: string | null;\n /**\n * Who `getPrincipal` said was signed in when the report was raised, as a\n * one-way fingerprint (`\"p:<hex>\"`). Only set when the reporter has a\n * `getPrincipal`; `null` means it named nobody. It never grants the bearer\n * — `session` does — it only decides whether `foreignReports: \"drop\"`\n * discards the entry.\n */\n owner?: string | null;\n}\n\n/** Who a report is stamped with when it is raised. */\nexport interface AppErrorStamp {\n /** Token fingerprint, or `null`. */\n session: string | null;\n /** Principal fingerprint; absent without `getPrincipal`. */\n owner?: string | null;\n}\n\n/** The persisted queue the reporter drains. */\nexport interface AppErrorQueue {\n /** Every waiting entry, oldest first. */\n entries: () => AppErrorQueueEntry[];\n /**\n * Add a report, folding it into an identical waiting one with the same stamp.\n *\n * @param report - The fitted report.\n * @param stamp - Who was signed in when it was raised.\n * @returns How many entries the size ceiling evicted (0 or 1).\n */\n push: (report: AppErrorReportSchema, stamp: AppErrorStamp) => number;\n /** Whether `entry` is still waiting — `false` once sent, dropped or cleared. */\n has: (entry: AppErrorQueueEntry) => boolean;\n /**\n * Drop `entry` — the one just sent, or refused for good.\n *\n * Matched by content and owner, not by position: a report raised while the\n * send was in flight may have evicted the head, and dropping \"the first\n * entry\" would then throw away a report nobody sent.\n */\n remove: (entry: AppErrorQueueEntry) => void;\n /** Drop everything. */\n clear: () => void;\n}\n\n/**\n * Whether two reports describe the same failure on the same build.\n *\n * @param a - A waiting report.\n * @param b - The incoming report.\n * @returns `true` when `code`, `message` and `app_version` all match.\n */\nfunction sameReport(a: AppErrorReportSchema, b: AppErrorReportSchema): boolean {\n return a.code === b.code && a.message === b.message && a.app_version === b.app_version;\n}\n\n/**\n * Whether a waiting entry holds this report with this stamp.\n *\n * The stamp is part of the identity on purpose: folding B's report into A's\n * waiting one would send B's occurrences under A's name, which is the\n * misattribution the stamp exists to prevent.\n *\n * @param entry - A waiting entry.\n * @param other - The report and stamp to match; a missing field matches only a\n * missing field, so legacy entries keep to themselves.\n * @returns `true` when report, session and owner all match.\n */\nfunction holds(\n entry: AppErrorQueueEntry,\n other: Pick<AppErrorQueueEntry, \"report\" | \"session\" | \"owner\">,\n): boolean {\n return (\n sameReport(entry.report, other.report) &&\n entry.session === other.session &&\n entry.owner === other.owner\n );\n}\n\n/**\n * Read the queue back, treating anything unreadable as empty.\n *\n * @param storage - Where the queue lives.\n * @param key - Its storage key.\n * @returns The waiting entries.\n */\nfunction load(storage: AppErrorQueueStorage | null, key: string): AppErrorQueueEntry[] {\n if (!storage) return [];\n try {\n const raw = storage.getItem(key);\n const parsed: unknown = raw ? JSON.parse(raw) : [];\n return Array.isArray(parsed) ? (parsed as AppErrorQueueEntry[]) : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Build the persisted queue of error reports waiting to be sent.\n *\n * Two rules bound it, because it fills exactly when the device is offline and\n * something is failing repeatedly:\n *\n * - **Identical reports from the same owner fold.** A failure in a loop raises the same report a\n * hundred times; it is stored once with a `count`, instead of pushing every\n * other failure out of the queue.\n * - **A ceiling evicts the oldest.** The newest report is the one closest to\n * what the user is looking at.\n *\n * Every write is best-effort: with storage unavailable or full, the queue keeps\n * working in memory for the session.\n *\n * @param storage - Backing store, or `null` to keep the queue in memory only.\n * @param key - Storage key.\n * @param maxEntries - Ceiling on distinct waiting reports.\n * @returns The queue.\n *\n * @tempest-limits empty-catch — `setItem` throws on quota exhaustion and in\n * Safari private mode; the in-memory copy is the fallback, and a reporter has no\n * one to tell.\n */\nexport function createAppErrorQueue(\n storage: AppErrorQueueStorage | null,\n key: string,\n maxEntries: number,\n): AppErrorQueue {\n let memory: AppErrorQueueEntry[] = load(storage, key);\n const save = (): void => {\n if (!storage) return;\n try {\n if (memory.length === 0) storage.removeItem(key);\n else storage.setItem(key, JSON.stringify(memory));\n } catch {\n return;\n }\n };\n return {\n entries: () => [...memory],\n push(report, stamp) {\n const incoming: AppErrorQueueEntry = { report, count: 1, ...stamp };\n const existing = memory.find((entry) => holds(entry, incoming));\n let evicted = 0;\n if (existing) {\n existing.count += 1;\n } else {\n memory.push(incoming);\n if (memory.length > maxEntries) {\n memory = memory.slice(memory.length - maxEntries);\n evicted = 1;\n }\n }\n save();\n return evicted;\n },\n has: (target) => memory.some((entry) => holds(entry, target)),\n remove(target) {\n memory = memory.filter((entry) => !holds(entry, target));\n save();\n },\n clear() {\n memory = [];\n save();\n },\n };\n}\n"],"mappings":";AA+EA,SAAS,EAAW,GAAyB,GAAkC;CAC3E,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE;AAC/E;AAcA,SAAS,EACL,GACA,GACO;CACP,OACI,EAAW,EAAM,QAAQ,EAAM,MAAM,KACrC,EAAM,YAAY,EAAM,WACxB,EAAM,UAAU,EAAM;AAE9B;AASA,SAAS,EAAK,GAAsC,GAAmC;CACnF,IAAI,CAAC,GAAS,OAAO,CAAC;CACtB,IAAI;EACA,IAAM,IAAM,EAAQ,QAAQ,CAAG,GACzB,IAAkB,IAAM,KAAK,MAAM,CAAG,IAAI,CAAC;EACjD,OAAO,MAAM,QAAQ,CAAM,IAAK,IAAkC,CAAC;CACvE,QAAQ;EACJ,OAAO,CAAC;CACZ;AACJ;AA0BA,SAAgB,EACZ,GACA,GACA,GACa;CACb,IAAI,IAA+B,EAAK,GAAS,CAAG,GAC9C,UAAmB;EAChB,OACL,IAAI;GACA,AAAI,EAAO,WAAW,IAAG,EAAQ,WAAW,CAAG,IAC1C,EAAQ,QAAQ,GAAK,KAAK,UAAU,CAAM,CAAC;EACpD,QAAQ;GACJ;EACJ;CACJ;CACA,OAAO;EACH,eAAe,CAAC,GAAG,CAAM;EACzB,KAAK,GAAQ,GAAO;GAChB,IAAM,IAA+B;IAAE;IAAQ,OAAO;IAAG,GAAG;GAAM,GAC5D,IAAW,EAAO,MAAM,MAAU,EAAM,GAAO,CAAQ,CAAC,GAC1D,IAAU;GAWd,OAVI,IACA,EAAS,SAAS,KAElB,EAAO,KAAK,CAAQ,GAChB,EAAO,SAAS,MAChB,IAAS,EAAO,MAAM,EAAO,SAAS,CAAU,GAChD,IAAU,KAGlB,EAAK,GACE;EACX;EACA,MAAM,MAAW,EAAO,MAAM,MAAU,EAAM,GAAO,CAAM,CAAC;EAC5D,OAAO,GAAQ;GAEX,AADA,IAAS,EAAO,QAAQ,MAAU,CAAC,EAAM,GAAO,CAAM,CAAC,GACvD,EAAK;EACT;EACA,QAAQ;GAEJ,AADA,IAAS,CAAC,GACV,EAAK;EACT;CACJ;AACJ"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("./app-error-queue.cjs"),
|
|
1
|
+
const e=require("../utils/dev-mode.cjs"),t=require("./app-error-owner.cjs"),n=require("./app-error-queue.cjs"),r=require("./app-error-schema.cjs"),i=require("./app-error-transport.cjs"),a=require("./describe-app-error.cjs");var o=!1;function s(t){o||t.foreignReports!==`drop`||t.getPrincipal===void 0&&e.isDevBuild()&&(o=!0,console.warn('[tempest-react-sdk] createAppErrorReporter({ foreignReports: "drop" }) needs `getPrincipal`: without it the reporter cannot prove a queued report belongs to someone else, so it drops nothing and those reports go out anonymously. Pass `getPrincipal` to make "drop" take effect.'))}function c(){try{return typeof localStorage>`u`?null:localStorage}catch{return null}}function l(e){try{return e?.()??{}}catch{return{}}}function u(e){try{return t.appErrorOwner(`p`,e())}catch{return null}}function d(e,n){let r=e?{owner:u(e)}:{};return typeof n==`string`||n===null?{session:t.appErrorOwner(`t`,n),...r}:n.then(e=>({session:t.appErrorOwner(`t`,e),...r}))}function f(e){let{endpoint:o,getToken:f,getPrincipal:p,device:m,autoFlush:h=!0}=e;s(e);let g=p!==void 0&&e.foreignReports===`drop`,_=n.createAppErrorQueue(e.storage===void 0?c():e.storage,e.storageKey??`tempest:app-errors`,e.maxEntries??50),v=null,y=0,b=null,x=new Set,S=0,C=async()=>{let n=e.fetch??globalThis.fetch.bind(globalThis);await Promise.all(x);let r=await i.readToken(f),a=t.appErrorOwner(`t`,r),s=p?u(p):null,c=S,l={sent:0,dropped:0,pending:0,retryAfterMs:null};for(let e of _.entries()){if(!_.has(e))continue;if(g&&(e.owner==null||e.owner!==s)){_.remove(e),l.dropped+=1;continue}let t=e.session!=null&&e.session===a?r:null,u=await i.sendOne(n,o,t,e);if(S!==c||u.kind===`stop`)break;if(u.kind===`wait`){l.retryAfterMs=u.ms,y=Date.now()+u.ms,b??=setTimeout(()=>{b=null,w()},u.ms);break}_.remove(e),l[u.kind===`sent`?`sent`:`dropped`]+=1}return l.pending=_.entries().length,l},w=()=>(v??=C().finally(()=>{v=null}),v),T=()=>h&&Date.now()>=y&&(typeof navigator>`u`||navigator.onLine!==!1),E=()=>{T()&&w()};return h&&typeof window<`u`&&window.addEventListener(`online`,E),{report(e,t){let n=a.describeAppError(e,t),o=r.fitAppErrorReport({...l(m),...n}),s=d(p,i.readToken(f));if(!(s instanceof Promise)){_.push(o,s),T()&&w();return}let c=S,u=s.then(e=>{x.delete(u),S===c&&(_.push(o,e),T()&&w())});x.add(u)},flush:w,pending:()=>_.entries(),clear(){S+=1,_.clear()},dispose(){typeof window<`u`&&window.removeEventListener(`online`,E),b&&clearTimeout(b),b=null}}}exports.createAppErrorReporter=f;
|
|
2
2
|
//# sourceMappingURL=app-error-reporter.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-error-reporter.cjs","names":[],"sources":["../../src/telemetry/app-error-reporter.ts"],"sourcesContent":["import { createAppErrorQueue } from \"./app-error-queue\";\nimport type { AppErrorQueueEntry, AppErrorQueueStorage } from \"./app-error-queue\";\nimport { fitAppErrorReport } from \"./app-error-schema\";\nimport type { AppErrorDeviceSchema } from \"./app-error-schema\";\nimport { resolveToken, sendOne } from \"./app-error-transport\";\nimport { describeAppError } from \"./describe-app-error\";\n\n/** Options for {@link createAppErrorReporter}. */\nexport interface CreateAppErrorReporterOptions {\n /** Full URL of the backend route, e.g. `${API_URL}/api/app-errors`. */\n endpoint: string;\n /**\n * Current access token, if any. Optional on purpose: the route is public,\n * and an error raised before login — or with a stale session — still has\n * to get through. A token that throws or resolves empty sends anonymously.\n */\n getToken?: () => string | null | undefined | Promise<string | null | undefined>;\n /**\n * Device facts, read **when the error is reported**, not when it is sent —\n * a report queued offline on `1.4.0` must not go out labelled `1.5.0`.\n */\n device?: () => AppErrorDeviceSchema;\n /** Where the queue persists. Defaults to `localStorage`; `null` keeps it in memory. */\n storage?: AppErrorQueueStorage | null;\n /** Storage key. Default `\"tempest:app-errors\"`. */\n storageKey?: string;\n /** Ceiling on distinct waiting reports; the oldest is evicted. Default `50`. */\n maxEntries?: number;\n /** `fetch` to send with. Defaults to the global one. */\n fetch?: typeof fetch;\n /** Send on every report and when the browser goes `online`. Default `true`. */\n autoFlush?: boolean;\n}\n\n/** What one {@link AppErrorReporter.flush} did. */\nexport interface AppErrorFlushResult {\n /** Reports the backend accepted. */\n sent: number;\n /** Reports the backend refused for good (a 4xx other than 408/429) and were dropped. */\n dropped: number;\n /** Reports still waiting. */\n pending: number;\n /** Milliseconds the backend asked to wait (429 `Retry-After`), or `null`. */\n retryAfterMs: number | null;\n}\n\n/** The reporter {@link createAppErrorReporter} returns. */\nexport interface AppErrorReporter {\n /** Queue an error with optional context, and try to send it. Never throws. */\n report: (error: unknown, context?: Record<string, unknown>) => void;\n /** Drain the queue now. Concurrent calls share one run. */\n flush: () => Promise<AppErrorFlushResult>;\n /** The waiting entries, oldest first. */\n pending: () => AppErrorQueueEntry[];\n /** Drop every waiting report. */\n clear: () => void;\n /** Remove the `online` listener and the retry timer. */\n dispose: () => void;\n}\n\n/**\n * Pick the default store, guarding hosts where touching `localStorage` throws.\n *\n * @returns `localStorage`, or `null` when there is none.\n */\nfunction defaultStorage(): AppErrorQueueStorage | null {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null;\n }\n}\n\n/**\n * Read the device facts, reporting without them when the getter throws.\n *\n * @param device - The caller's getter.\n * @returns The facts, or an empty object.\n */\nfunction readDevice(device: CreateAppErrorReporterOptions[\"device\"]): AppErrorDeviceSchema {\n try {\n return device?.() ?? {};\n } catch {\n return {};\n }\n}\n\n/**\n * Report client errors to a Tempest backend's `POST /api/app-errors`, surviving\n * the offline stretches where field errors actually happen.\n *\n * The client half of `tempest-fastapi-sdk`'s `app_errors` module. An error is\n * described ({@link describeAppError}), stamped with the device facts of that\n * moment, fitted to the backend's column limits and queued in `localStorage`\n * before anything goes over the network — so a report raised with no signal is\n * sent when the browser comes back `online`, not lost.\n *\n * What each answer does to the queue:\n *\n * - **2xx** — removed.\n * - **429** — kept; sending pauses for `Retry-After`, then resumes on its own.\n * - **5xx, 408, network failure** — kept; the next report, `online` event or\n * `flush()` retries.\n * - **any other 4xx** — dropped: the backend will refuse that body forever, and\n * keeping it would block every report queued behind it.\n *\n * @example\n * import { createAppErrorReporter } from \"tempest-react-sdk\";\n *\n * const reporter = createAppErrorReporter({\n * endpoint: `${import.meta.env.VITE_API_URL}/api/app-errors`,\n * getToken: () => sessionStorage.getItem(\"access_token\"),\n * device: () => ({ platform: \"web\", app_version: \"1.4.0\" }),\n * });\n *\n * reporter.report(new Error(\"crop failed\"), { step: \"crop\", size: 0 });\n *\n * @param options - Endpoint, token and device getters, storage and limits.\n * @returns The reporter.\n */\nexport function createAppErrorReporter(options: CreateAppErrorReporterOptions): AppErrorReporter {\n const { endpoint, getToken, device, autoFlush = true } = options;\n const queue = createAppErrorQueue(\n options.storage === undefined ? defaultStorage() : options.storage,\n options.storageKey ?? \"tempest:app-errors\",\n options.maxEntries ?? 50,\n );\n let running: Promise<AppErrorFlushResult> | null = null;\n let pausedUntil = 0;\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n const drain = async (): Promise<AppErrorFlushResult> => {\n const send = options.fetch ?? globalThis.fetch.bind(globalThis);\n const token = await resolveToken(getToken);\n const result: AppErrorFlushResult = { sent: 0, dropped: 0, pending: 0, retryAfterMs: null };\n for (const entry of queue.entries()) {\n const outcome = await sendOne(send, endpoint, token, entry);\n if (outcome.kind === \"stop\") break;\n if (outcome.kind === \"wait\") {\n result.retryAfterMs = outcome.ms;\n pausedUntil = Date.now() + outcome.ms;\n timer ??= setTimeout(() => {\n timer = null;\n void flush();\n }, outcome.ms);\n break;\n }\n queue.remove(entry.report);\n result[outcome.kind === \"sent\" ? \"sent\" : \"dropped\"] += 1;\n }\n result.pending = queue.entries().length;\n return result;\n };\n\n const flush = (): Promise<AppErrorFlushResult> => {\n running ??= drain().finally(() => {\n running = null;\n });\n return running;\n };\n\n const shouldSend = (): boolean =>\n autoFlush &&\n Date.now() >= pausedUntil &&\n (typeof navigator === \"undefined\" || navigator.onLine !== false);\n\n const onOnline = (): void => {\n if (shouldSend()) void flush();\n };\n if (autoFlush && typeof window !== \"undefined\") window.addEventListener(\"online\", onOnline);\n\n return {\n report(error, context) {\n const description = describeAppError(error, context);\n queue.push(fitAppErrorReport({ ...readDevice(device), ...description }));\n if (shouldSend()) void flush();\n },\n flush,\n pending: () => queue.entries(),\n clear: () => queue.clear(),\n dispose() {\n if (typeof window !== \"undefined\") window.removeEventListener(\"online\", onOnline);\n if (timer) clearTimeout(timer);\n timer = null;\n },\n };\n}\n"],"mappings":"0JAiEA,SAAS,GAA8C,CACnD,GAAI,CACA,OAAO,OAAO,aAAiB,IAAc,KAAO,YACxD,MAAQ,CACJ,OAAO,IACX,CACJ,CAQA,SAAS,EAAW,EAAuE,CACvF,GAAI,CACA,OAAO,IAAS,GAAK,CAAC,CAC1B,MAAQ,CACJ,MAAO,CAAC,CACZ,CACJ,CAmCA,SAAgB,EAAuB,EAA0D,CAC7F,GAAM,CAAE,WAAU,WAAU,SAAQ,YAAY,IAAS,EACnD,EAAQ,EAAA,oBACV,EAAQ,UAAY,IAAA,GAAY,EAAe,EAAI,EAAQ,QAC3D,EAAQ,YAAc,qBACtB,EAAQ,YAAc,EAC1B,EACI,EAA+C,KAC/C,EAAc,EACd,EAA8C,KAE5C,EAAQ,SAA0C,CACpD,IAAM,EAAO,EAAQ,OAAS,WAAW,MAAM,KAAK,UAAU,EACxD,EAAQ,MAAM,EAAA,aAAa,CAAQ,EACnC,EAA8B,CAAE,KAAM,EAAG,QAAS,EAAG,QAAS,EAAG,aAAc,IAAK,EAC1F,IAAK,IAAM,KAAS,EAAM,QAAQ,EAAG,CACjC,IAAM,EAAU,MAAM,EAAA,QAAQ,EAAM,EAAU,EAAO,CAAK,EAC1D,GAAI,EAAQ,OAAS,OAAQ,MAC7B,GAAI,EAAQ,OAAS,OAAQ,CACzB,EAAO,aAAe,EAAQ,GAC9B,EAAc,KAAK,IAAI,EAAI,EAAQ,GACnC,IAAU,eAAiB,CACvB,EAAQ,KACR,EAAW,CACf,EAAG,EAAQ,EAAE,EACb,KACJ,CACA,EAAM,OAAO,EAAM,MAAM,EACzB,EAAO,EAAQ,OAAS,OAAS,OAAS,YAAc,CAC5D,CAEA,MADA,GAAO,QAAU,EAAM,QAAQ,CAAC,CAAC,OAC1B,CACX,EAEM,OACF,IAAY,EAAM,CAAC,CAAC,YAAc,CAC9B,EAAU,IACd,CAAC,EACM,GAGL,MACF,GACA,KAAK,IAAI,GAAK,IACb,OAAO,UAAc,KAAe,UAAU,SAAW,IAExD,MAAuB,CACrB,EAAW,GAAG,EAAW,CACjC,EAGA,OAFI,GAAa,OAAO,OAAW,KAAa,OAAO,iBAAiB,SAAU,CAAQ,EAEnF,CACH,OAAO,EAAO,EAAS,CACnB,IAAM,EAAc,EAAA,iBAAiB,EAAO,CAAO,EACnD,EAAM,KAAK,EAAA,kBAAkB,CAAE,GAAG,EAAW,CAAM,EAAG,GAAG,CAAY,CAAC,CAAC,EACnE,EAAW,GAAG,EAAW,CACjC,EACA,QACA,YAAe,EAAM,QAAQ,EAC7B,UAAa,EAAM,MAAM,EACzB,SAAU,CACF,OAAO,OAAW,KAAa,OAAO,oBAAoB,SAAU,CAAQ,EAC5E,GAAO,aAAa,CAAK,EAC7B,EAAQ,IACZ,CACJ,CACJ"}
|
|
1
|
+
{"version":3,"file":"app-error-reporter.cjs","names":[],"sources":["../../src/telemetry/app-error-reporter.ts"],"sourcesContent":["import { appErrorOwner } from \"./app-error-owner\";\nimport { createAppErrorQueue } from \"./app-error-queue\";\nimport type { AppErrorQueueEntry, AppErrorQueueStorage, AppErrorStamp } from \"./app-error-queue\";\nimport { fitAppErrorReport } from \"./app-error-schema\";\nimport type { AppErrorDeviceSchema } from \"./app-error-schema\";\nimport { readToken, sendOne } from \"./app-error-transport\";\nimport { describeAppError } from \"./describe-app-error\";\nimport { isDevBuild } from \"../utils/dev-mode\";\n\n/** Options for {@link createAppErrorReporter}. */\nexport interface CreateAppErrorReporterOptions {\n /** Full URL of the backend route, e.g. `${API_URL}/api/app-errors`. */\n endpoint: string;\n /**\n * Current access token, if any. Optional on purpose: the route is public,\n * and an error raised before login — or with a stale session — still has\n * to get through. A token that throws or resolves empty sends anonymously.\n */\n getToken?: () => string | null | undefined | Promise<string | null | undefined>;\n /**\n * Who is signed in right now — a stable, non-secret id such as the user id.\n * Read **when the error is reported**, and stored in the queue only as a\n * one-way fingerprint (`entry.owner`). A non-string (an `async` getter\n * from untyped JS, an object) or a getter that throws reads as nobody.\n *\n * It never grants the bearer. The bearer goes out only when the token at\n * send time is the very token that was in force at report time (its\n * fingerprint is `entry.session`): the principal and the token come from\n * two sources, and a principal store that lags behind a login would\n * otherwise send A's report with B's token. The principal only tells\n * {@link CreateAppErrorReporterOptions.foreignReports} `\"drop\"` whose\n * report is whose.\n */\n getPrincipal?: () => string | number | null | undefined;\n /**\n * What to do with a queued report that cannot go out with the current\n * bearer — raised under another token, or queued before 0.77.0. Default\n * `\"anonymous\"`.\n *\n * - `\"anonymous\"` — send it without `Authorization`. The route is public,\n * so the error still reaches the backend; it is only not attributed to\n * anybody. This includes the user's own reports raised before a token\n * refresh: attribution is lost, never misplaced.\n * - `\"drop\"` — discard every report not proven to be the current\n * principal's: another principal, nobody, or unknown (no principal at\n * report time, a legacy entry, or nobody signed in now). For apps where\n * a previous user's error text must not leave the device after they\n * signed out. **Needs `getPrincipal`**: without it the only identity is\n * the token fingerprint, and a refresh changes it, so a mismatch is no\n * proof the report is someone else's — the reporter then sends\n * anonymously instead of destroying the user's own reports.\n *\n * Never is a report sent with a bearer other than the one it was raised\n * under: that would credit the previous user's error to whoever signed in\n * next.\n */\n foreignReports?: \"anonymous\" | \"drop\";\n /**\n * Device facts, read **when the error is reported**, not when it is sent —\n * a report queued offline on `1.4.0` must not go out labelled `1.5.0`.\n */\n device?: () => AppErrorDeviceSchema;\n /** Where the queue persists. Defaults to `localStorage`; `null` keeps it in memory. */\n storage?: AppErrorQueueStorage | null;\n /** Storage key. Default `\"tempest:app-errors\"`. */\n storageKey?: string;\n /** Ceiling on distinct waiting reports; the oldest is evicted. Default `50`. */\n maxEntries?: number;\n /** `fetch` to send with. Defaults to the global one. */\n fetch?: typeof fetch;\n /** Send on every report and when the browser goes `online`. Default `true`. */\n autoFlush?: boolean;\n}\n\n/** What one {@link AppErrorReporter.flush} did. */\nexport interface AppErrorFlushResult {\n /** Reports the backend accepted. */\n sent: number;\n /** Reports the backend refused for good (a 4xx other than 408/429) and were dropped. */\n dropped: number;\n /** Reports still waiting. */\n pending: number;\n /** Milliseconds the backend asked to wait (429 `Retry-After`), or `null`. */\n retryAfterMs: number | null;\n}\n\n/** The reporter {@link createAppErrorReporter} returns. */\nexport interface AppErrorReporter {\n /** Queue an error with optional context, and try to send it. Never throws. */\n report: (error: unknown, context?: Record<string, unknown>) => void;\n /** Drain the queue now. Concurrent calls share one run. */\n flush: () => Promise<AppErrorFlushResult>;\n /**\n * The waiting entries, oldest first. With an async `getToken` and no\n * `getPrincipal`, a report joins them once its token resolves — `flush()`\n * waits for that, `pending()` does not.\n */\n pending: () => AppErrorQueueEntry[];\n /**\n * Drop every waiting report. Call it on logout when reports raised in that\n * session should not outlive it; even without it, a report never leaves\n * with the next user's bearer.\n *\n * It also stops a flush in flight before its next send (the one already on\n * the wire cannot be recalled), and discards reports still waiting for an\n * async `getToken` to stamp them — both would otherwise refill the queue or\n * keep sending what the logout meant to forget.\n */\n clear: () => void;\n /** Remove the `online` listener and the retry timer. */\n dispose: () => void;\n}\n\n/** Set once the warning has printed, so every reporter built after it stays quiet. */\nlet warnedDropWithoutPrincipal = false;\n\n/**\n * Reset the warn-once latch. Test-only: the latch is module state, so a suite\n * asserting the warning more than once would otherwise see it only the first time.\n */\nexport function resetDropWithoutPrincipalWarning(): void {\n warnedDropWithoutPrincipal = false;\n}\n\n/**\n * Warn once, in development, that `foreignReports: \"drop\"` was asked for\n * without the `getPrincipal` it needs.\n *\n * Without a principal the only identity is the token fingerprint, which a\n * refresh changes, so the reporter cannot tell a foreign report from the\n * user's own and sends them anonymously instead of dropping them. The option\n * type-checks either way, so nothing else would tell the app its privacy\n * choice is not in force.\n *\n * @param options - The reporter's options.\n */\nfunction warnOnDropWithoutPrincipal(options: CreateAppErrorReporterOptions): void {\n if (warnedDropWithoutPrincipal || options.foreignReports !== \"drop\") return;\n if (options.getPrincipal !== undefined || !isDevBuild()) return;\n warnedDropWithoutPrincipal = true;\n console.warn(\n '[tempest-react-sdk] createAppErrorReporter({ foreignReports: \"drop\" }) needs ' +\n \"`getPrincipal`: without it the reporter cannot prove a queued report belongs \" +\n \"to someone else, so it drops nothing and those reports go out anonymously. \" +\n 'Pass `getPrincipal` to make \"drop\" take effect.',\n );\n}\n\n/**\n * Pick the default store, guarding hosts where touching `localStorage` throws.\n *\n * @returns `localStorage`, or `null` when there is none.\n */\nfunction defaultStorage(): AppErrorQueueStorage | null {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null;\n }\n}\n\n/**\n * Read the device facts, reporting without them when the getter throws.\n *\n * @param device - The caller's getter.\n * @returns The facts, or an empty object.\n */\nfunction readDevice(device: CreateAppErrorReporterOptions[\"device\"]): AppErrorDeviceSchema {\n try {\n return device?.() ?? {};\n } catch {\n return {};\n }\n}\n\n/**\n * Fingerprint whoever `getPrincipal` says is signed in.\n *\n * @param getPrincipal - The app's principal getter.\n * @returns The owner, or `null` when nobody is signed in or the getter throws.\n */\nfunction principalOwner(getPrincipal: () => unknown): string | null {\n try {\n return appErrorOwner(\"p\", getPrincipal());\n } catch {\n return null;\n }\n}\n\n/**\n * Stamp a report being raised now with its session and, when given, principal.\n *\n * @param getPrincipal - The app's principal getter, read synchronously.\n * @param token - The token read now, or a promise of it.\n * @returns The stamp, or a promise of it when the token is still arriving.\n */\nfunction stampNow(\n getPrincipal: CreateAppErrorReporterOptions[\"getPrincipal\"],\n token: string | null | Promise<string | null>,\n): AppErrorStamp | Promise<AppErrorStamp> {\n const owner = getPrincipal ? { owner: principalOwner(getPrincipal) } : {};\n if (typeof token === \"string\" || token === null) {\n return { session: appErrorOwner(\"t\", token), ...owner };\n }\n return token.then((value) => ({ session: appErrorOwner(\"t\", value), ...owner }));\n}\n\n/**\n * Report client errors to a Tempest backend's `POST /api/app-errors`, surviving\n * the offline stretches where field errors actually happen.\n *\n * The client half of `tempest-fastapi-sdk`'s `app_errors` module. An error is\n * described ({@link describeAppError}), stamped with the device facts of that\n * moment, fitted to the backend's column limits and queued in `localStorage`\n * before anything goes over the network — so a report raised with no signal is\n * sent when the browser comes back `online`, not lost.\n *\n * What each answer does to the queue:\n *\n * - **2xx** — removed.\n * - **429** — kept; sending pauses for `Retry-After`, then resumes on its own.\n * - **5xx, 408, network failure** — kept; the next report, `online` event or\n * `flush()` retries.\n * - **any other 4xx** — dropped: the backend will refuse that body forever, and\n * keeping it would block every report queued behind it.\n *\n * Each report is stamped with a one-way fingerprint of the token in force when\n * it was raised, and goes out with a bearer only when the token at send time is\n * that same one — never with the token of whoever signed in next. The queue\n * never stores a token. `clear()` also stops a flush in flight and discards\n * reports still waiting for an async token.\n *\n * @example\n * import { createAppErrorReporter } from \"tempest-react-sdk\";\n *\n * const reporter = createAppErrorReporter({\n * endpoint: `${import.meta.env.VITE_API_URL}/api/app-errors`,\n * getToken: () => sessionStorage.getItem(\"access_token\"),\n * device: () => ({ platform: \"web\", app_version: \"1.4.0\" }),\n * });\n *\n * reporter.report(new Error(\"crop failed\"), { step: \"crop\", size: 0 });\n *\n * @param options - Endpoint, token and device getters, storage and limits.\n * @returns The reporter.\n */\nexport function createAppErrorReporter(options: CreateAppErrorReporterOptions): AppErrorReporter {\n const { endpoint, getToken, getPrincipal, device, autoFlush = true } = options;\n warnOnDropWithoutPrincipal(options);\n const strict = getPrincipal !== undefined && options.foreignReports === \"drop\";\n const queue = createAppErrorQueue(\n options.storage === undefined ? defaultStorage() : options.storage,\n options.storageKey ?? \"tempest:app-errors\",\n options.maxEntries ?? 50,\n );\n let running: Promise<AppErrorFlushResult> | null = null;\n let pausedUntil = 0;\n let timer: ReturnType<typeof setTimeout> | null = null;\n const stamping = new Set<Promise<void>>();\n let generation = 0;\n\n const drain = async (): Promise<AppErrorFlushResult> => {\n const send = options.fetch ?? globalThis.fetch.bind(globalThis);\n await Promise.all(stamping);\n const token = await readToken(getToken);\n const session = appErrorOwner(\"t\", token);\n const current = getPrincipal ? principalOwner(getPrincipal) : null;\n const started = generation;\n const result: AppErrorFlushResult = { sent: 0, dropped: 0, pending: 0, retryAfterMs: null };\n for (const entry of queue.entries()) {\n if (!queue.has(entry)) continue;\n if (strict && (entry.owner == null || entry.owner !== current)) {\n queue.remove(entry);\n result.dropped += 1;\n continue;\n }\n const bearer = entry.session != null && entry.session === session ? token : null;\n const outcome = await sendOne(send, endpoint, bearer, entry);\n if (generation !== started) break;\n if (outcome.kind === \"stop\") break;\n if (outcome.kind === \"wait\") {\n result.retryAfterMs = outcome.ms;\n pausedUntil = Date.now() + outcome.ms;\n timer ??= setTimeout(() => {\n timer = null;\n void flush();\n }, outcome.ms);\n break;\n }\n queue.remove(entry);\n result[outcome.kind === \"sent\" ? \"sent\" : \"dropped\"] += 1;\n }\n result.pending = queue.entries().length;\n return result;\n };\n\n const flush = (): Promise<AppErrorFlushResult> => {\n running ??= drain().finally(() => {\n running = null;\n });\n return running;\n };\n\n const shouldSend = (): boolean =>\n autoFlush &&\n Date.now() >= pausedUntil &&\n (typeof navigator === \"undefined\" || navigator.onLine !== false);\n\n const onOnline = (): void => {\n if (shouldSend()) void flush();\n };\n if (autoFlush && typeof window !== \"undefined\") window.addEventListener(\"online\", onOnline);\n\n return {\n report(error, context) {\n const description = describeAppError(error, context);\n const report = fitAppErrorReport({ ...readDevice(device), ...description });\n const stamp = stampNow(getPrincipal, readToken(getToken));\n if (!(stamp instanceof Promise)) {\n queue.push(report, stamp);\n if (shouldSend()) void flush();\n return;\n }\n const raisedIn = generation;\n const stamped = stamp.then((value) => {\n stamping.delete(stamped);\n if (generation !== raisedIn) return;\n queue.push(report, value);\n if (shouldSend()) void flush();\n });\n stamping.add(stamped);\n },\n flush,\n pending: () => queue.entries(),\n clear() {\n generation += 1;\n queue.clear();\n },\n dispose() {\n if (typeof window !== \"undefined\") window.removeEventListener(\"online\", onOnline);\n if (timer) clearTimeout(timer);\n timer = null;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,64 +1,105 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { isDevBuild as e } from "../utils/dev-mode.js";
|
|
2
|
+
import { appErrorOwner as t } from "./app-error-owner.js";
|
|
3
|
+
import { createAppErrorQueue as n } from "./app-error-queue.js";
|
|
4
|
+
import { fitAppErrorReport as r } from "./app-error-schema.js";
|
|
5
|
+
import { readToken as i, sendOne as a } from "./app-error-transport.js";
|
|
6
|
+
import { describeAppError as o } from "./describe-app-error.js";
|
|
5
7
|
//#region src/telemetry/app-error-reporter.ts
|
|
6
|
-
|
|
8
|
+
var s = !1;
|
|
9
|
+
function c(t) {
|
|
10
|
+
s || t.foreignReports !== "drop" || t.getPrincipal === void 0 && e() && (s = !0, console.warn("[tempest-react-sdk] createAppErrorReporter({ foreignReports: \"drop\" }) needs `getPrincipal`: without it the reporter cannot prove a queued report belongs to someone else, so it drops nothing and those reports go out anonymously. Pass `getPrincipal` to make \"drop\" take effect."));
|
|
11
|
+
}
|
|
12
|
+
function l() {
|
|
7
13
|
try {
|
|
8
14
|
return typeof localStorage > "u" ? null : localStorage;
|
|
9
15
|
} catch {
|
|
10
16
|
return null;
|
|
11
17
|
}
|
|
12
18
|
}
|
|
13
|
-
function
|
|
19
|
+
function u(e) {
|
|
14
20
|
try {
|
|
15
21
|
return e?.() ?? {};
|
|
16
22
|
} catch {
|
|
17
23
|
return {};
|
|
18
24
|
}
|
|
19
25
|
}
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
function d(e) {
|
|
27
|
+
try {
|
|
28
|
+
return t("p", e());
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function f(e, n) {
|
|
34
|
+
let r = e ? { owner: d(e) } : {};
|
|
35
|
+
return typeof n == "string" || n === null ? {
|
|
36
|
+
session: t("t", n),
|
|
37
|
+
...r
|
|
38
|
+
} : n.then((e) => ({
|
|
39
|
+
session: t("t", e),
|
|
40
|
+
...r
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
function p(e) {
|
|
44
|
+
let { endpoint: s, getToken: p, getPrincipal: m, device: h, autoFlush: g = !0 } = e;
|
|
45
|
+
c(e);
|
|
46
|
+
let _ = m !== void 0 && e.foreignReports === "drop", v = n(e.storage === void 0 ? l() : e.storage, e.storageKey ?? "tempest:app-errors", e.maxEntries ?? 50), y = null, b = 0, x = null, S = /* @__PURE__ */ new Set(), C = 0, w = async () => {
|
|
47
|
+
let n = e.fetch ?? globalThis.fetch.bind(globalThis);
|
|
48
|
+
await Promise.all(S);
|
|
49
|
+
let r = await i(p), o = t("t", r), c = m ? d(m) : null, l = C, u = {
|
|
23
50
|
sent: 0,
|
|
24
51
|
dropped: 0,
|
|
25
52
|
pending: 0,
|
|
26
53
|
retryAfterMs: null
|
|
27
54
|
};
|
|
28
|
-
for (let
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
for (let e of v.entries()) {
|
|
56
|
+
if (!v.has(e)) continue;
|
|
57
|
+
if (_ && (e.owner == null || e.owner !== c)) {
|
|
58
|
+
v.remove(e), u.dropped += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
let t = e.session != null && e.session === o ? r : null, i = await a(n, s, t, e);
|
|
62
|
+
if (C !== l || i.kind === "stop") break;
|
|
63
|
+
if (i.kind === "wait") {
|
|
64
|
+
u.retryAfterMs = i.ms, b = Date.now() + i.ms, x ??= setTimeout(() => {
|
|
65
|
+
x = null, T();
|
|
66
|
+
}, i.ms);
|
|
35
67
|
break;
|
|
36
68
|
}
|
|
37
|
-
|
|
69
|
+
v.remove(e), u[i.kind === "sent" ? "sent" : "dropped"] += 1;
|
|
38
70
|
}
|
|
39
|
-
return
|
|
40
|
-
},
|
|
41
|
-
|
|
42
|
-
}),
|
|
43
|
-
|
|
71
|
+
return u.pending = v.entries().length, u;
|
|
72
|
+
}, T = () => (y ??= w().finally(() => {
|
|
73
|
+
y = null;
|
|
74
|
+
}), y), E = () => g && Date.now() >= b && (typeof navigator > "u" || navigator.onLine !== !1), D = () => {
|
|
75
|
+
E() && T();
|
|
44
76
|
};
|
|
45
|
-
return
|
|
46
|
-
report(e,
|
|
47
|
-
let
|
|
48
|
-
|
|
49
|
-
...
|
|
50
|
-
|
|
51
|
-
|
|
77
|
+
return g && typeof window < "u" && window.addEventListener("online", D), {
|
|
78
|
+
report(e, t) {
|
|
79
|
+
let n = o(e, t), a = r({
|
|
80
|
+
...u(h),
|
|
81
|
+
...n
|
|
82
|
+
}), s = f(m, i(p));
|
|
83
|
+
if (!(s instanceof Promise)) {
|
|
84
|
+
v.push(a, s), E() && T();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let c = C, l = s.then((e) => {
|
|
88
|
+
S.delete(l), C === c && (v.push(a, e), E() && T());
|
|
89
|
+
});
|
|
90
|
+
S.add(l);
|
|
91
|
+
},
|
|
92
|
+
flush: T,
|
|
93
|
+
pending: () => v.entries(),
|
|
94
|
+
clear() {
|
|
95
|
+
C += 1, v.clear();
|
|
52
96
|
},
|
|
53
|
-
flush: _,
|
|
54
|
-
pending: () => f.entries(),
|
|
55
|
-
clear: () => f.clear(),
|
|
56
97
|
dispose() {
|
|
57
|
-
typeof window < "u" && window.removeEventListener("online",
|
|
98
|
+
typeof window < "u" && window.removeEventListener("online", D), x && clearTimeout(x), x = null;
|
|
58
99
|
}
|
|
59
100
|
};
|
|
60
101
|
}
|
|
61
102
|
//#endregion
|
|
62
|
-
export {
|
|
103
|
+
export { p as createAppErrorReporter };
|
|
63
104
|
|
|
64
105
|
//# sourceMappingURL=app-error-reporter.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-error-reporter.js","names":[],"sources":["../../src/telemetry/app-error-reporter.ts"],"sourcesContent":["import { createAppErrorQueue } from \"./app-error-queue\";\nimport type { AppErrorQueueEntry, AppErrorQueueStorage } from \"./app-error-queue\";\nimport { fitAppErrorReport } from \"./app-error-schema\";\nimport type { AppErrorDeviceSchema } from \"./app-error-schema\";\nimport { resolveToken, sendOne } from \"./app-error-transport\";\nimport { describeAppError } from \"./describe-app-error\";\n\n/** Options for {@link createAppErrorReporter}. */\nexport interface CreateAppErrorReporterOptions {\n /** Full URL of the backend route, e.g. `${API_URL}/api/app-errors`. */\n endpoint: string;\n /**\n * Current access token, if any. Optional on purpose: the route is public,\n * and an error raised before login — or with a stale session — still has\n * to get through. A token that throws or resolves empty sends anonymously.\n */\n getToken?: () => string | null | undefined | Promise<string | null | undefined>;\n /**\n * Device facts, read **when the error is reported**, not when it is sent —\n * a report queued offline on `1.4.0` must not go out labelled `1.5.0`.\n */\n device?: () => AppErrorDeviceSchema;\n /** Where the queue persists. Defaults to `localStorage`; `null` keeps it in memory. */\n storage?: AppErrorQueueStorage | null;\n /** Storage key. Default `\"tempest:app-errors\"`. */\n storageKey?: string;\n /** Ceiling on distinct waiting reports; the oldest is evicted. Default `50`. */\n maxEntries?: number;\n /** `fetch` to send with. Defaults to the global one. */\n fetch?: typeof fetch;\n /** Send on every report and when the browser goes `online`. Default `true`. */\n autoFlush?: boolean;\n}\n\n/** What one {@link AppErrorReporter.flush} did. */\nexport interface AppErrorFlushResult {\n /** Reports the backend accepted. */\n sent: number;\n /** Reports the backend refused for good (a 4xx other than 408/429) and were dropped. */\n dropped: number;\n /** Reports still waiting. */\n pending: number;\n /** Milliseconds the backend asked to wait (429 `Retry-After`), or `null`. */\n retryAfterMs: number | null;\n}\n\n/** The reporter {@link createAppErrorReporter} returns. */\nexport interface AppErrorReporter {\n /** Queue an error with optional context, and try to send it. Never throws. */\n report: (error: unknown, context?: Record<string, unknown>) => void;\n /** Drain the queue now. Concurrent calls share one run. */\n flush: () => Promise<AppErrorFlushResult>;\n /** The waiting entries, oldest first. */\n pending: () => AppErrorQueueEntry[];\n /** Drop every waiting report. */\n clear: () => void;\n /** Remove the `online` listener and the retry timer. */\n dispose: () => void;\n}\n\n/**\n * Pick the default store, guarding hosts where touching `localStorage` throws.\n *\n * @returns `localStorage`, or `null` when there is none.\n */\nfunction defaultStorage(): AppErrorQueueStorage | null {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null;\n }\n}\n\n/**\n * Read the device facts, reporting without them when the getter throws.\n *\n * @param device - The caller's getter.\n * @returns The facts, or an empty object.\n */\nfunction readDevice(device: CreateAppErrorReporterOptions[\"device\"]): AppErrorDeviceSchema {\n try {\n return device?.() ?? {};\n } catch {\n return {};\n }\n}\n\n/**\n * Report client errors to a Tempest backend's `POST /api/app-errors`, surviving\n * the offline stretches where field errors actually happen.\n *\n * The client half of `tempest-fastapi-sdk`'s `app_errors` module. An error is\n * described ({@link describeAppError}), stamped with the device facts of that\n * moment, fitted to the backend's column limits and queued in `localStorage`\n * before anything goes over the network — so a report raised with no signal is\n * sent when the browser comes back `online`, not lost.\n *\n * What each answer does to the queue:\n *\n * - **2xx** — removed.\n * - **429** — kept; sending pauses for `Retry-After`, then resumes on its own.\n * - **5xx, 408, network failure** — kept; the next report, `online` event or\n * `flush()` retries.\n * - **any other 4xx** — dropped: the backend will refuse that body forever, and\n * keeping it would block every report queued behind it.\n *\n * @example\n * import { createAppErrorReporter } from \"tempest-react-sdk\";\n *\n * const reporter = createAppErrorReporter({\n * endpoint: `${import.meta.env.VITE_API_URL}/api/app-errors`,\n * getToken: () => sessionStorage.getItem(\"access_token\"),\n * device: () => ({ platform: \"web\", app_version: \"1.4.0\" }),\n * });\n *\n * reporter.report(new Error(\"crop failed\"), { step: \"crop\", size: 0 });\n *\n * @param options - Endpoint, token and device getters, storage and limits.\n * @returns The reporter.\n */\nexport function createAppErrorReporter(options: CreateAppErrorReporterOptions): AppErrorReporter {\n const { endpoint, getToken, device, autoFlush = true } = options;\n const queue = createAppErrorQueue(\n options.storage === undefined ? defaultStorage() : options.storage,\n options.storageKey ?? \"tempest:app-errors\",\n options.maxEntries ?? 50,\n );\n let running: Promise<AppErrorFlushResult> | null = null;\n let pausedUntil = 0;\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n const drain = async (): Promise<AppErrorFlushResult> => {\n const send = options.fetch ?? globalThis.fetch.bind(globalThis);\n const token = await resolveToken(getToken);\n const result: AppErrorFlushResult = { sent: 0, dropped: 0, pending: 0, retryAfterMs: null };\n for (const entry of queue.entries()) {\n const outcome = await sendOne(send, endpoint, token, entry);\n if (outcome.kind === \"stop\") break;\n if (outcome.kind === \"wait\") {\n result.retryAfterMs = outcome.ms;\n pausedUntil = Date.now() + outcome.ms;\n timer ??= setTimeout(() => {\n timer = null;\n void flush();\n }, outcome.ms);\n break;\n }\n queue.remove(entry.report);\n result[outcome.kind === \"sent\" ? \"sent\" : \"dropped\"] += 1;\n }\n result.pending = queue.entries().length;\n return result;\n };\n\n const flush = (): Promise<AppErrorFlushResult> => {\n running ??= drain().finally(() => {\n running = null;\n });\n return running;\n };\n\n const shouldSend = (): boolean =>\n autoFlush &&\n Date.now() >= pausedUntil &&\n (typeof navigator === \"undefined\" || navigator.onLine !== false);\n\n const onOnline = (): void => {\n if (shouldSend()) void flush();\n };\n if (autoFlush && typeof window !== \"undefined\") window.addEventListener(\"online\", onOnline);\n\n return {\n report(error, context) {\n const description = describeAppError(error, context);\n queue.push(fitAppErrorReport({ ...readDevice(device), ...description }));\n if (shouldSend()) void flush();\n },\n flush,\n pending: () => queue.entries(),\n clear: () => queue.clear(),\n dispose() {\n if (typeof window !== \"undefined\") window.removeEventListener(\"online\", onOnline);\n if (timer) clearTimeout(timer);\n timer = null;\n },\n };\n}\n"],"mappings":";;;;;AAiEA,SAAS,IAA8C;CACnD,IAAI;EACA,OAAO,OAAO,eAAiB,MAAc,OAAO;CACxD,QAAQ;EACJ,OAAO;CACX;AACJ;AAQA,SAAS,EAAW,GAAuE;CACvF,IAAI;EACA,OAAO,IAAS,KAAK,CAAC;CAC1B,QAAQ;EACJ,OAAO,CAAC;CACZ;AACJ;AAmCA,SAAgB,EAAuB,GAA0D;CAC7F,IAAM,EAAE,aAAU,aAAU,WAAQ,eAAY,OAAS,GACnD,IAAQ,EACV,EAAQ,YAAY,KAAA,IAAY,EAAe,IAAI,EAAQ,SAC3D,EAAQ,cAAc,sBACtB,EAAQ,cAAc,EAC1B,GACI,IAA+C,MAC/C,IAAc,GACd,IAA8C,MAE5C,IAAQ,YAA0C;EACpD,IAAM,IAAO,EAAQ,SAAS,WAAW,MAAM,KAAK,UAAU,GACxD,IAAQ,MAAM,EAAa,CAAQ,GACnC,IAA8B;GAAE,MAAM;GAAG,SAAS;GAAG,SAAS;GAAG,cAAc;EAAK;EAC1F,KAAK,IAAM,KAAS,EAAM,QAAQ,GAAG;GACjC,IAAM,IAAU,MAAM,EAAQ,GAAM,GAAU,GAAO,CAAK;GAC1D,IAAI,EAAQ,SAAS,QAAQ;GAC7B,IAAI,EAAQ,SAAS,QAAQ;IAGzB,AAFA,EAAO,eAAe,EAAQ,IAC9B,IAAc,KAAK,IAAI,IAAI,EAAQ,IACnC,MAAU,iBAAiB;KAEvB,AADA,IAAQ,MACR,EAAW;IACf,GAAG,EAAQ,EAAE;IACb;GACJ;GAEA,AADA,EAAM,OAAO,EAAM,MAAM,GACzB,EAAO,EAAQ,SAAS,SAAS,SAAS,cAAc;EAC5D;EAEA,OADA,EAAO,UAAU,EAAM,QAAQ,CAAC,CAAC,QAC1B;CACX,GAEM,WACF,MAAY,EAAM,CAAC,CAAC,cAAc;EAC9B,IAAU;CACd,CAAC,GACM,IAGL,UACF,KACA,KAAK,IAAI,KAAK,MACb,OAAO,YAAc,OAAe,UAAU,WAAW,KAExD,UAAuB;EACzB,AAAI,EAAW,KAAG,EAAW;CACjC;CAGA,OAFI,KAAa,OAAO,SAAW,OAAa,OAAO,iBAAiB,UAAU,CAAQ,GAEnF;EACH,OAAO,GAAO,GAAS;GACnB,IAAM,IAAc,EAAiB,GAAO,CAAO;GAEnD,AADA,EAAM,KAAK,EAAkB;IAAE,GAAG,EAAW,CAAM;IAAG,GAAG;GAAY,CAAC,CAAC,GACnE,EAAW,KAAG,EAAW;EACjC;EACA;EACA,eAAe,EAAM,QAAQ;EAC7B,aAAa,EAAM,MAAM;EACzB,UAAU;GAGN,AAFI,OAAO,SAAW,OAAa,OAAO,oBAAoB,UAAU,CAAQ,GAC5E,KAAO,aAAa,CAAK,GAC7B,IAAQ;EACZ;CACJ;AACJ"}
|
|
1
|
+
{"version":3,"file":"app-error-reporter.js","names":[],"sources":["../../src/telemetry/app-error-reporter.ts"],"sourcesContent":["import { appErrorOwner } from \"./app-error-owner\";\nimport { createAppErrorQueue } from \"./app-error-queue\";\nimport type { AppErrorQueueEntry, AppErrorQueueStorage, AppErrorStamp } from \"./app-error-queue\";\nimport { fitAppErrorReport } from \"./app-error-schema\";\nimport type { AppErrorDeviceSchema } from \"./app-error-schema\";\nimport { readToken, sendOne } from \"./app-error-transport\";\nimport { describeAppError } from \"./describe-app-error\";\nimport { isDevBuild } from \"../utils/dev-mode\";\n\n/** Options for {@link createAppErrorReporter}. */\nexport interface CreateAppErrorReporterOptions {\n /** Full URL of the backend route, e.g. `${API_URL}/api/app-errors`. */\n endpoint: string;\n /**\n * Current access token, if any. Optional on purpose: the route is public,\n * and an error raised before login — or with a stale session — still has\n * to get through. A token that throws or resolves empty sends anonymously.\n */\n getToken?: () => string | null | undefined | Promise<string | null | undefined>;\n /**\n * Who is signed in right now — a stable, non-secret id such as the user id.\n * Read **when the error is reported**, and stored in the queue only as a\n * one-way fingerprint (`entry.owner`). A non-string (an `async` getter\n * from untyped JS, an object) or a getter that throws reads as nobody.\n *\n * It never grants the bearer. The bearer goes out only when the token at\n * send time is the very token that was in force at report time (its\n * fingerprint is `entry.session`): the principal and the token come from\n * two sources, and a principal store that lags behind a login would\n * otherwise send A's report with B's token. The principal only tells\n * {@link CreateAppErrorReporterOptions.foreignReports} `\"drop\"` whose\n * report is whose.\n */\n getPrincipal?: () => string | number | null | undefined;\n /**\n * What to do with a queued report that cannot go out with the current\n * bearer — raised under another token, or queued before 0.77.0. Default\n * `\"anonymous\"`.\n *\n * - `\"anonymous\"` — send it without `Authorization`. The route is public,\n * so the error still reaches the backend; it is only not attributed to\n * anybody. This includes the user's own reports raised before a token\n * refresh: attribution is lost, never misplaced.\n * - `\"drop\"` — discard every report not proven to be the current\n * principal's: another principal, nobody, or unknown (no principal at\n * report time, a legacy entry, or nobody signed in now). For apps where\n * a previous user's error text must not leave the device after they\n * signed out. **Needs `getPrincipal`**: without it the only identity is\n * the token fingerprint, and a refresh changes it, so a mismatch is no\n * proof the report is someone else's — the reporter then sends\n * anonymously instead of destroying the user's own reports.\n *\n * Never is a report sent with a bearer other than the one it was raised\n * under: that would credit the previous user's error to whoever signed in\n * next.\n */\n foreignReports?: \"anonymous\" | \"drop\";\n /**\n * Device facts, read **when the error is reported**, not when it is sent —\n * a report queued offline on `1.4.0` must not go out labelled `1.5.0`.\n */\n device?: () => AppErrorDeviceSchema;\n /** Where the queue persists. Defaults to `localStorage`; `null` keeps it in memory. */\n storage?: AppErrorQueueStorage | null;\n /** Storage key. Default `\"tempest:app-errors\"`. */\n storageKey?: string;\n /** Ceiling on distinct waiting reports; the oldest is evicted. Default `50`. */\n maxEntries?: number;\n /** `fetch` to send with. Defaults to the global one. */\n fetch?: typeof fetch;\n /** Send on every report and when the browser goes `online`. Default `true`. */\n autoFlush?: boolean;\n}\n\n/** What one {@link AppErrorReporter.flush} did. */\nexport interface AppErrorFlushResult {\n /** Reports the backend accepted. */\n sent: number;\n /** Reports the backend refused for good (a 4xx other than 408/429) and were dropped. */\n dropped: number;\n /** Reports still waiting. */\n pending: number;\n /** Milliseconds the backend asked to wait (429 `Retry-After`), or `null`. */\n retryAfterMs: number | null;\n}\n\n/** The reporter {@link createAppErrorReporter} returns. */\nexport interface AppErrorReporter {\n /** Queue an error with optional context, and try to send it. Never throws. */\n report: (error: unknown, context?: Record<string, unknown>) => void;\n /** Drain the queue now. Concurrent calls share one run. */\n flush: () => Promise<AppErrorFlushResult>;\n /**\n * The waiting entries, oldest first. With an async `getToken` and no\n * `getPrincipal`, a report joins them once its token resolves — `flush()`\n * waits for that, `pending()` does not.\n */\n pending: () => AppErrorQueueEntry[];\n /**\n * Drop every waiting report. Call it on logout when reports raised in that\n * session should not outlive it; even without it, a report never leaves\n * with the next user's bearer.\n *\n * It also stops a flush in flight before its next send (the one already on\n * the wire cannot be recalled), and discards reports still waiting for an\n * async `getToken` to stamp them — both would otherwise refill the queue or\n * keep sending what the logout meant to forget.\n */\n clear: () => void;\n /** Remove the `online` listener and the retry timer. */\n dispose: () => void;\n}\n\n/** Set once the warning has printed, so every reporter built after it stays quiet. */\nlet warnedDropWithoutPrincipal = false;\n\n/**\n * Reset the warn-once latch. Test-only: the latch is module state, so a suite\n * asserting the warning more than once would otherwise see it only the first time.\n */\nexport function resetDropWithoutPrincipalWarning(): void {\n warnedDropWithoutPrincipal = false;\n}\n\n/**\n * Warn once, in development, that `foreignReports: \"drop\"` was asked for\n * without the `getPrincipal` it needs.\n *\n * Without a principal the only identity is the token fingerprint, which a\n * refresh changes, so the reporter cannot tell a foreign report from the\n * user's own and sends them anonymously instead of dropping them. The option\n * type-checks either way, so nothing else would tell the app its privacy\n * choice is not in force.\n *\n * @param options - The reporter's options.\n */\nfunction warnOnDropWithoutPrincipal(options: CreateAppErrorReporterOptions): void {\n if (warnedDropWithoutPrincipal || options.foreignReports !== \"drop\") return;\n if (options.getPrincipal !== undefined || !isDevBuild()) return;\n warnedDropWithoutPrincipal = true;\n console.warn(\n '[tempest-react-sdk] createAppErrorReporter({ foreignReports: \"drop\" }) needs ' +\n \"`getPrincipal`: without it the reporter cannot prove a queued report belongs \" +\n \"to someone else, so it drops nothing and those reports go out anonymously. \" +\n 'Pass `getPrincipal` to make \"drop\" take effect.',\n );\n}\n\n/**\n * Pick the default store, guarding hosts where touching `localStorage` throws.\n *\n * @returns `localStorage`, or `null` when there is none.\n */\nfunction defaultStorage(): AppErrorQueueStorage | null {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null;\n }\n}\n\n/**\n * Read the device facts, reporting without them when the getter throws.\n *\n * @param device - The caller's getter.\n * @returns The facts, or an empty object.\n */\nfunction readDevice(device: CreateAppErrorReporterOptions[\"device\"]): AppErrorDeviceSchema {\n try {\n return device?.() ?? {};\n } catch {\n return {};\n }\n}\n\n/**\n * Fingerprint whoever `getPrincipal` says is signed in.\n *\n * @param getPrincipal - The app's principal getter.\n * @returns The owner, or `null` when nobody is signed in or the getter throws.\n */\nfunction principalOwner(getPrincipal: () => unknown): string | null {\n try {\n return appErrorOwner(\"p\", getPrincipal());\n } catch {\n return null;\n }\n}\n\n/**\n * Stamp a report being raised now with its session and, when given, principal.\n *\n * @param getPrincipal - The app's principal getter, read synchronously.\n * @param token - The token read now, or a promise of it.\n * @returns The stamp, or a promise of it when the token is still arriving.\n */\nfunction stampNow(\n getPrincipal: CreateAppErrorReporterOptions[\"getPrincipal\"],\n token: string | null | Promise<string | null>,\n): AppErrorStamp | Promise<AppErrorStamp> {\n const owner = getPrincipal ? { owner: principalOwner(getPrincipal) } : {};\n if (typeof token === \"string\" || token === null) {\n return { session: appErrorOwner(\"t\", token), ...owner };\n }\n return token.then((value) => ({ session: appErrorOwner(\"t\", value), ...owner }));\n}\n\n/**\n * Report client errors to a Tempest backend's `POST /api/app-errors`, surviving\n * the offline stretches where field errors actually happen.\n *\n * The client half of `tempest-fastapi-sdk`'s `app_errors` module. An error is\n * described ({@link describeAppError}), stamped with the device facts of that\n * moment, fitted to the backend's column limits and queued in `localStorage`\n * before anything goes over the network — so a report raised with no signal is\n * sent when the browser comes back `online`, not lost.\n *\n * What each answer does to the queue:\n *\n * - **2xx** — removed.\n * - **429** — kept; sending pauses for `Retry-After`, then resumes on its own.\n * - **5xx, 408, network failure** — kept; the next report, `online` event or\n * `flush()` retries.\n * - **any other 4xx** — dropped: the backend will refuse that body forever, and\n * keeping it would block every report queued behind it.\n *\n * Each report is stamped with a one-way fingerprint of the token in force when\n * it was raised, and goes out with a bearer only when the token at send time is\n * that same one — never with the token of whoever signed in next. The queue\n * never stores a token. `clear()` also stops a flush in flight and discards\n * reports still waiting for an async token.\n *\n * @example\n * import { createAppErrorReporter } from \"tempest-react-sdk\";\n *\n * const reporter = createAppErrorReporter({\n * endpoint: `${import.meta.env.VITE_API_URL}/api/app-errors`,\n * getToken: () => sessionStorage.getItem(\"access_token\"),\n * device: () => ({ platform: \"web\", app_version: \"1.4.0\" }),\n * });\n *\n * reporter.report(new Error(\"crop failed\"), { step: \"crop\", size: 0 });\n *\n * @param options - Endpoint, token and device getters, storage and limits.\n * @returns The reporter.\n */\nexport function createAppErrorReporter(options: CreateAppErrorReporterOptions): AppErrorReporter {\n const { endpoint, getToken, getPrincipal, device, autoFlush = true } = options;\n warnOnDropWithoutPrincipal(options);\n const strict = getPrincipal !== undefined && options.foreignReports === \"drop\";\n const queue = createAppErrorQueue(\n options.storage === undefined ? defaultStorage() : options.storage,\n options.storageKey ?? \"tempest:app-errors\",\n options.maxEntries ?? 50,\n );\n let running: Promise<AppErrorFlushResult> | null = null;\n let pausedUntil = 0;\n let timer: ReturnType<typeof setTimeout> | null = null;\n const stamping = new Set<Promise<void>>();\n let generation = 0;\n\n const drain = async (): Promise<AppErrorFlushResult> => {\n const send = options.fetch ?? globalThis.fetch.bind(globalThis);\n await Promise.all(stamping);\n const token = await readToken(getToken);\n const session = appErrorOwner(\"t\", token);\n const current = getPrincipal ? principalOwner(getPrincipal) : null;\n const started = generation;\n const result: AppErrorFlushResult = { sent: 0, dropped: 0, pending: 0, retryAfterMs: null };\n for (const entry of queue.entries()) {\n if (!queue.has(entry)) continue;\n if (strict && (entry.owner == null || entry.owner !== current)) {\n queue.remove(entry);\n result.dropped += 1;\n continue;\n }\n const bearer = entry.session != null && entry.session === session ? token : null;\n const outcome = await sendOne(send, endpoint, bearer, entry);\n if (generation !== started) break;\n if (outcome.kind === \"stop\") break;\n if (outcome.kind === \"wait\") {\n result.retryAfterMs = outcome.ms;\n pausedUntil = Date.now() + outcome.ms;\n timer ??= setTimeout(() => {\n timer = null;\n void flush();\n }, outcome.ms);\n break;\n }\n queue.remove(entry);\n result[outcome.kind === \"sent\" ? \"sent\" : \"dropped\"] += 1;\n }\n result.pending = queue.entries().length;\n return result;\n };\n\n const flush = (): Promise<AppErrorFlushResult> => {\n running ??= drain().finally(() => {\n running = null;\n });\n return running;\n };\n\n const shouldSend = (): boolean =>\n autoFlush &&\n Date.now() >= pausedUntil &&\n (typeof navigator === \"undefined\" || navigator.onLine !== false);\n\n const onOnline = (): void => {\n if (shouldSend()) void flush();\n };\n if (autoFlush && typeof window !== \"undefined\") window.addEventListener(\"online\", onOnline);\n\n return {\n report(error, context) {\n const description = describeAppError(error, context);\n const report = fitAppErrorReport({ ...readDevice(device), ...description });\n const stamp = stampNow(getPrincipal, readToken(getToken));\n if (!(stamp instanceof Promise)) {\n queue.push(report, stamp);\n if (shouldSend()) void flush();\n return;\n }\n const raisedIn = generation;\n const stamped = stamp.then((value) => {\n stamping.delete(stamped);\n if (generation !== raisedIn) return;\n queue.push(report, value);\n if (shouldSend()) void flush();\n });\n stamping.add(stamped);\n },\n flush,\n pending: () => queue.entries(),\n clear() {\n generation += 1;\n queue.clear();\n },\n dispose() {\n if (typeof window !== \"undefined\") window.removeEventListener(\"online\", onOnline);\n if (timer) clearTimeout(timer);\n timer = null;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("./app-error-schema.cjs");function t(e){let t=Number(e);if(e!==null&&Number.isFinite(t))return Math.max(t,1)*1e3;let n=e?Date.parse(e):NaN;return Number.isNaN(n)?6e4:Math.max(n-Date.now(),1e3)}function n(t){if(t.count<=1)return t.report;let n=`[repeated ${t.count}×]\n${t.report.message}`;return e.fitAppErrorReport({...t.report,message:n})}async function r(e,r,i,a){let o={"Content-Type":`application/json`};i&&(o.Authorization=`Bearer ${i}`);try{let i=await e(r,{method:`POST`,headers:o,body:JSON.stringify(n(a))});return i.ok?{kind:`sent`}:i.status===429?{kind:`wait`,ms:t(i.headers.get(`Retry-After`))}:i.status===408||i.status>=500?{kind:`stop`}:{kind:`drop`}}catch{return{kind:`stop`}}}
|
|
1
|
+
const e=require("./app-error-schema.cjs");function t(e){let t=Number(e);if(e!==null&&Number.isFinite(t))return Math.max(t,1)*1e3;let n=e?Date.parse(e):NaN;return Number.isNaN(n)?6e4:Math.max(n-Date.now(),1e3)}function n(t){if(t.count<=1)return t.report;let n=`[repeated ${t.count}×]\n${t.report.message}`;return e.fitAppErrorReport({...t.report,message:n})}async function r(e,r,i,a){let o={"Content-Type":`application/json`};i&&(o.Authorization=`Bearer ${i}`);try{let i=await e(r,{method:`POST`,headers:o,body:JSON.stringify(n(a))});return i.ok?{kind:`sent`}:i.status===429?{kind:`wait`,ms:t(i.headers.get(`Retry-After`))}:i.status===408||i.status>=500?{kind:`stop`}:{kind:`drop`}}catch{return{kind:`stop`}}}var i=5e3;function a(e){return typeof e==`string`&&e!==``?e:null}function o(e){let t;try{t=e?.()}catch{return null}return typeof t!=`object`||!t?a(t):new Promise(e=>{let n=setTimeout(()=>e(null),i);Promise.resolve(t).then(t=>{clearTimeout(n),e(a(t))},()=>{clearTimeout(n),e(null)})})}exports.APP_ERROR_TOKEN_TIMEOUT_MS=i,exports.bodyFor=n,exports.readToken=o,exports.retryAfterMs=t,exports.sendOne=r;
|
|
2
2
|
//# sourceMappingURL=app-error-transport.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-error-transport.cjs","names":[],"sources":["../../src/telemetry/app-error-transport.ts"],"sourcesContent":["import type { AppErrorQueueEntry } from \"./app-error-queue\";\nimport { fitAppErrorReport } from \"./app-error-schema\";\nimport type { AppErrorReportSchema } from \"./app-error-schema\";\n\n/** What the backend's answer to one send means for the queue. */\nexport type SendOutcome = { kind: \"sent\" | \"drop\" | \"stop\" } | { kind: \"wait\"; ms: number };\n\n/**\n * Read a `Retry-After` header as milliseconds.\n *\n * @param header - The header value: delay-seconds or an HTTP date.\n * @returns The delay, at least one second.\n */\nexport function retryAfterMs(header: string | null): number {\n const seconds = Number(header);\n if (header !== null && Number.isFinite(seconds)) return Math.max(seconds, 1) * 1000;\n const date = header ? Date.parse(header) : Number.NaN;\n return Number.isNaN(date) ? 60_000 : Math.max(date - Date.now(), 1000);\n}\n\n/**\n * Build the body for one queued entry, marking how often it repeated.\n *\n * @param entry - The queued entry.\n * @returns The report to send.\n */\nexport function bodyFor(entry: AppErrorQueueEntry): AppErrorReportSchema {\n if (entry.count <= 1) return entry.report;\n const message = `[repeated ${entry.count}×]\\n${entry.report.message}`;\n return fitAppErrorReport({ ...entry.report, message });\n}\n\n/**\n * Send one entry and classify what the backend answered.\n *\n * @param send - The `fetch` to use.\n * @param endpoint - Route URL.\n * @param token - Bearer token, or `null`.\n * @param entry - What to send.\n * @returns `sent` (2xx), `wait` (429), `drop` (other 4xx) or `stop` (5xx, 408, network).\n * Anything unexpected — a rejected `fetch`, a wrapper that resolves without a\n * `Response` — is `stop`: the report stays queued, and the flush that\n * `report()` fires in the background never becomes an unhandled rejection.\n */\nexport async function sendOne(\n send: typeof fetch,\n endpoint: string,\n token: string | null,\n entry: AppErrorQueueEntry,\n): Promise<SendOutcome> {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n try {\n const response = await send(endpoint, {\n method: \"POST\",\n headers,\n body: JSON.stringify(bodyFor(entry)),\n });\n if (response.ok) return { kind: \"sent\" };\n if (response.status === 429) {\n return { kind: \"wait\", ms: retryAfterMs(response.headers.get(\"Retry-After\")) };\n }\n if (response.status === 408 || response.status >= 500) return { kind: \"stop\" };\n return { kind: \"drop\" };\n } catch {\n return { kind: \"stop\" };\n }\n}\n\n/**\n *
|
|
1
|
+
{"version":3,"file":"app-error-transport.cjs","names":[],"sources":["../../src/telemetry/app-error-transport.ts"],"sourcesContent":["import type { AppErrorQueueEntry } from \"./app-error-queue\";\nimport { fitAppErrorReport } from \"./app-error-schema\";\nimport type { AppErrorReportSchema } from \"./app-error-schema\";\n\n/** What the backend's answer to one send means for the queue. */\nexport type SendOutcome = { kind: \"sent\" | \"drop\" | \"stop\" } | { kind: \"wait\"; ms: number };\n\n/**\n * Read a `Retry-After` header as milliseconds.\n *\n * @param header - The header value: delay-seconds or an HTTP date.\n * @returns The delay, at least one second.\n */\nexport function retryAfterMs(header: string | null): number {\n const seconds = Number(header);\n if (header !== null && Number.isFinite(seconds)) return Math.max(seconds, 1) * 1000;\n const date = header ? Date.parse(header) : Number.NaN;\n return Number.isNaN(date) ? 60_000 : Math.max(date - Date.now(), 1000);\n}\n\n/**\n * Build the body for one queued entry, marking how often it repeated.\n *\n * @param entry - The queued entry.\n * @returns The report to send.\n */\nexport function bodyFor(entry: AppErrorQueueEntry): AppErrorReportSchema {\n if (entry.count <= 1) return entry.report;\n const message = `[repeated ${entry.count}×]\\n${entry.report.message}`;\n return fitAppErrorReport({ ...entry.report, message });\n}\n\n/**\n * Send one entry and classify what the backend answered.\n *\n * @param send - The `fetch` to use.\n * @param endpoint - Route URL.\n * @param token - Bearer token, or `null`.\n * @param entry - What to send.\n * @returns `sent` (2xx), `wait` (429), `drop` (other 4xx) or `stop` (5xx, 408, network).\n * Anything unexpected — a rejected `fetch`, a wrapper that resolves without a\n * `Response` — is `stop`: the report stays queued, and the flush that\n * `report()` fires in the background never becomes an unhandled rejection.\n */\nexport async function sendOne(\n send: typeof fetch,\n endpoint: string,\n token: string | null,\n entry: AppErrorQueueEntry,\n): Promise<SendOutcome> {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n try {\n const response = await send(endpoint, {\n method: \"POST\",\n headers,\n body: JSON.stringify(bodyFor(entry)),\n });\n if (response.ok) return { kind: \"sent\" };\n if (response.status === 429) {\n return { kind: \"wait\", ms: retryAfterMs(response.headers.get(\"Retry-After\")) };\n }\n if (response.status === 408 || response.status >= 500) return { kind: \"stop\" };\n return { kind: \"drop\" };\n } catch {\n return { kind: \"stop\" };\n }\n}\n\n/**\n * How long a report or a flush waits for an async `getToken` before giving up\n * and going without a token.\n *\n * A getter that never settles — a refresh stuck on a dead network, a promise\n * nobody resolves — must not hold the report out of the queue, nor the flush\n * (and every later one, which shares its run) forever. Five seconds covers a\n * slow refresh round trip on a weak connection; past it the report is stamped\n * with no session and leaves anonymously, which never misattributes.\n */\nexport const APP_ERROR_TOKEN_TIMEOUT_MS = 5_000;\n\n/** The token getter's shape, as {@link readToken} accepts it. */\nexport type AppErrorTokenGetter = () =>\n string | null | undefined | Promise<string | null | undefined>;\n\n/**\n * Keep only a usable token: a non-empty string.\n *\n * @param value - Whatever the getter produced, possibly from untyped JS.\n * @returns The token, or `null`.\n */\nfunction asToken(value: unknown): string | null {\n return typeof value === \"string\" && value !== \"\" ? value : null;\n}\n\n/**\n * Read the token, synchronously when the getter is synchronous.\n *\n * Staying synchronous for a synchronous getter is what lets `report()` queue\n * the report before it returns; an async getter is bounded by\n * {@link APP_ERROR_TOKEN_TIMEOUT_MS}. A getter that throws, rejects, times out\n * or produces anything but a non-empty string reads as no token.\n *\n * @param getToken - The caller's token getter.\n * @returns The token or `null`, directly or as a promise that always settles.\n */\nexport function readToken(\n getToken: AppErrorTokenGetter | undefined,\n): string | null | Promise<string | null> {\n let value: unknown;\n try {\n value = getToken?.();\n } catch {\n return null;\n }\n if (typeof value !== \"object\" || value === null) return asToken(value);\n return new Promise<string | null>((resolve) => {\n const timer = setTimeout(() => resolve(null), APP_ERROR_TOKEN_TIMEOUT_MS);\n Promise.resolve(value).then(\n (token) => {\n clearTimeout(timer);\n resolve(asToken(token));\n },\n () => {\n clearTimeout(timer);\n resolve(null);\n },\n );\n });\n}\n"],"mappings":"0CAaA,SAAgB,EAAa,EAA+B,CACxD,IAAM,EAAU,OAAO,CAAM,EAC7B,GAAI,IAAW,MAAQ,OAAO,SAAS,CAAO,EAAG,OAAO,KAAK,IAAI,EAAS,CAAC,EAAI,IAC/E,IAAM,EAAO,EAAS,KAAK,MAAM,CAAM,EAAI,IAC3C,OAAO,OAAO,MAAM,CAAI,EAAI,IAAS,KAAK,IAAI,EAAO,KAAK,IAAI,EAAG,GAAI,CACzE,CAQA,SAAgB,EAAQ,EAAiD,CACrE,GAAI,EAAM,OAAS,EAAG,OAAO,EAAM,OACnC,IAAM,EAAU,aAAa,EAAM,MAAM,MAAM,EAAM,OAAO,UAC5D,OAAO,EAAA,kBAAkB,CAAE,GAAG,EAAM,OAAQ,SAAQ,CAAC,CACzD,CAcA,eAAsB,EAClB,EACA,EACA,EACA,EACoB,CACpB,IAAM,EAAkC,CAAE,eAAgB,kBAAmB,EACzE,IAAO,EAAQ,cAAgB,UAAU,KAC7C,GAAI,CACA,IAAM,EAAW,MAAM,EAAK,EAAU,CAClC,OAAQ,OACR,UACA,KAAM,KAAK,UAAU,EAAQ,CAAK,CAAC,CACvC,CAAC,EAMD,OALI,EAAS,GAAW,CAAE,KAAM,MAAO,EACnC,EAAS,SAAW,IACb,CAAE,KAAM,OAAQ,GAAI,EAAa,EAAS,QAAQ,IAAI,aAAa,CAAC,CAAE,EAE7E,EAAS,SAAW,KAAO,EAAS,QAAU,IAAY,CAAE,KAAM,MAAO,EACtE,CAAE,KAAM,MAAO,CAC1B,MAAQ,CACJ,MAAO,CAAE,KAAM,MAAO,CAC1B,CACJ,CAYA,IAAa,EAA6B,IAY1C,SAAS,EAAQ,EAA+B,CAC5C,OAAO,OAAO,GAAU,UAAY,IAAU,GAAK,EAAQ,IAC/D,CAaA,SAAgB,EACZ,EACsC,CACtC,IAAI,EACJ,GAAI,CACA,EAAQ,IAAW,CACvB,MAAQ,CACJ,OAAO,IACX,CAEA,OADI,OAAO,GAAU,WAAY,EAAuB,EAAQ,CAAK,EAC9D,IAAI,QAAwB,GAAY,CAC3C,IAAM,EAAQ,eAAiB,EAAQ,IAAI,EAAG,CAA0B,EACxE,QAAQ,QAAQ,CAAK,CAAC,CAAC,KAClB,GAAU,CACP,aAAa,CAAK,EAClB,EAAQ,EAAQ,CAAK,CAAC,CAC1B,MACM,CACF,aAAa,CAAK,EAClB,EAAQ,IAAI,CAChB,CACJ,CACJ,CAAC,CACL"}
|
|
@@ -31,14 +31,27 @@ async function r(e, r, i, a) {
|
|
|
31
31
|
return { kind: "stop" };
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
var i = 5e3;
|
|
35
|
+
function a(e) {
|
|
36
|
+
return typeof e == "string" && e !== "" ? e : null;
|
|
37
|
+
}
|
|
38
|
+
function o(e) {
|
|
39
|
+
let t;
|
|
35
40
|
try {
|
|
36
|
-
|
|
41
|
+
t = e?.();
|
|
37
42
|
} catch {
|
|
38
43
|
return null;
|
|
39
44
|
}
|
|
45
|
+
return typeof t != "object" || !t ? a(t) : new Promise((e) => {
|
|
46
|
+
let n = setTimeout(() => e(null), i);
|
|
47
|
+
Promise.resolve(t).then((t) => {
|
|
48
|
+
clearTimeout(n), e(a(t));
|
|
49
|
+
}, () => {
|
|
50
|
+
clearTimeout(n), e(null);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
40
53
|
}
|
|
41
54
|
//#endregion
|
|
42
|
-
export { n as bodyFor,
|
|
55
|
+
export { i as APP_ERROR_TOKEN_TIMEOUT_MS, n as bodyFor, o as readToken, t as retryAfterMs, r as sendOne };
|
|
43
56
|
|
|
44
57
|
//# sourceMappingURL=app-error-transport.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-error-transport.js","names":[],"sources":["../../src/telemetry/app-error-transport.ts"],"sourcesContent":["import type { AppErrorQueueEntry } from \"./app-error-queue\";\nimport { fitAppErrorReport } from \"./app-error-schema\";\nimport type { AppErrorReportSchema } from \"./app-error-schema\";\n\n/** What the backend's answer to one send means for the queue. */\nexport type SendOutcome = { kind: \"sent\" | \"drop\" | \"stop\" } | { kind: \"wait\"; ms: number };\n\n/**\n * Read a `Retry-After` header as milliseconds.\n *\n * @param header - The header value: delay-seconds or an HTTP date.\n * @returns The delay, at least one second.\n */\nexport function retryAfterMs(header: string | null): number {\n const seconds = Number(header);\n if (header !== null && Number.isFinite(seconds)) return Math.max(seconds, 1) * 1000;\n const date = header ? Date.parse(header) : Number.NaN;\n return Number.isNaN(date) ? 60_000 : Math.max(date - Date.now(), 1000);\n}\n\n/**\n * Build the body for one queued entry, marking how often it repeated.\n *\n * @param entry - The queued entry.\n * @returns The report to send.\n */\nexport function bodyFor(entry: AppErrorQueueEntry): AppErrorReportSchema {\n if (entry.count <= 1) return entry.report;\n const message = `[repeated ${entry.count}×]\\n${entry.report.message}`;\n return fitAppErrorReport({ ...entry.report, message });\n}\n\n/**\n * Send one entry and classify what the backend answered.\n *\n * @param send - The `fetch` to use.\n * @param endpoint - Route URL.\n * @param token - Bearer token, or `null`.\n * @param entry - What to send.\n * @returns `sent` (2xx), `wait` (429), `drop` (other 4xx) or `stop` (5xx, 408, network).\n * Anything unexpected — a rejected `fetch`, a wrapper that resolves without a\n * `Response` — is `stop`: the report stays queued, and the flush that\n * `report()` fires in the background never becomes an unhandled rejection.\n */\nexport async function sendOne(\n send: typeof fetch,\n endpoint: string,\n token: string | null,\n entry: AppErrorQueueEntry,\n): Promise<SendOutcome> {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n try {\n const response = await send(endpoint, {\n method: \"POST\",\n headers,\n body: JSON.stringify(bodyFor(entry)),\n });\n if (response.ok) return { kind: \"sent\" };\n if (response.status === 429) {\n return { kind: \"wait\", ms: retryAfterMs(response.headers.get(\"Retry-After\")) };\n }\n if (response.status === 408 || response.status >= 500) return { kind: \"stop\" };\n return { kind: \"drop\" };\n } catch {\n return { kind: \"stop\" };\n }\n}\n\n/**\n *
|
|
1
|
+
{"version":3,"file":"app-error-transport.js","names":[],"sources":["../../src/telemetry/app-error-transport.ts"],"sourcesContent":["import type { AppErrorQueueEntry } from \"./app-error-queue\";\nimport { fitAppErrorReport } from \"./app-error-schema\";\nimport type { AppErrorReportSchema } from \"./app-error-schema\";\n\n/** What the backend's answer to one send means for the queue. */\nexport type SendOutcome = { kind: \"sent\" | \"drop\" | \"stop\" } | { kind: \"wait\"; ms: number };\n\n/**\n * Read a `Retry-After` header as milliseconds.\n *\n * @param header - The header value: delay-seconds or an HTTP date.\n * @returns The delay, at least one second.\n */\nexport function retryAfterMs(header: string | null): number {\n const seconds = Number(header);\n if (header !== null && Number.isFinite(seconds)) return Math.max(seconds, 1) * 1000;\n const date = header ? Date.parse(header) : Number.NaN;\n return Number.isNaN(date) ? 60_000 : Math.max(date - Date.now(), 1000);\n}\n\n/**\n * Build the body for one queued entry, marking how often it repeated.\n *\n * @param entry - The queued entry.\n * @returns The report to send.\n */\nexport function bodyFor(entry: AppErrorQueueEntry): AppErrorReportSchema {\n if (entry.count <= 1) return entry.report;\n const message = `[repeated ${entry.count}×]\\n${entry.report.message}`;\n return fitAppErrorReport({ ...entry.report, message });\n}\n\n/**\n * Send one entry and classify what the backend answered.\n *\n * @param send - The `fetch` to use.\n * @param endpoint - Route URL.\n * @param token - Bearer token, or `null`.\n * @param entry - What to send.\n * @returns `sent` (2xx), `wait` (429), `drop` (other 4xx) or `stop` (5xx, 408, network).\n * Anything unexpected — a rejected `fetch`, a wrapper that resolves without a\n * `Response` — is `stop`: the report stays queued, and the flush that\n * `report()` fires in the background never becomes an unhandled rejection.\n */\nexport async function sendOne(\n send: typeof fetch,\n endpoint: string,\n token: string | null,\n entry: AppErrorQueueEntry,\n): Promise<SendOutcome> {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n try {\n const response = await send(endpoint, {\n method: \"POST\",\n headers,\n body: JSON.stringify(bodyFor(entry)),\n });\n if (response.ok) return { kind: \"sent\" };\n if (response.status === 429) {\n return { kind: \"wait\", ms: retryAfterMs(response.headers.get(\"Retry-After\")) };\n }\n if (response.status === 408 || response.status >= 500) return { kind: \"stop\" };\n return { kind: \"drop\" };\n } catch {\n return { kind: \"stop\" };\n }\n}\n\n/**\n * How long a report or a flush waits for an async `getToken` before giving up\n * and going without a token.\n *\n * A getter that never settles — a refresh stuck on a dead network, a promise\n * nobody resolves — must not hold the report out of the queue, nor the flush\n * (and every later one, which shares its run) forever. Five seconds covers a\n * slow refresh round trip on a weak connection; past it the report is stamped\n * with no session and leaves anonymously, which never misattributes.\n */\nexport const APP_ERROR_TOKEN_TIMEOUT_MS = 5_000;\n\n/** The token getter's shape, as {@link readToken} accepts it. */\nexport type AppErrorTokenGetter = () =>\n string | null | undefined | Promise<string | null | undefined>;\n\n/**\n * Keep only a usable token: a non-empty string.\n *\n * @param value - Whatever the getter produced, possibly from untyped JS.\n * @returns The token, or `null`.\n */\nfunction asToken(value: unknown): string | null {\n return typeof value === \"string\" && value !== \"\" ? value : null;\n}\n\n/**\n * Read the token, synchronously when the getter is synchronous.\n *\n * Staying synchronous for a synchronous getter is what lets `report()` queue\n * the report before it returns; an async getter is bounded by\n * {@link APP_ERROR_TOKEN_TIMEOUT_MS}. A getter that throws, rejects, times out\n * or produces anything but a non-empty string reads as no token.\n *\n * @param getToken - The caller's token getter.\n * @returns The token or `null`, directly or as a promise that always settles.\n */\nexport function readToken(\n getToken: AppErrorTokenGetter | undefined,\n): string | null | Promise<string | null> {\n let value: unknown;\n try {\n value = getToken?.();\n } catch {\n return null;\n }\n if (typeof value !== \"object\" || value === null) return asToken(value);\n return new Promise<string | null>((resolve) => {\n const timer = setTimeout(() => resolve(null), APP_ERROR_TOKEN_TIMEOUT_MS);\n Promise.resolve(value).then(\n (token) => {\n clearTimeout(timer);\n resolve(asToken(token));\n },\n () => {\n clearTimeout(timer);\n resolve(null);\n },\n );\n });\n}\n"],"mappings":";;AAaA,SAAgB,EAAa,GAA+B;CACxD,IAAM,IAAU,OAAO,CAAM;CAC7B,IAAI,MAAW,QAAQ,OAAO,SAAS,CAAO,GAAG,OAAO,KAAK,IAAI,GAAS,CAAC,IAAI;CAC/E,IAAM,IAAO,IAAS,KAAK,MAAM,CAAM,IAAI;CAC3C,OAAO,OAAO,MAAM,CAAI,IAAI,MAAS,KAAK,IAAI,IAAO,KAAK,IAAI,GAAG,GAAI;AACzE;AAQA,SAAgB,EAAQ,GAAiD;CACrE,IAAI,EAAM,SAAS,GAAG,OAAO,EAAM;CACnC,IAAM,IAAU,aAAa,EAAM,MAAM,MAAM,EAAM,OAAO;CAC5D,OAAO,EAAkB;EAAE,GAAG,EAAM;EAAQ;CAAQ,CAAC;AACzD;AAcA,eAAsB,EAClB,GACA,GACA,GACA,GACoB;CACpB,IAAM,IAAkC,EAAE,gBAAgB,mBAAmB;CAC7E,AAAI,MAAO,EAAQ,gBAAgB,UAAU;CAC7C,IAAI;EACA,IAAM,IAAW,MAAM,EAAK,GAAU;GAClC,QAAQ;GACR;GACA,MAAM,KAAK,UAAU,EAAQ,CAAK,CAAC;EACvC,CAAC;EAMD,OALI,EAAS,KAAW,EAAE,MAAM,OAAO,IACnC,EAAS,WAAW,MACb;GAAE,MAAM;GAAQ,IAAI,EAAa,EAAS,QAAQ,IAAI,aAAa,CAAC;EAAE,IAE7E,EAAS,WAAW,OAAO,EAAS,UAAU,MAAY,EAAE,MAAM,OAAO,IACtE,EAAE,MAAM,OAAO;CAC1B,QAAQ;EACJ,OAAO,EAAE,MAAM,OAAO;CAC1B;AACJ;AAYA,IAAa,IAA6B;AAY1C,SAAS,EAAQ,GAA+B;CAC5C,OAAO,OAAO,KAAU,YAAY,MAAU,KAAK,IAAQ;AAC/D;AAaA,SAAgB,EACZ,GACsC;CACtC,IAAI;CACJ,IAAI;EACA,IAAQ,IAAW;CACvB,QAAQ;EACJ,OAAO;CACX;CAEA,OADI,OAAO,KAAU,aAAY,IAAuB,EAAQ,CAAK,IAC9D,IAAI,SAAwB,MAAY;EAC3C,IAAM,IAAQ,iBAAiB,EAAQ,IAAI,GAAG,CAA0B;EACxE,QAAQ,QAAQ,CAAK,CAAC,CAAC,MAClB,MAAU;GAEP,AADA,aAAa,CAAK,GAClB,EAAQ,EAAQ,CAAK,CAAC;EAC1B,SACM;GAEF,AADA,aAAa,CAAK,GAClB,EAAQ,IAAI;EAChB,CACJ;CACJ,CAAC;AACL"}
|