@aglyn/aglyn 1.0.0-beta.169 → 1.0.0-beta.170
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/package.json +10 -10
- package/src/lib/app-utils/contact-holder.d.ts +16 -0
- package/src/lib/app-utils/contact-holder.js +31 -2
- package/src/lib/app-utils/contact-holder.js.map +1 -1
- package/src/lib/app-utils/error-beacon.d.ts +24 -0
- package/src/lib/app-utils/error-beacon.js +48 -2
- package/src/lib/app-utils/error-beacon.js.map +1 -1
- package/src/lib/app-utils/stale-build-error.d.ts +34 -0
- package/src/lib/app-utils/stale-build-error.js +100 -0
- package/src/lib/app-utils/stale-build-error.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/aglyn",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.170",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"./package.json": "./package.json"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-logger": "1.0.0-beta.
|
|
42
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
43
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
44
|
-
"@aglyn/shared-util-vendor": "1.0.0-beta.
|
|
36
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.170",
|
|
37
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.170",
|
|
38
|
+
"@aglyn/shared-data-types": "1.0.0-beta.170",
|
|
39
|
+
"@aglyn/shared-util-email": "1.0.0-beta.170",
|
|
40
|
+
"@aglyn/shared-util-http": "1.0.0-beta.170",
|
|
41
|
+
"@aglyn/shared-util-logger": "1.0.0-beta.170",
|
|
42
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.170",
|
|
43
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.170",
|
|
44
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.170",
|
|
45
45
|
"@data-driven-forms/react-form-renderer": "^4.2.0",
|
|
46
46
|
"@msgpack/msgpack": "^3.1.3",
|
|
47
47
|
"@types/unist": "^3.0.3",
|
|
@@ -28,6 +28,22 @@ import { type ConsentGroup } from './consent-groups';
|
|
|
28
28
|
* empty string.
|
|
29
29
|
*/
|
|
30
30
|
export declare const NO_HOLDER_GROUP: ConsentGroup;
|
|
31
|
+
/**
|
|
32
|
+
* The group a LEAD is read and acted on through (AGL-3278).
|
|
33
|
+
*
|
|
34
|
+
* The same question {@link contactPrimaryGroup} answers, with a shorter
|
|
35
|
+
* answer: a lead is one flat document — no facets, one profile, one
|
|
36
|
+
* status — so there is nothing to choose between and the holder is simply
|
|
37
|
+
* the site that captured the person first. What it is FOR is the same: at
|
|
38
|
+
* the organization level the record page has no viewing site, and it needs
|
|
39
|
+
* one to read the consent basis against, to file an activity under, to
|
|
40
|
+
* offer a booking on and to convert as.
|
|
41
|
+
*
|
|
42
|
+
* A lead no site has captured — a row imported before attribution — reads
|
|
43
|
+
* through {@link NO_HOLDER_GROUP}, whose empty `hostId` is how a surface
|
|
44
|
+
* asks "is there a site here at all" without testing for an empty string.
|
|
45
|
+
*/
|
|
46
|
+
export declare function leadPrimaryGroup(lead: Record<string, unknown> | null | undefined, org: Record<string, unknown> | null | undefined): ConsentGroup;
|
|
31
47
|
/**
|
|
32
48
|
* The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).
|
|
33
49
|
*
|
|
@@ -46,6 +46,36 @@ import { consentGroupForHost, readConsentGroups, soloConsentGroup } from "./cons
|
|
|
46
46
|
hostIds: [],
|
|
47
47
|
declared: false
|
|
48
48
|
});
|
|
49
|
+
/**
|
|
50
|
+
* The sites that captured this person, in CAPTURE order — the order
|
|
51
|
+
* `arrayUnion` left, so the first entry is the site that met them first.
|
|
52
|
+
*
|
|
53
|
+
* `contactCaptureHostIds` sorts the same array for RENDERING, where a
|
|
54
|
+
* stable order matters more than a first. Both readers below want the
|
|
55
|
+
* first, so neither may use the sorted one.
|
|
56
|
+
*/ function captureOrder(record) {
|
|
57
|
+
const raw = (record != null ? record : {})[CAPTURED_BY_HOST_FIELD];
|
|
58
|
+
if (!Array.isArray(raw)) return [];
|
|
59
|
+
return raw.map((id)=>String(id != null ? id : '').trim()).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The group a LEAD is read and acted on through (AGL-3278).
|
|
63
|
+
*
|
|
64
|
+
* The same question {@link contactPrimaryGroup} answers, with a shorter
|
|
65
|
+
* answer: a lead is one flat document — no facets, one profile, one
|
|
66
|
+
* status — so there is nothing to choose between and the holder is simply
|
|
67
|
+
* the site that captured the person first. What it is FOR is the same: at
|
|
68
|
+
* the organization level the record page has no viewing site, and it needs
|
|
69
|
+
* one to read the consent basis against, to file an activity under, to
|
|
70
|
+
* offer a booking on and to convert as.
|
|
71
|
+
*
|
|
72
|
+
* A lead no site has captured — a row imported before attribution — reads
|
|
73
|
+
* through {@link NO_HOLDER_GROUP}, whose empty `hostId` is how a surface
|
|
74
|
+
* asks "is there a site here at all" without testing for an empty string.
|
|
75
|
+
*/ export function leadPrimaryGroup(lead, org) {
|
|
76
|
+
const captured = captureOrder(lead);
|
|
77
|
+
return captured.length ? consentGroupForHost(org, captured[0]) : NO_HOLDER_GROUP;
|
|
78
|
+
}
|
|
49
79
|
/**
|
|
50
80
|
* The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).
|
|
51
81
|
*
|
|
@@ -70,8 +100,7 @@ import { consentGroupForHost, readConsentGroups, soloConsentGroup } from "./cons
|
|
|
70
100
|
const record = row != null ? row : {};
|
|
71
101
|
const facets = record[CONTACT_FACETS_FIELD];
|
|
72
102
|
const facetKeys = facets && typeof facets === 'object' && !Array.isArray(facets) ? Object.keys(facets) : [];
|
|
73
|
-
const
|
|
74
|
-
const captured = Array.isArray(raw) ? raw.map((id)=>String(id != null ? id : '').trim()).filter(Boolean) : [];
|
|
103
|
+
const captured = captureOrder(record);
|
|
75
104
|
for (const hostId of captured){
|
|
76
105
|
const group = consentGroupForHost(org, hostId);
|
|
77
106
|
if (facetKeys.includes(group.groupId)) return group;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contact-holder.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHICH HOLDER A CROSS-SITE READER SEES A CONTACT THROUGH (AGL-2630).\n *\n * A contact document carries one facet per holder, and every field but the\n * shared identity lives in one of them. A surface standing on a site reads\n * that site's group; a surface standing at the organization — the org-level\n * Contacts list, and the whole-collection export that writes the same file\n * — has no site to read as and needs one answer per row.\n *\n * In the library rather than in the CRM plugin because both of those\n * readers need it and one of them is an app: an app may not import a\n * feature plugin (AGL-2662).\n */\n\nimport { CONTACT_FACETS_FIELD } from './contacts'\nimport { CAPTURED_BY_HOST_FIELD } from './marketing-consent'\nimport {\n type ConsentGroup,\n consentGroupForHost,\n readConsentGroups,\n soloConsentGroup,\n} from './consent-groups'\n\n/**\n * The holder of nothing: the group a contact is flattened through when no\n * site has captured it and no facet exists — a row written by a path that\n * predates attribution, read at the org level (AGL-2630).\n *\n * An empty `groupId` reads an empty facet (`readContactFacet` answers the\n * empty facet for a key the map lacks) and REFUSES a write\n * (`contactFacetPath` throws on an empty holder), which is the right pair:\n * the row still shows its shared identity, and nothing edits a facet\n * nobody holds. Exported so a surface can test for it rather than for an\n * empty string.\n */\nexport const NO_HOLDER_GROUP: ConsentGroup = Object.freeze({\n hostId: '',\n groupId: '',\n name: null,\n hostIds: [],\n declared: false,\n}) as ConsentGroup\n\n/**\n * The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).\n *\n * Under a site there is one answer for every row — the site's own group,\n * `consentGroupForHost` — and the reader never asks this. At the\n * organization level there is no viewing site, and a contact captured by\n * three sites has up to three facets; the org-wide member reading them all\n * needs ONE to show the row's profile, owner and stage under, and to write\n * an edit back to. This picks the person's PRIMARY holder: the first\n * capturing site, in capture order, whose group actually holds a facet;\n * then any facet at all, resolved back to its declared group or to the\n * site whose id it is keyed by; then the first capturing site even with no\n * facet yet (the facet is written on the first edit). A row nobody captured\n * and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.\n *\n * Capture order rather than sorted order, deliberately: `capturedByHostIds`\n * is maintained by `arrayUnion`, so its first entry is the site that met\n * the person first, and that site's profile is the one a person reading\n * across brands would expect to see. `contactCaptureHostIds` sorts the same\n * array for RENDERING, where a stable order matters more than a first.\n */\nexport function contactPrimaryGroup(\n row: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const record = row ?? {}\n const facets = record[CONTACT_FACETS_FIELD]\n const facetKeys =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets as Record<string, unknown>)\n : []\n const
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contact-holder.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHICH HOLDER A CROSS-SITE READER SEES A CONTACT THROUGH (AGL-2630).\n *\n * A contact document carries one facet per holder, and every field but the\n * shared identity lives in one of them. A surface standing on a site reads\n * that site's group; a surface standing at the organization — the org-level\n * Contacts list, and the whole-collection export that writes the same file\n * — has no site to read as and needs one answer per row.\n *\n * In the library rather than in the CRM plugin because both of those\n * readers need it and one of them is an app: an app may not import a\n * feature plugin (AGL-2662).\n */\n\nimport { CONTACT_FACETS_FIELD } from './contacts'\nimport { CAPTURED_BY_HOST_FIELD } from './marketing-consent'\nimport {\n type ConsentGroup,\n consentGroupForHost,\n readConsentGroups,\n soloConsentGroup,\n} from './consent-groups'\n\n/**\n * The holder of nothing: the group a contact is flattened through when no\n * site has captured it and no facet exists — a row written by a path that\n * predates attribution, read at the org level (AGL-2630).\n *\n * An empty `groupId` reads an empty facet (`readContactFacet` answers the\n * empty facet for a key the map lacks) and REFUSES a write\n * (`contactFacetPath` throws on an empty holder), which is the right pair:\n * the row still shows its shared identity, and nothing edits a facet\n * nobody holds. Exported so a surface can test for it rather than for an\n * empty string.\n */\nexport const NO_HOLDER_GROUP: ConsentGroup = Object.freeze({\n hostId: '',\n groupId: '',\n name: null,\n hostIds: [],\n declared: false,\n}) as ConsentGroup\n\n/**\n * The sites that captured this person, in CAPTURE order — the order\n * `arrayUnion` left, so the first entry is the site that met them first.\n *\n * `contactCaptureHostIds` sorts the same array for RENDERING, where a\n * stable order matters more than a first. Both readers below want the\n * first, so neither may use the sorted one.\n */\nfunction captureOrder(\n record: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (record ?? {})[CAPTURED_BY_HOST_FIELD]\n if (!Array.isArray(raw)) return []\n return raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n}\n\n/**\n * The group a LEAD is read and acted on through (AGL-3278).\n *\n * The same question {@link contactPrimaryGroup} answers, with a shorter\n * answer: a lead is one flat document — no facets, one profile, one\n * status — so there is nothing to choose between and the holder is simply\n * the site that captured the person first. What it is FOR is the same: at\n * the organization level the record page has no viewing site, and it needs\n * one to read the consent basis against, to file an activity under, to\n * offer a booking on and to convert as.\n *\n * A lead no site has captured — a row imported before attribution — reads\n * through {@link NO_HOLDER_GROUP}, whose empty `hostId` is how a surface\n * asks \"is there a site here at all\" without testing for an empty string.\n */\nexport function leadPrimaryGroup(\n lead: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const captured = captureOrder(lead)\n return captured.length ? consentGroupForHost(org, captured[0]) : NO_HOLDER_GROUP\n}\n\n/**\n * The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).\n *\n * Under a site there is one answer for every row — the site's own group,\n * `consentGroupForHost` — and the reader never asks this. At the\n * organization level there is no viewing site, and a contact captured by\n * three sites has up to three facets; the org-wide member reading them all\n * needs ONE to show the row's profile, owner and stage under, and to write\n * an edit back to. This picks the person's PRIMARY holder: the first\n * capturing site, in capture order, whose group actually holds a facet;\n * then any facet at all, resolved back to its declared group or to the\n * site whose id it is keyed by; then the first capturing site even with no\n * facet yet (the facet is written on the first edit). A row nobody captured\n * and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.\n *\n * Capture order rather than sorted order, deliberately: `capturedByHostIds`\n * is maintained by `arrayUnion`, so its first entry is the site that met\n * the person first, and that site's profile is the one a person reading\n * across brands would expect to see. `contactCaptureHostIds` sorts the same\n * array for RENDERING, where a stable order matters more than a first.\n */\nexport function contactPrimaryGroup(\n row: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const record = row ?? {}\n const facets = record[CONTACT_FACETS_FIELD]\n const facetKeys =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets as Record<string, unknown>)\n : []\n const captured = captureOrder(record)\n for (const hostId of captured) {\n const group = consentGroupForHost(org, hostId)\n if (facetKeys.includes(group.groupId)) return group\n }\n for (const key of facetKeys) {\n const declared = readConsentGroups(org)[key]\n if (declared) {\n return {\n hostId: declared.hostIds[0],\n groupId: key,\n name: declared.name,\n hostIds: [...declared.hostIds],\n declared: true,\n }\n }\n return soloConsentGroup(key)\n }\n if (captured.length) return consentGroupForHost(org, captured[0])\n return NO_HOLDER_GROUP\n}"],"names":["CONTACT_FACETS_FIELD","CAPTURED_BY_HOST_FIELD","consentGroupForHost","readConsentGroups","soloConsentGroup","NO_HOLDER_GROUP","Object","freeze","hostId","groupId","name","hostIds","declared","captureOrder","record","raw","Array","isArray","map","id","String","trim","filter","Boolean","leadPrimaryGroup","lead","org","captured","length","contactPrimaryGroup","row","facets","facetKeys","keys","group","includes","key"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,SAASA,oBAAoB,QAAQ,gBAAY;AACjD,SAASC,sBAAsB,QAAQ,yBAAqB;AAC5D,SAEEC,mBAAmB,EACnBC,iBAAiB,EACjBC,gBAAgB,QACX,sBAAkB;AAEzB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,kBAAgCC,OAAOC,MAAM,CAAC;IACzDC,QAAQ;IACRC,SAAS;IACTC,MAAM;IACNC,SAAS,EAAE;IACXC,UAAU;AACZ,GAAkB;AAElB;;;;;;;CAOC,GACD,SAASC,aACPC,MAAkD;IAElD,MAAMC,MAAM,CAACD,iBAAAA,SAAU,CAAC,EAAE,CAACb,uBAAuB;IAClD,IAAI,CAACe,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,OAAOA,IAAIG,GAAG,CAAC,CAACC,KAAOC,OAAOD,aAAAA,KAAM,IAAIE,IAAI,IAAIC,MAAM,CAACC;AACzD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,iBACdC,IAAgD,EAChDC,GAA+C;IAE/C,MAAMC,WAAWd,aAAaY;IAC9B,OAAOE,SAASC,MAAM,GAAG1B,oBAAoBwB,KAAKC,QAAQ,CAAC,EAAE,IAAItB;AACnE;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASwB,oBACdC,GAA+C,EAC/CJ,GAA+C;IAE/C,MAAMZ,SAASgB,cAAAA,MAAO,CAAC;IACvB,MAAMC,SAASjB,MAAM,CAACd,qBAAqB;IAC3C,MAAMgC,YACJD,UAAU,OAAOA,WAAW,YAAY,CAACf,MAAMC,OAAO,CAACc,UACnDzB,OAAO2B,IAAI,CAACF,UACZ,EAAE;IACR,MAAMJ,WAAWd,aAAaC;IAC9B,KAAK,MAAMN,UAAUmB,SAAU;QAC7B,MAAMO,QAAQhC,oBAAoBwB,KAAKlB;QACvC,IAAIwB,UAAUG,QAAQ,CAACD,MAAMzB,OAAO,GAAG,OAAOyB;IAChD;IACA,KAAK,MAAME,OAAOJ,UAAW;QAC3B,MAAMpB,WAAWT,kBAAkBuB,IAAI,CAACU,IAAI;QAC5C,IAAIxB,UAAU;YACZ,OAAO;gBACLJ,QAAQI,SAASD,OAAO,CAAC,EAAE;gBAC3BF,SAAS2B;gBACT1B,MAAME,SAASF,IAAI;gBACnBC,SAAS;uBAAIC,SAASD,OAAO;iBAAC;gBAC9BC,UAAU;YACZ;QACF;QACA,OAAOR,iBAAiBgC;IAC1B;IACA,IAAIT,SAASC,MAAM,EAAE,OAAO1B,oBAAoBwB,KAAKC,QAAQ,CAAC,EAAE;IAChE,OAAOtB;AACT"}
|
|
@@ -76,6 +76,30 @@ export interface ErrorBeaconOptions {
|
|
|
76
76
|
*/
|
|
77
77
|
export declare function isInjectedThirdPartyFrame(stack: string, documentUrl: string): boolean;
|
|
78
78
|
export declare function isHydrationMismatch(message: string): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* WHAT A REJECTED PROMISE ACTUALLY CARRIED (AGL-3279).
|
|
81
|
+
*
|
|
82
|
+
* `unhandledrejection` hands over whatever was passed to `reject`, and only
|
|
83
|
+
* an `Error` brings a message and a stack. A rejection with anything else —
|
|
84
|
+
* an API's `{ code, message }` object, a `DOMException` from an aborted
|
|
85
|
+
* fetch, a bare string, a `Response` — used to be reported as the literal
|
|
86
|
+
* words "Unhandled promise rejection" with no stack and no other field,
|
|
87
|
+
* which names the handler rather than the failure. One such report arrived
|
|
88
|
+
* on 2026-09-21 from the Besigner editor and there is nothing in it to act
|
|
89
|
+
* on: no message, no stack, no code, nothing but the page.
|
|
90
|
+
*
|
|
91
|
+
* So the value is DESCRIBED rather than defaulted. In order of how much a
|
|
92
|
+
* reader can do with it: the error's own message; a string as itself; a
|
|
93
|
+
* `name`/`code`/`message` shape assembled from whatever of those it has —
|
|
94
|
+
* the shape every Firebase, DOM and fetch rejection takes; the value's own
|
|
95
|
+
* `toString` when it says more than `[object Object]`; and last its own
|
|
96
|
+
* keys, which at least name what kind of thing rejected.
|
|
97
|
+
*
|
|
98
|
+
* Nothing here may throw: a getter on the reason can, `toString` can, and
|
|
99
|
+
* a describer that throws inside the rejection handler loses the report it
|
|
100
|
+
* was called to improve.
|
|
101
|
+
*/
|
|
102
|
+
export declare function describeRejectionReason(reason: unknown): string;
|
|
79
103
|
/**
|
|
80
104
|
* Re-export so `error-beacon` stays the one name a caller has to know for
|
|
81
105
|
* browser error reporting. The definition lives in its own module because a
|
|
@@ -137,6 +137,53 @@ const HYDRATION_TEXT = /Hydration failed because|Text content does not match ser
|
|
|
137
137
|
export function isHydrationMismatch(message) {
|
|
138
138
|
return HYDRATION_MINIFIED.test(message) || HYDRATION_TEXT.test(message);
|
|
139
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* WHAT A REJECTED PROMISE ACTUALLY CARRIED (AGL-3279).
|
|
142
|
+
*
|
|
143
|
+
* `unhandledrejection` hands over whatever was passed to `reject`, and only
|
|
144
|
+
* an `Error` brings a message and a stack. A rejection with anything else —
|
|
145
|
+
* an API's `{ code, message }` object, a `DOMException` from an aborted
|
|
146
|
+
* fetch, a bare string, a `Response` — used to be reported as the literal
|
|
147
|
+
* words "Unhandled promise rejection" with no stack and no other field,
|
|
148
|
+
* which names the handler rather than the failure. One such report arrived
|
|
149
|
+
* on 2026-09-21 from the Besigner editor and there is nothing in it to act
|
|
150
|
+
* on: no message, no stack, no code, nothing but the page.
|
|
151
|
+
*
|
|
152
|
+
* So the value is DESCRIBED rather than defaulted. In order of how much a
|
|
153
|
+
* reader can do with it: the error's own message; a string as itself; a
|
|
154
|
+
* `name`/`code`/`message` shape assembled from whatever of those it has —
|
|
155
|
+
* the shape every Firebase, DOM and fetch rejection takes; the value's own
|
|
156
|
+
* `toString` when it says more than `[object Object]`; and last its own
|
|
157
|
+
* keys, which at least name what kind of thing rejected.
|
|
158
|
+
*
|
|
159
|
+
* Nothing here may throw: a getter on the reason can, `toString` can, and
|
|
160
|
+
* a describer that throws inside the rejection handler loses the report it
|
|
161
|
+
* was called to improve.
|
|
162
|
+
*/ export function describeRejectionReason(reason) {
|
|
163
|
+
try {
|
|
164
|
+
var _ref, _shape_name, _ref1, _shape_message;
|
|
165
|
+
if (reason instanceof Error && reason.message) return reason.message;
|
|
166
|
+
if (typeof reason === 'string') return reason;
|
|
167
|
+
if (reason === null || reason === undefined) {
|
|
168
|
+
return `Unhandled promise rejection (${String(reason)})`;
|
|
169
|
+
}
|
|
170
|
+
if (typeof reason !== 'object') {
|
|
171
|
+
return `Unhandled promise rejection (${typeof reason}: ${String(reason)})`;
|
|
172
|
+
}
|
|
173
|
+
const shape = reason;
|
|
174
|
+
const named = [
|
|
175
|
+
(_ref = (_shape_name = shape['name']) != null ? _shape_name : shape['code']) != null ? _ref : '',
|
|
176
|
+
(_ref1 = (_shape_message = shape['message']) != null ? _shape_message : shape['statusText']) != null ? _ref1 : ''
|
|
177
|
+
].map((part)=>String(part != null ? part : '').trim()).filter(Boolean);
|
|
178
|
+
if (named.length) return named.join(': ');
|
|
179
|
+
const printed = String(reason);
|
|
180
|
+
if (printed && printed !== '[object Object]') return printed;
|
|
181
|
+
const keys = Object.keys(shape).slice(0, 12).join(', ');
|
|
182
|
+
return keys ? `Unhandled promise rejection (object with ${keys})` : 'Unhandled promise rejection (empty object)';
|
|
183
|
+
} catch (unused) {
|
|
184
|
+
return 'Unhandled promise rejection (undescribable)';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
140
187
|
let installed = false;
|
|
141
188
|
/**
|
|
142
189
|
* The live beacon's `enqueue`, published for {@link reportHandledError}.
|
|
@@ -271,9 +318,8 @@ let installed = false;
|
|
|
271
318
|
});
|
|
272
319
|
window.addEventListener('unhandledrejection', (event)=>{
|
|
273
320
|
try {
|
|
274
|
-
var _ref;
|
|
275
321
|
const reason = event.reason;
|
|
276
|
-
const message = clamp((
|
|
322
|
+
const message = clamp(describeRejectionReason(event.reason), MAX_MESSAGE);
|
|
277
323
|
if (!message) return;
|
|
278
324
|
enqueue({
|
|
279
325
|
kind: 'unhandledrejection',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/error-beacon.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * First-party browser error beacon (AGL-1538).\n *\n * Crashlytics does not exist for web, and adding a third-party error vendor\n * is a subprocessor-list decision that was explicitly deferred. This is the\n * first-party alternative: `window.onerror` / `unhandledrejection` handlers\n * that batch uncaught failures to the app's own `/api/errors` route, which\n * forwards them to Google Cloud Error Reporting — a processor already on the\n * list, alerting through the GCP stack that already pages (AGL-1502).\n *\n * Design constraints, in order:\n *\n * - **It must never become the outage.** Everything is wrapped; a failure to\n * report is silence, not a thrown error. Batches are capped per page so an\n * error thrown in a loop cannot DDoS our own API — after `maxPerPage`\n * events the beacon disarms for the rest of the pageview.\n * - **No PII.** URLs are scrubbed to origin + pathname (query strings and\n * fragments carry tokens and search terms), no user id, no cookies —\n * the payload is the error text and where in the CODE it happened.\n * Cross-origin scripts surface as the browser's opaque \"Script error.\"\n * with no stack; those are dropped since they cannot be acted on.\n * - **Dedupe.** One page throwing the same error on every frame reports it\n * once.\n *\n * Framework-free on purpose: the console mounts it from its root layout, the\n * tenant runtime from its own, and neither needs React here. Deliberately NO\n * 'use client' directive — inside this shared lib the directive forks the\n * module graph (AGL-52); every importer is already a client module.\n */\n\nimport { isForeignScriptUrl, stackFrameUrls } from './foreign-script'\n\nexport interface ErrorBeaconEvent {\n /** 'error' | 'unhandledrejection' — which handler caught it. */\n kind: string\n /** Error message, clamped. */\n message: string\n /** Stack trace when the thrown value carried one, clamped. */\n stack?: string\n /** Script URL for stackless `window.onerror` events, scrubbed. */\n source?: string\n line?: number\n col?: number\n /** Page URL, scrubbed to origin + pathname. */\n url: string\n}\n\nexport interface ErrorBeaconOptions {\n /** POST target; same-origin. Default `/api/errors`. */\n endpoint?: string\n /** 0..1 — fraction of pageviews that report at all. Default 1. */\n sampleRate?: number\n /** Max events reported per pageview before the beacon disarms. */\n maxPerPage?: number\n}\n\nconst MAX_MESSAGE = 1_024\nconst MAX_STACK = 8_192\nconst FLUSH_DELAY_MS = 2_000\n\n/** Origin + pathname only — query strings and fragments never leave. */\nfunction scrubUrl(raw: string | null | undefined): string {\n if (!raw) return ''\n try {\n const url = new URL(String(raw), window.location.href)\n return `${url.origin}${url.pathname}`\n } catch {\n return ''\n }\n}\n\nfunction clamp(value: unknown, max: number): string {\n return String(value ?? '').slice(0, max)\n}\n\n/**\n * Was this thrown by a script somebody ELSE evaluated into our page?\n *\n * Two tells, both read off the frames' URLs and neither off a function name —\n * a name would be a denylist needing a new entry per vendor.\n *\n * **Every frame is the DOCUMENT.** Measured 2026-09-02 (AGL-2523) on\n * aglyn.com/pricing, reported as an error of ours:\n *\n * sendDataToNative@https://aglyn.com/pricing:1:1325\n * sendPageHideMessage@https://aglyn.com/pricing:1:4139\n * @https://aglyn.com/pricing:1:6257\n *\n * `sendDataToNative` is the Meta in-app browser's native bridge, injected by\n * the Facebook/Instagram webview. In WebKit's format the code was evaluated\n * inline, so every frame is the document rather than a script we served.\n *\n * **Every frame is under a FOREIGN scheme.** Measured 2026-09-10 (AGL-2786) on\n * aglyn.com, the same bridge in the Android webview:\n *\n * at sendDataToNative (iabjs://navigation_performance_logger_android:1:10632)\n *\n * That webview names its scripts under its own `iabjs://` scheme, and\n * extensions do the same under theirs — see `FOREIGN_SCRIPT_SCHEMES`. A frame\n * may carry either tell. Nothing we ship can be fixed in response to any of\n * them, which is the same test the opaque `Script error.` cut already applies.\n *\n * ⚑ Deliberately `every`, and deliberately compared against the document\n * rather than against our asset path. A rule like \"no frame under\n * `/_next/static/`\" would delete EVERY error from a self-hosted deployment\n * that serves its assets from a CDN, because none of its frames would match.\n * Comparing to the document cannot fail that way: a CDN frame is not the\n * document either, so it keeps the report.\n *\n * The cost is honest and small: a throw from one of our own inline\n * bootstrap scripts looks the same and is dropped with it. That trade buys a\n * rule that needs no maintenance as new webviews and extensions appear.\n */\nexport function isInjectedThirdPartyFrame(\n stack: string,\n documentUrl: string,\n): boolean {\n const urls = stackFrameUrls(stack)\n // No frames parsed is not evidence of anything — keep the report.\n if (!urls.length) return false\n return urls.every(\n (url) => isForeignScriptUrl(url) || scrubUrl(url) === documentUrl,\n )\n}\n\n/**\n * React's hydration-mismatch family, which a page TRANSLATOR causes and a\n * real render divergence also causes.\n *\n * These are `onRecoverableError` reports, not crashes: React has already\n * re-rendered on the client and the visitor has a working page. They arrive\n * here as uncaught errors only because React re-throws them to `reportError`.\n *\n * They are marked rather than dropped, and the distinction is the whole\n * design. Dropping would hide a real hydration regression, which is a genuine\n * and expensive bug; the entries stay in `client-errors` at full severity and\n * still group in Error Reporting. What the mark buys is that the log-match\n * policy can stop paging on a single report, while a rate-based policy on the\n * same mark still catches a systemic one.\n *\n * ⚠️ THE MARK IS NOT A VERDICT, and the one cluster this repo has measured is\n * why. Eight #418 reports arrived inside a nine-minute window on 2026-09-01,\n * on one build, and the three pages they came from were exactly the three\n * carrying a form — `/`, `/pricing` and the solutions pages reported nothing\n * in the same window, which no browser-side rewrite would respect. A placed\n * form was expanding inside its own grafted subtree, so every page carrying\n * one shipped six nested `<form>` elements; nested forms are invalid HTML, the\n * parser dropped the inner ones, and the client tree disagreed with the server\n * tree. The visible cost was a submission the route never received, because\n * the native submit came from an unhandled ancestor and the browser fell back\n * to a GET with the visitor's name, email and message in the address bar. The\n * reports stopped because the nesting was fixed, roughly a quarter of an hour\n * after the last one.\n *\n * So a rate low enough not to page is a reason to READ the entry — the page\n * it names, and whether the set of pages has a shape — never a reason to\n * assume somebody's extension.\n */\nconst HYDRATION_MINIFIED = /Minified React error #(?:418|423|425)\\b/\nconst HYDRATION_TEXT =\n /Hydration failed because|Text content does not match server-rendered HTML|There was an error while hydrating/\n\nexport function isHydrationMismatch(message: string): boolean {\n return HYDRATION_MINIFIED.test(message) || HYDRATION_TEXT.test(message)\n}\n\nlet installed = false\n\n/**\n * The live beacon's `enqueue`, published for {@link reportHandledError}.\n *\n * Null until `installErrorBeacon` runs, and null forever on a surface that\n * never installs one — so a caught error reported from a page with no beacon\n * is silently dropped rather than throwing inside somebody's error handler.\n */\nlet publishEvent: ((event: ErrorBeaconEvent) => void) | null = null\n\n/**\n * Re-export so `error-beacon` stays the one name a caller has to know for\n * browser error reporting. The definition lives in its own module because a\n * boundary that imports it must not pull the installer below into a chunk\n * every visitor downloads.\n */\nexport { redispatchCaughtError } from './redispatch-caught-error'\n\n/**\n * Report an error the code ALREADY CAUGHT.\n *\n * The two window handlers below see only what nothing caught. A `catch` that\n * swallows its error is invisible to them by construction, which is the whole\n * failure mode this exists for: a background write that can only fail\n * silently is one that stays broken for as long as nobody thinks to look.\n *\n * It rides the same queue as an uncaught error, which is the point — the\n * dedupe collapses a failure that repeats on every edit into one report, and\n * `maxPerPage` bounds it, so a call site in a loop cannot turn a broken\n * feature into a flood. Reporting is never worth an exception of its own, so\n * every path here returns rather than throws.\n */\nexport function reportHandledError(\n error: unknown,\n options?: { kind?: string },\n): void {\n try {\n if (!publishEvent) return\n const thrown = error as Error | undefined\n const message = clamp(\n thrown?.message ?? (typeof error === 'string' ? error : ''),\n MAX_MESSAGE,\n )\n if (!message) return\n publishEvent({\n kind: clamp(options?.kind ?? 'handled', 32),\n message,\n stack: thrown?.stack ? clamp(thrown.stack, MAX_STACK) : undefined,\n url: scrubUrl(window.location.href),\n })\n } catch {\n // An observer that throws is worse than one that misses an event.\n }\n}\n\n/**\n * Installs the handlers once per page. Safe to call from module scope of a\n * client bundle: it no-ops during SSR and on repeat calls.\n */\nexport function installErrorBeacon(options?: ErrorBeaconOptions): void {\n if (typeof window === 'undefined' || installed) return\n installed = true\n\n const endpoint = options?.endpoint ?? '/api/errors'\n const sampleRate = options?.sampleRate ?? 1\n const maxPerPage = options?.maxPerPage ?? 10\n if (Math.random() >= sampleRate) return\n\n const seen = new Set<string>()\n let queued: ErrorBeaconEvent[] = []\n let sent = 0\n let timer: ReturnType<typeof setTimeout> | null = null\n\n const flush = () => {\n timer = null\n if (!queued.length) return\n const batch = queued\n queued = []\n try {\n const body = JSON.stringify({ events: batch })\n // sendBeacon survives unloads and never blocks; keepalive fetch is the\n // fallback for browsers that refuse the payload.\n if (!navigator.sendBeacon?.(endpoint, body)) {\n void fetch(endpoint, {\n method: 'POST',\n body,\n keepalive: true,\n headers: { 'Content-Type': 'application/json' },\n }).catch((): undefined => undefined)\n }\n } catch {\n // Reporting never breaks the page.\n }\n }\n\n const enqueue = (event: ErrorBeaconEvent) => {\n if (sent >= maxPerPage) return\n // Message + first stack line: enough identity to collapse a render loop\n // without collapsing distinct errors that share a message.\n const key = `${event.message}\\x00${(event.stack ?? '').split('\\n', 2).join('\\n')}`\n if (seen.has(key)) return\n seen.add(key)\n sent += 1\n queued.push(event)\n if (!timer) timer = setTimeout(flush, FLUSH_DELAY_MS)\n }\n // Published AFTER the sample-rate return above, so an unsampled pageview\n // reports nothing by either door rather than one by one and none by the\n // other.\n publishEvent = enqueue\n\n window.addEventListener('error', (event) => {\n try {\n const error = event.error as Error | undefined\n const message = clamp(error?.message ?? event.message, MAX_MESSAGE)\n // Cross-origin scripts yield an opaque \"Script error.\" with nothing\n // actionable attached; reporting it would only create a noisy group.\n if (!message || message === 'Script error.') return\n const pageUrl = scrubUrl(window.location.href)\n const fullStack = error?.stack ? String(error.stack) : undefined\n // A webview or extension that evaluated its own code into our page —\n // see `isInjectedThirdPartyFrame`. Nothing we ship can be changed in\n // response, so it is dropped on the same test as `Script error.`.\n // Judged on the WHOLE stack, before the clamp: a frame of ours past the\n // clamp still makes the error ours.\n if (fullStack && isInjectedThirdPartyFrame(fullStack, pageUrl)) return\n const stack = fullStack ? clamp(fullStack, MAX_STACK) : undefined\n enqueue({\n // MARKED, not dropped: a translator causes these and so does a real\n // render divergence, and only a rate tells them apart.\n kind: isHydrationMismatch(message) ? 'hydration' : 'error',\n message,\n stack,\n source: scrubUrl(event.filename) || undefined,\n line: typeof event.lineno === 'number' ? event.lineno : undefined,\n col: typeof event.colno === 'number' ? event.colno : undefined,\n url: pageUrl,\n })\n } catch {\n // Never rethrow from an error handler.\n }\n })\n\n window.addEventListener('unhandledrejection', (event) => {\n try {\n const reason = event.reason as Error | undefined\n const message = clamp(\n reason?.message ?? (typeof event.reason === 'string' ? event.reason : 'Unhandled promise rejection'),\n MAX_MESSAGE,\n )\n if (!message) return\n enqueue({\n kind: 'unhandledrejection',\n message,\n stack: reason?.stack ? clamp(reason.stack, MAX_STACK) : undefined,\n url: scrubUrl(window.location.href),\n })\n } catch {\n // Never rethrow from an error handler.\n }\n })\n\n // A batch waiting out its debounce when the page hides would be lost;\n // `pagehide`/`visibilitychange` are the last reliable moments to send.\n window.addEventListener('pagehide', flush)\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'hidden') flush()\n })\n}\n"],"names":["isForeignScriptUrl","stackFrameUrls","MAX_MESSAGE","MAX_STACK","FLUSH_DELAY_MS","scrubUrl","raw","url","URL","String","window","location","href","origin","pathname","clamp","value","max","slice","isInjectedThirdPartyFrame","stack","documentUrl","urls","length","every","HYDRATION_MINIFIED","HYDRATION_TEXT","isHydrationMismatch","message","test","installed","publishEvent","redispatchCaughtError","reportHandledError","error","options","thrown","kind","undefined","installErrorBeacon","endpoint","sampleRate","maxPerPage","Math","random","seen","Set","queued","sent","timer","flush","batch","body","JSON","stringify","events","navigator","sendBeacon","fetch","method","keepalive","headers","catch","enqueue","event","key","split","join","has","add","push","setTimeout","addEventListener","pageUrl","fullStack","source","filename","line","lineno","col","colno","reason","document","visibilityState"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/error-beacon.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * First-party browser error beacon (AGL-1538).\n *\n * Crashlytics does not exist for web, and adding a third-party error vendor\n * is a subprocessor-list decision that was explicitly deferred. This is the\n * first-party alternative: `window.onerror` / `unhandledrejection` handlers\n * that batch uncaught failures to the app's own `/api/errors` route, which\n * forwards them to Google Cloud Error Reporting — a processor already on the\n * list, alerting through the GCP stack that already pages (AGL-1502).\n *\n * Design constraints, in order:\n *\n * - **It must never become the outage.** Everything is wrapped; a failure to\n * report is silence, not a thrown error. Batches are capped per page so an\n * error thrown in a loop cannot DDoS our own API — after `maxPerPage`\n * events the beacon disarms for the rest of the pageview.\n * - **No PII.** URLs are scrubbed to origin + pathname (query strings and\n * fragments carry tokens and search terms), no user id, no cookies —\n * the payload is the error text and where in the CODE it happened.\n * Cross-origin scripts surface as the browser's opaque \"Script error.\"\n * with no stack; those are dropped since they cannot be acted on.\n * - **Dedupe.** One page throwing the same error on every frame reports it\n * once.\n *\n * Framework-free on purpose: the console mounts it from its root layout, the\n * tenant runtime from its own, and neither needs React here. Deliberately NO\n * 'use client' directive — inside this shared lib the directive forks the\n * module graph (AGL-52); every importer is already a client module.\n */\n\nimport { isForeignScriptUrl, stackFrameUrls } from './foreign-script'\n\nexport interface ErrorBeaconEvent {\n /** 'error' | 'unhandledrejection' — which handler caught it. */\n kind: string\n /** Error message, clamped. */\n message: string\n /** Stack trace when the thrown value carried one, clamped. */\n stack?: string\n /** Script URL for stackless `window.onerror` events, scrubbed. */\n source?: string\n line?: number\n col?: number\n /** Page URL, scrubbed to origin + pathname. */\n url: string\n}\n\nexport interface ErrorBeaconOptions {\n /** POST target; same-origin. Default `/api/errors`. */\n endpoint?: string\n /** 0..1 — fraction of pageviews that report at all. Default 1. */\n sampleRate?: number\n /** Max events reported per pageview before the beacon disarms. */\n maxPerPage?: number\n}\n\nconst MAX_MESSAGE = 1_024\nconst MAX_STACK = 8_192\nconst FLUSH_DELAY_MS = 2_000\n\n/** Origin + pathname only — query strings and fragments never leave. */\nfunction scrubUrl(raw: string | null | undefined): string {\n if (!raw) return ''\n try {\n const url = new URL(String(raw), window.location.href)\n return `${url.origin}${url.pathname}`\n } catch {\n return ''\n }\n}\n\nfunction clamp(value: unknown, max: number): string {\n return String(value ?? '').slice(0, max)\n}\n\n/**\n * Was this thrown by a script somebody ELSE evaluated into our page?\n *\n * Two tells, both read off the frames' URLs and neither off a function name —\n * a name would be a denylist needing a new entry per vendor.\n *\n * **Every frame is the DOCUMENT.** Measured 2026-09-02 (AGL-2523) on\n * aglyn.com/pricing, reported as an error of ours:\n *\n * sendDataToNative@https://aglyn.com/pricing:1:1325\n * sendPageHideMessage@https://aglyn.com/pricing:1:4139\n * @https://aglyn.com/pricing:1:6257\n *\n * `sendDataToNative` is the Meta in-app browser's native bridge, injected by\n * the Facebook/Instagram webview. In WebKit's format the code was evaluated\n * inline, so every frame is the document rather than a script we served.\n *\n * **Every frame is under a FOREIGN scheme.** Measured 2026-09-10 (AGL-2786) on\n * aglyn.com, the same bridge in the Android webview:\n *\n * at sendDataToNative (iabjs://navigation_performance_logger_android:1:10632)\n *\n * That webview names its scripts under its own `iabjs://` scheme, and\n * extensions do the same under theirs — see `FOREIGN_SCRIPT_SCHEMES`. A frame\n * may carry either tell. Nothing we ship can be fixed in response to any of\n * them, which is the same test the opaque `Script error.` cut already applies.\n *\n * ⚑ Deliberately `every`, and deliberately compared against the document\n * rather than against our asset path. A rule like \"no frame under\n * `/_next/static/`\" would delete EVERY error from a self-hosted deployment\n * that serves its assets from a CDN, because none of its frames would match.\n * Comparing to the document cannot fail that way: a CDN frame is not the\n * document either, so it keeps the report.\n *\n * The cost is honest and small: a throw from one of our own inline\n * bootstrap scripts looks the same and is dropped with it. That trade buys a\n * rule that needs no maintenance as new webviews and extensions appear.\n */\nexport function isInjectedThirdPartyFrame(\n stack: string,\n documentUrl: string,\n): boolean {\n const urls = stackFrameUrls(stack)\n // No frames parsed is not evidence of anything — keep the report.\n if (!urls.length) return false\n return urls.every(\n (url) => isForeignScriptUrl(url) || scrubUrl(url) === documentUrl,\n )\n}\n\n/**\n * React's hydration-mismatch family, which a page TRANSLATOR causes and a\n * real render divergence also causes.\n *\n * These are `onRecoverableError` reports, not crashes: React has already\n * re-rendered on the client and the visitor has a working page. They arrive\n * here as uncaught errors only because React re-throws them to `reportError`.\n *\n * They are marked rather than dropped, and the distinction is the whole\n * design. Dropping would hide a real hydration regression, which is a genuine\n * and expensive bug; the entries stay in `client-errors` at full severity and\n * still group in Error Reporting. What the mark buys is that the log-match\n * policy can stop paging on a single report, while a rate-based policy on the\n * same mark still catches a systemic one.\n *\n * ⚠️ THE MARK IS NOT A VERDICT, and the one cluster this repo has measured is\n * why. Eight #418 reports arrived inside a nine-minute window on 2026-09-01,\n * on one build, and the three pages they came from were exactly the three\n * carrying a form — `/`, `/pricing` and the solutions pages reported nothing\n * in the same window, which no browser-side rewrite would respect. A placed\n * form was expanding inside its own grafted subtree, so every page carrying\n * one shipped six nested `<form>` elements; nested forms are invalid HTML, the\n * parser dropped the inner ones, and the client tree disagreed with the server\n * tree. The visible cost was a submission the route never received, because\n * the native submit came from an unhandled ancestor and the browser fell back\n * to a GET with the visitor's name, email and message in the address bar. The\n * reports stopped because the nesting was fixed, roughly a quarter of an hour\n * after the last one.\n *\n * So a rate low enough not to page is a reason to READ the entry — the page\n * it names, and whether the set of pages has a shape — never a reason to\n * assume somebody's extension.\n */\nconst HYDRATION_MINIFIED = /Minified React error #(?:418|423|425)\\b/\nconst HYDRATION_TEXT =\n /Hydration failed because|Text content does not match server-rendered HTML|There was an error while hydrating/\n\nexport function isHydrationMismatch(message: string): boolean {\n return HYDRATION_MINIFIED.test(message) || HYDRATION_TEXT.test(message)\n}\n\n/**\n * WHAT A REJECTED PROMISE ACTUALLY CARRIED (AGL-3279).\n *\n * `unhandledrejection` hands over whatever was passed to `reject`, and only\n * an `Error` brings a message and a stack. A rejection with anything else —\n * an API's `{ code, message }` object, a `DOMException` from an aborted\n * fetch, a bare string, a `Response` — used to be reported as the literal\n * words \"Unhandled promise rejection\" with no stack and no other field,\n * which names the handler rather than the failure. One such report arrived\n * on 2026-09-21 from the Besigner editor and there is nothing in it to act\n * on: no message, no stack, no code, nothing but the page.\n *\n * So the value is DESCRIBED rather than defaulted. In order of how much a\n * reader can do with it: the error's own message; a string as itself; a\n * `name`/`code`/`message` shape assembled from whatever of those it has —\n * the shape every Firebase, DOM and fetch rejection takes; the value's own\n * `toString` when it says more than `[object Object]`; and last its own\n * keys, which at least name what kind of thing rejected.\n *\n * Nothing here may throw: a getter on the reason can, `toString` can, and\n * a describer that throws inside the rejection handler loses the report it\n * was called to improve.\n */\nexport function describeRejectionReason(reason: unknown): string {\n try {\n if (reason instanceof Error && reason.message) return reason.message\n if (typeof reason === 'string') return reason\n if (reason === null || reason === undefined) {\n return `Unhandled promise rejection (${String(reason)})`\n }\n if (typeof reason !== 'object') {\n return `Unhandled promise rejection (${typeof reason}: ${String(reason)})`\n }\n const shape = reason as Record<string, unknown>\n const named = [\n shape['name'] ?? shape['code'] ?? '',\n shape['message'] ?? shape['statusText'] ?? '',\n ]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n if (named.length) return named.join(': ')\n const printed = String(reason)\n if (printed && printed !== '[object Object]') return printed\n const keys = Object.keys(shape).slice(0, 12).join(', ')\n return keys\n ? `Unhandled promise rejection (object with ${keys})`\n : 'Unhandled promise rejection (empty object)'\n } catch {\n return 'Unhandled promise rejection (undescribable)'\n }\n}\n\nlet installed = false\n\n/**\n * The live beacon's `enqueue`, published for {@link reportHandledError}.\n *\n * Null until `installErrorBeacon` runs, and null forever on a surface that\n * never installs one — so a caught error reported from a page with no beacon\n * is silently dropped rather than throwing inside somebody's error handler.\n */\nlet publishEvent: ((event: ErrorBeaconEvent) => void) | null = null\n\n/**\n * Re-export so `error-beacon` stays the one name a caller has to know for\n * browser error reporting. The definition lives in its own module because a\n * boundary that imports it must not pull the installer below into a chunk\n * every visitor downloads.\n */\nexport { redispatchCaughtError } from './redispatch-caught-error'\n\n/**\n * Report an error the code ALREADY CAUGHT.\n *\n * The two window handlers below see only what nothing caught. A `catch` that\n * swallows its error is invisible to them by construction, which is the whole\n * failure mode this exists for: a background write that can only fail\n * silently is one that stays broken for as long as nobody thinks to look.\n *\n * It rides the same queue as an uncaught error, which is the point — the\n * dedupe collapses a failure that repeats on every edit into one report, and\n * `maxPerPage` bounds it, so a call site in a loop cannot turn a broken\n * feature into a flood. Reporting is never worth an exception of its own, so\n * every path here returns rather than throws.\n */\nexport function reportHandledError(\n error: unknown,\n options?: { kind?: string },\n): void {\n try {\n if (!publishEvent) return\n const thrown = error as Error | undefined\n const message = clamp(\n thrown?.message ?? (typeof error === 'string' ? error : ''),\n MAX_MESSAGE,\n )\n if (!message) return\n publishEvent({\n kind: clamp(options?.kind ?? 'handled', 32),\n message,\n stack: thrown?.stack ? clamp(thrown.stack, MAX_STACK) : undefined,\n url: scrubUrl(window.location.href),\n })\n } catch {\n // An observer that throws is worse than one that misses an event.\n }\n}\n\n/**\n * Installs the handlers once per page. Safe to call from module scope of a\n * client bundle: it no-ops during SSR and on repeat calls.\n */\nexport function installErrorBeacon(options?: ErrorBeaconOptions): void {\n if (typeof window === 'undefined' || installed) return\n installed = true\n\n const endpoint = options?.endpoint ?? '/api/errors'\n const sampleRate = options?.sampleRate ?? 1\n const maxPerPage = options?.maxPerPage ?? 10\n if (Math.random() >= sampleRate) return\n\n const seen = new Set<string>()\n let queued: ErrorBeaconEvent[] = []\n let sent = 0\n let timer: ReturnType<typeof setTimeout> | null = null\n\n const flush = () => {\n timer = null\n if (!queued.length) return\n const batch = queued\n queued = []\n try {\n const body = JSON.stringify({ events: batch })\n // sendBeacon survives unloads and never blocks; keepalive fetch is the\n // fallback for browsers that refuse the payload.\n if (!navigator.sendBeacon?.(endpoint, body)) {\n void fetch(endpoint, {\n method: 'POST',\n body,\n keepalive: true,\n headers: { 'Content-Type': 'application/json' },\n }).catch((): undefined => undefined)\n }\n } catch {\n // Reporting never breaks the page.\n }\n }\n\n const enqueue = (event: ErrorBeaconEvent) => {\n if (sent >= maxPerPage) return\n // Message + first stack line: enough identity to collapse a render loop\n // without collapsing distinct errors that share a message.\n const key = `${event.message}\\x00${(event.stack ?? '').split('\\n', 2).join('\\n')}`\n if (seen.has(key)) return\n seen.add(key)\n sent += 1\n queued.push(event)\n if (!timer) timer = setTimeout(flush, FLUSH_DELAY_MS)\n }\n // Published AFTER the sample-rate return above, so an unsampled pageview\n // reports nothing by either door rather than one by one and none by the\n // other.\n publishEvent = enqueue\n\n window.addEventListener('error', (event) => {\n try {\n const error = event.error as Error | undefined\n const message = clamp(error?.message ?? event.message, MAX_MESSAGE)\n // Cross-origin scripts yield an opaque \"Script error.\" with nothing\n // actionable attached; reporting it would only create a noisy group.\n if (!message || message === 'Script error.') return\n const pageUrl = scrubUrl(window.location.href)\n const fullStack = error?.stack ? String(error.stack) : undefined\n // A webview or extension that evaluated its own code into our page —\n // see `isInjectedThirdPartyFrame`. Nothing we ship can be changed in\n // response, so it is dropped on the same test as `Script error.`.\n // Judged on the WHOLE stack, before the clamp: a frame of ours past the\n // clamp still makes the error ours.\n if (fullStack && isInjectedThirdPartyFrame(fullStack, pageUrl)) return\n const stack = fullStack ? clamp(fullStack, MAX_STACK) : undefined\n enqueue({\n // MARKED, not dropped: a translator causes these and so does a real\n // render divergence, and only a rate tells them apart.\n kind: isHydrationMismatch(message) ? 'hydration' : 'error',\n message,\n stack,\n source: scrubUrl(event.filename) || undefined,\n line: typeof event.lineno === 'number' ? event.lineno : undefined,\n col: typeof event.colno === 'number' ? event.colno : undefined,\n url: pageUrl,\n })\n } catch {\n // Never rethrow from an error handler.\n }\n })\n\n window.addEventListener('unhandledrejection', (event) => {\n try {\n const reason = event.reason as Error | undefined\n const message = clamp(describeRejectionReason(event.reason), MAX_MESSAGE)\n if (!message) return\n enqueue({\n kind: 'unhandledrejection',\n message,\n stack: reason?.stack ? clamp(reason.stack, MAX_STACK) : undefined,\n url: scrubUrl(window.location.href),\n })\n } catch {\n // Never rethrow from an error handler.\n }\n })\n\n // A batch waiting out its debounce when the page hides would be lost;\n // `pagehide`/`visibilitychange` are the last reliable moments to send.\n window.addEventListener('pagehide', flush)\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'hidden') flush()\n })\n}\n"],"names":["isForeignScriptUrl","stackFrameUrls","MAX_MESSAGE","MAX_STACK","FLUSH_DELAY_MS","scrubUrl","raw","url","URL","String","window","location","href","origin","pathname","clamp","value","max","slice","isInjectedThirdPartyFrame","stack","documentUrl","urls","length","every","HYDRATION_MINIFIED","HYDRATION_TEXT","isHydrationMismatch","message","test","describeRejectionReason","reason","shape","Error","undefined","named","map","part","trim","filter","Boolean","join","printed","keys","Object","installed","publishEvent","redispatchCaughtError","reportHandledError","error","options","thrown","kind","installErrorBeacon","endpoint","sampleRate","maxPerPage","Math","random","seen","Set","queued","sent","timer","flush","batch","body","JSON","stringify","events","navigator","sendBeacon","fetch","method","keepalive","headers","catch","enqueue","event","key","split","has","add","push","setTimeout","addEventListener","pageUrl","fullStack","source","filename","line","lineno","col","colno","document","visibilityState"],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** How long one recovery suppresses the next, in this tab. */
|
|
18
|
+
export declare const RECOVERY_INTERVAL_MS: number;
|
|
19
|
+
/** Was this thrown because the document names a chunk the origin dropped? */
|
|
20
|
+
export declare function isStaleBuildError(error: unknown): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Should this tab reload itself for a stale build — and, if so, SPEND the
|
|
23
|
+
* recovery, so asking twice cannot answer yes twice.
|
|
24
|
+
*
|
|
25
|
+
* The decision and the reload are separated on purpose. The decision is
|
|
26
|
+
* the part with the rate limit in it, and the part that must be provable;
|
|
27
|
+
* the reload is one line at the call site, which the boundary writes where
|
|
28
|
+
* a reader can see it happen.
|
|
29
|
+
*
|
|
30
|
+
* Every storage path is guarded: an accessor may throw in a private window
|
|
31
|
+
* or with site data blocked, and a failure to RECOVER from an error must
|
|
32
|
+
* never be a second error.
|
|
33
|
+
*/
|
|
34
|
+
export declare function shouldReloadForStaleBuild(error: unknown): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* THE TAB THAT WAS OPEN ACROSS A DEPLOY (AGL-3279).
|
|
18
|
+
*
|
|
19
|
+
* Every build names its chunks by content hash and the previous build's are
|
|
20
|
+
* not kept, so a tab loaded before a release asks for a file the origin no
|
|
21
|
+
* longer serves the moment it navigates or opens a lazy route. The browser
|
|
22
|
+
* rejects the import, the boundary above catches it, and the visitor is
|
|
23
|
+
* shown a crash page with a **Try again** button that cannot possibly work:
|
|
24
|
+
* `reset()` re-renders the same tree, which requests the same missing chunk.
|
|
25
|
+
* It reported once in the 48h to 2026-09-23, on `aglyn.com`.
|
|
26
|
+
*
|
|
27
|
+
* A reload fixes it completely, because the fresh document names the chunks
|
|
28
|
+
* the deploy actually shipped. What matters is that it may never LOOP: a
|
|
29
|
+
* genuinely broken deploy would otherwise reload every visitor forever, and
|
|
30
|
+
* an error reporter's first rule — never become the outage — applies twice
|
|
31
|
+
* over to something that reloads pages.
|
|
32
|
+
*
|
|
33
|
+
* So the recovery is RATE-BOUND, held in session storage: at most one
|
|
34
|
+
* reload per tab per {@link RECOVERY_INTERVAL_MS}, and a browser that
|
|
35
|
+
* refuses storage gets none at all rather than an unbounded one. A second
|
|
36
|
+
* failure inside that window renders the crash page and stays there, which
|
|
37
|
+
* is the honest outcome — at that point the build really is broken and
|
|
38
|
+
* reloading is not the answer.
|
|
39
|
+
*
|
|
40
|
+
* An interval rather than a once-ever mark, because a console tab lives for
|
|
41
|
+
* days and this repo ships several times a day: a once-ever mark would
|
|
42
|
+
* spend the tab's only recovery on the first deploy it survived and leave
|
|
43
|
+
* it stranded on the third. Long enough that no plausible loop is
|
|
44
|
+
* comfortable; short enough that the next release is covered.
|
|
45
|
+
*
|
|
46
|
+
* A module of its own, not a function in `error-beacon.ts`, for the reason
|
|
47
|
+
* `redispatch-caught-error.ts` gives: an error boundary's chunk is
|
|
48
|
+
* downloaded by every visitor, and importing one name off the beacon would
|
|
49
|
+
* drag its whole installer in with it.
|
|
50
|
+
*/ const RELOADED_KEY = 'aglyn.staleBuildReloaded';
|
|
51
|
+
/** How long one recovery suppresses the next, in this tab. */ export const RECOVERY_INTERVAL_MS = 30 * 60 * 1000;
|
|
52
|
+
/**
|
|
53
|
+
* Messages the browsers spell differently for one event — the module the
|
|
54
|
+
* document names is not there.
|
|
55
|
+
*
|
|
56
|
+
* Matched on the MESSAGE as well as the name because only webpack's own
|
|
57
|
+
* failure carries `name === 'ChunkLoadError'`; a native dynamic import that
|
|
58
|
+
* 404s rejects with a plain `TypeError` whose wording is the browser's.
|
|
59
|
+
*/ const STALE_BUILD_MESSAGE = /Loading chunk .* failed|Failed to load chunk|Loading CSS chunk .* failed|error loading dynamically imported module|Importing a module script failed|Failed to fetch dynamically imported module/i;
|
|
60
|
+
/** Was this thrown because the document names a chunk the origin dropped? */ export function isStaleBuildError(error) {
|
|
61
|
+
var _thrown_name, _thrown_message;
|
|
62
|
+
if (!error) return false;
|
|
63
|
+
const thrown = error;
|
|
64
|
+
if (String((_thrown_name = thrown.name) != null ? _thrown_name : '') === 'ChunkLoadError') return true;
|
|
65
|
+
return STALE_BUILD_MESSAGE.test(String((_thrown_message = thrown.message) != null ? _thrown_message : ''));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Should this tab reload itself for a stale build — and, if so, SPEND the
|
|
69
|
+
* recovery, so asking twice cannot answer yes twice.
|
|
70
|
+
*
|
|
71
|
+
* The decision and the reload are separated on purpose. The decision is
|
|
72
|
+
* the part with the rate limit in it, and the part that must be provable;
|
|
73
|
+
* the reload is one line at the call site, which the boundary writes where
|
|
74
|
+
* a reader can see it happen.
|
|
75
|
+
*
|
|
76
|
+
* Every storage path is guarded: an accessor may throw in a private window
|
|
77
|
+
* or with site data blocked, and a failure to RECOVER from an error must
|
|
78
|
+
* never be a second error.
|
|
79
|
+
*/ export function shouldReloadForStaleBuild(error) {
|
|
80
|
+
if (typeof window === 'undefined') return false;
|
|
81
|
+
if (!isStaleBuildError(error)) return false;
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
try {
|
|
84
|
+
var _window_sessionStorage_getItem;
|
|
85
|
+
const last = Number((_window_sessionStorage_getItem = window.sessionStorage.getItem(RELOADED_KEY)) != null ? _window_sessionStorage_getItem : '');
|
|
86
|
+
// An unreadable mark is not a recent one: a stored value we cannot
|
|
87
|
+
// parse must not suppress the recovery forever.
|
|
88
|
+
if (Number.isFinite(last) && last > 0 && now - last < RECOVERY_INTERVAL_MS) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
window.sessionStorage.setItem(RELOADED_KEY, String(now));
|
|
92
|
+
} catch (unused) {
|
|
93
|
+
// No storage is no rate limit, and an unbounded reload is worse than a
|
|
94
|
+
// crash page: take the crash page.
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//# sourceMappingURL=stale-build-error.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/stale-build-error.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE TAB THAT WAS OPEN ACROSS A DEPLOY (AGL-3279).\n *\n * Every build names its chunks by content hash and the previous build's are\n * not kept, so a tab loaded before a release asks for a file the origin no\n * longer serves the moment it navigates or opens a lazy route. The browser\n * rejects the import, the boundary above catches it, and the visitor is\n * shown a crash page with a **Try again** button that cannot possibly work:\n * `reset()` re-renders the same tree, which requests the same missing chunk.\n * It reported once in the 48h to 2026-09-23, on `aglyn.com`.\n *\n * A reload fixes it completely, because the fresh document names the chunks\n * the deploy actually shipped. What matters is that it may never LOOP: a\n * genuinely broken deploy would otherwise reload every visitor forever, and\n * an error reporter's first rule — never become the outage — applies twice\n * over to something that reloads pages.\n *\n * So the recovery is RATE-BOUND, held in session storage: at most one\n * reload per tab per {@link RECOVERY_INTERVAL_MS}, and a browser that\n * refuses storage gets none at all rather than an unbounded one. A second\n * failure inside that window renders the crash page and stays there, which\n * is the honest outcome — at that point the build really is broken and\n * reloading is not the answer.\n *\n * An interval rather than a once-ever mark, because a console tab lives for\n * days and this repo ships several times a day: a once-ever mark would\n * spend the tab's only recovery on the first deploy it survived and leave\n * it stranded on the third. Long enough that no plausible loop is\n * comfortable; short enough that the next release is covered.\n *\n * A module of its own, not a function in `error-beacon.ts`, for the reason\n * `redispatch-caught-error.ts` gives: an error boundary's chunk is\n * downloaded by every visitor, and importing one name off the beacon would\n * drag its whole installer in with it.\n */\n\nconst RELOADED_KEY = 'aglyn.staleBuildReloaded'\n\n/** How long one recovery suppresses the next, in this tab. */\nexport const RECOVERY_INTERVAL_MS = 30 * 60 * 1_000\n\n/**\n * Messages the browsers spell differently for one event — the module the\n * document names is not there.\n *\n * Matched on the MESSAGE as well as the name because only webpack's own\n * failure carries `name === 'ChunkLoadError'`; a native dynamic import that\n * 404s rejects with a plain `TypeError` whose wording is the browser's.\n */\nconst STALE_BUILD_MESSAGE =\n /Loading chunk .* failed|Failed to load chunk|Loading CSS chunk .* failed|error loading dynamically imported module|Importing a module script failed|Failed to fetch dynamically imported module/i\n\n/** Was this thrown because the document names a chunk the origin dropped? */\nexport function isStaleBuildError(error: unknown): boolean {\n if (!error) return false\n const thrown = error as { name?: unknown; message?: unknown }\n if (String(thrown.name ?? '') === 'ChunkLoadError') return true\n return STALE_BUILD_MESSAGE.test(String(thrown.message ?? ''))\n}\n\n/**\n * Should this tab reload itself for a stale build — and, if so, SPEND the\n * recovery, so asking twice cannot answer yes twice.\n *\n * The decision and the reload are separated on purpose. The decision is\n * the part with the rate limit in it, and the part that must be provable;\n * the reload is one line at the call site, which the boundary writes where\n * a reader can see it happen.\n *\n * Every storage path is guarded: an accessor may throw in a private window\n * or with site data blocked, and a failure to RECOVER from an error must\n * never be a second error.\n */\nexport function shouldReloadForStaleBuild(error: unknown): boolean {\n if (typeof window === 'undefined') return false\n if (!isStaleBuildError(error)) return false\n const now = Date.now()\n try {\n const last = Number(window.sessionStorage.getItem(RELOADED_KEY) ?? '')\n // An unreadable mark is not a recent one: a stored value we cannot\n // parse must not suppress the recovery forever.\n if (Number.isFinite(last) && last > 0 && now - last < RECOVERY_INTERVAL_MS) {\n return false\n }\n window.sessionStorage.setItem(RELOADED_KEY, String(now))\n } catch {\n // No storage is no rate limit, and an unbounded reload is worse than a\n // crash page: take the crash page.\n return false\n }\n return true\n}\n"],"names":["RELOADED_KEY","RECOVERY_INTERVAL_MS","STALE_BUILD_MESSAGE","isStaleBuildError","error","thrown","String","name","test","message","shouldReloadForStaleBuild","window","now","Date","last","Number","sessionStorage","getItem","isFinite","setItem"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,MAAMA,eAAe;AAErB,4DAA4D,GAC5D,OAAO,MAAMC,uBAAuB,KAAK,KAAK,KAAK;AAEnD;;;;;;;CAOC,GACD,MAAMC,sBACJ;AAEF,2EAA2E,GAC3E,OAAO,SAASC,kBAAkBC,KAAc;QAGnCC,cAC4BA;IAHvC,IAAI,CAACD,OAAO,OAAO;IACnB,MAAMC,SAASD;IACf,IAAIE,QAAOD,eAAAA,OAAOE,IAAI,YAAXF,eAAe,QAAQ,kBAAkB,OAAO;IAC3D,OAAOH,oBAAoBM,IAAI,CAACF,QAAOD,kBAAAA,OAAOI,OAAO,YAAdJ,kBAAkB;AAC3D;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASK,0BAA0BN,KAAc;IACtD,IAAI,OAAOO,WAAW,aAAa,OAAO;IAC1C,IAAI,CAACR,kBAAkBC,QAAQ,OAAO;IACtC,MAAMQ,MAAMC,KAAKD,GAAG;IACpB,IAAI;YACkBD;QAApB,MAAMG,OAAOC,QAAOJ,iCAAAA,OAAOK,cAAc,CAACC,OAAO,CAACjB,yBAA9BW,iCAA+C;QACnE,mEAAmE;QACnE,gDAAgD;QAChD,IAAII,OAAOG,QAAQ,CAACJ,SAASA,OAAO,KAAKF,MAAME,OAAOb,sBAAsB;YAC1E,OAAO;QACT;QACAU,OAAOK,cAAc,CAACG,OAAO,CAACnB,cAAcM,OAAOM;IACrD,EAAE,eAAM;QACN,uEAAuE;QACvE,mCAAmC;QACnC,OAAO;IACT;IACA,OAAO;AACT"}
|