@braidlabs/gateway 0.1.1
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 +464 -0
- package/package.json +44 -0
- package/src/appd.d.ts +130 -0
- package/src/appd.js +93 -0
- package/src/appd.js.map +1 -0
- package/src/breaker.d.ts +51 -0
- package/src/breaker.js +96 -0
- package/src/breaker.js.map +1 -0
- package/src/discovery.d.ts +91 -0
- package/src/discovery.js +127 -0
- package/src/discovery.js.map +1 -0
- package/src/gateway.d.ts +241 -0
- package/src/gateway.js +735 -0
- package/src/gateway.js.map +1 -0
- package/src/index.d.ts +36 -0
- package/src/index.js +27 -0
- package/src/index.js.map +1 -0
- package/src/node.d.ts +35 -0
- package/src/node.js +252 -0
- package/src/node.js.map +1 -0
- package/src/protocol.d.ts +79 -0
- package/src/protocol.js +89 -0
- package/src/protocol.js.map +1 -0
- package/src/registry.d.ts +184 -0
- package/src/registry.js +187 -0
- package/src/registry.js.map +1 -0
- package/src/rewriter/html-rewrite-stream.d.ts +70 -0
- package/src/rewriter/html-rewrite-stream.js +483 -0
- package/src/rewriter/html-rewrite-stream.js.map +1 -0
- package/src/rewriter/transforms.d.ts +107 -0
- package/src/rewriter/transforms.js +312 -0
- package/src/rewriter/transforms.js.map +1 -0
- package/src/single-flight.d.ts +53 -0
- package/src/single-flight.js +97 -0
- package/src/single-flight.js.map +1 -0
- package/src/telemetry.d.ts +115 -0
- package/src/telemetry.js +177 -0
- package/src/telemetry.js.map +1 -0
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional telemetry: what the gateway did on the server, and what the user experienced in the
|
|
3
|
+
* browser — both attributed to the fragment responsible.
|
|
4
|
+
*
|
|
5
|
+
* **The attribution is the point.** Server-side timings per fragment are ordinary; a reverse proxy
|
|
6
|
+
* gives you those. Web vitals per *fragment* are not, because in a composed page the page-level
|
|
7
|
+
* number is an average over apps owned by different teams. "The page has a CLS of 0.31" starts an
|
|
8
|
+
* argument. "Ninety percent of the layout shift happened inside `reviews`" ends it.
|
|
9
|
+
*
|
|
10
|
+
* Off unless configured, and configured as one hook rather than a sink: what to do with an event —
|
|
11
|
+
* OpenTelemetry, a log line, a counter, nothing — is the deployment's decision, and a gateway that
|
|
12
|
+
* picked for you would be a dependency you did not ask for.
|
|
13
|
+
*/
|
|
14
|
+
/** Thresholds from web.dev. Kept here so the gateway and the collector cannot disagree. */
|
|
15
|
+
const THRESHOLDS = {
|
|
16
|
+
LCP: [2500, 4000],
|
|
17
|
+
CLS: [0.1, 0.25],
|
|
18
|
+
INP: [200, 500],
|
|
19
|
+
FCP: [1800, 3000],
|
|
20
|
+
TTFB: [800, 1800],
|
|
21
|
+
};
|
|
22
|
+
export function rateVital(name, value) {
|
|
23
|
+
const [good, poor] = THRESHOLDS[name];
|
|
24
|
+
return value <= good ? 'good' : value <= poor ? 'needs-improvement' : 'poor';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parses a beacon body into events.
|
|
28
|
+
*
|
|
29
|
+
* **Everything here arrives from a browser, so none of it is trusted.** A metric name that is not
|
|
30
|
+
* one of the five, a value that is not finite, or a fragment id that is not registered is dropped
|
|
31
|
+
* rather than forwarded — a telemetry endpoint that relays whatever it is posted is an open
|
|
32
|
+
* ingestion API for anyone who can reach it, and the sink downstream would be the thing paying for
|
|
33
|
+
* that. `known` is the registry's fragment ids; pass an empty set to accept none.
|
|
34
|
+
*/
|
|
35
|
+
export function parseVitalsBeacon(body, known, now = Date.now()) {
|
|
36
|
+
if (!body || typeof body !== 'object')
|
|
37
|
+
return [];
|
|
38
|
+
const { pathname, metrics } = body;
|
|
39
|
+
if (typeof pathname !== 'string' || !Array.isArray(metrics))
|
|
40
|
+
return [];
|
|
41
|
+
// A pathname is echoed back into whatever the sink writes to, so it is length-capped and
|
|
42
|
+
// stripped of its query here rather than trusted to be a URL this gateway ever served.
|
|
43
|
+
const safePath = pathname.split('?')[0].slice(0, 512);
|
|
44
|
+
const events = [];
|
|
45
|
+
for (const metric of metrics.slice(0, 32)) {
|
|
46
|
+
if (!metric || typeof metric !== 'object')
|
|
47
|
+
continue;
|
|
48
|
+
const { name, value, fragmentId } = metric;
|
|
49
|
+
if (typeof name !== 'string' || !(name in THRESHOLDS))
|
|
50
|
+
continue;
|
|
51
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
52
|
+
continue;
|
|
53
|
+
const vital = name;
|
|
54
|
+
events.push({
|
|
55
|
+
kind: 'web-vital',
|
|
56
|
+
name: vital,
|
|
57
|
+
value,
|
|
58
|
+
rating: rateVital(vital, value),
|
|
59
|
+
// An unregistered id means the page was tampered with or the registry changed under it.
|
|
60
|
+
// Either way the honest attribution is "not a fragment we know", which is null.
|
|
61
|
+
fragmentId: typeof fragmentId === 'string' && known.has(fragmentId) ? fragmentId : null,
|
|
62
|
+
pathname: safePath,
|
|
63
|
+
at: now,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return events;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The browser-side collector, as a source string.
|
|
70
|
+
*
|
|
71
|
+
* Hand-written rather than pulling in `web-vitals`: the library is excellent and mostly solves a
|
|
72
|
+
* problem this does not have — cross-browser normalization for metrics whose polyfills matter on
|
|
73
|
+
* browsers that cannot run Braid's realms anyway. What is left is four `PerformanceObserver`
|
|
74
|
+
* registrations, and shipping them inline keeps this dependency-free and keeps the injected script
|
|
75
|
+
* small enough that measuring a page does not measurably change it.
|
|
76
|
+
*
|
|
77
|
+
* The part that is not boilerplate is {@link ownerOf}: attributing an element to the fragment that
|
|
78
|
+
* rendered it means climbing *out of shadow roots*, since a pierced fragment's DOM lives inside a
|
|
79
|
+
* declarative shadow root and `closest()` stops at that boundary.
|
|
80
|
+
*/
|
|
81
|
+
export function vitalsCollectorScript(endpoint, sampleRate) {
|
|
82
|
+
return `// Generated by @braidlabs/gateway. Reports web vitals, attributed per fragment.
|
|
83
|
+
(function () {
|
|
84
|
+
if (Math.random() >= ${JSON.stringify(sampleRate)}) return;
|
|
85
|
+
if (typeof PerformanceObserver !== 'function') return;
|
|
86
|
+
|
|
87
|
+
var metrics = new Map();
|
|
88
|
+
var sent = false;
|
|
89
|
+
|
|
90
|
+
// Climbs out of shadow roots, which closest() will not do: a pierced fragment's content lives
|
|
91
|
+
// in a declarative shadow root, so every node inside one is invisible to an ordinary ancestor
|
|
92
|
+
// walk from the document.
|
|
93
|
+
function ownerOf(node) {
|
|
94
|
+
var current = node;
|
|
95
|
+
while (current) {
|
|
96
|
+
if (current.nodeType === 1 && current.tagName === 'FRAGMENT-SLOT') {
|
|
97
|
+
return current.getAttribute('name');
|
|
98
|
+
}
|
|
99
|
+
var next = current.parentNode;
|
|
100
|
+
if (!next) {
|
|
101
|
+
var root = current.getRootNode && current.getRootNode();
|
|
102
|
+
next = root && root.host ? root.host : null;
|
|
103
|
+
}
|
|
104
|
+
current = next;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Last value wins for the cumulative and "largest" metrics; that is what the final report means.
|
|
110
|
+
function record(name, value, node) {
|
|
111
|
+
metrics.set(name, { name: name, value: value, fragmentId: node ? ownerOf(node) : null });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function observe(type, handler, options) {
|
|
115
|
+
try {
|
|
116
|
+
var observer = new PerformanceObserver(function (list) { list.getEntries().forEach(handler); });
|
|
117
|
+
observer.observe(Object.assign({ type: type, buffered: true }, options || {}));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// An unsupported entry type is not an error worth surfacing to the page; it just means this
|
|
120
|
+
// browser does not report that metric.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
observe('largest-contentful-paint', function (entry) {
|
|
125
|
+
record('LCP', entry.startTime, entry.element);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
observe('paint', function (entry) {
|
|
129
|
+
if (entry.name === 'first-contentful-paint') record('FCP', entry.startTime, null);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
observe('navigation', function (entry) {
|
|
133
|
+
record('TTFB', entry.responseStart, null);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// CLS is cumulative, and the interesting question is which fragment moved the most — so the
|
|
137
|
+
// score is accumulated per owner and the worst offender is what gets reported.
|
|
138
|
+
var shiftByOwner = new Map();
|
|
139
|
+
observe('layout-shift', function (entry) {
|
|
140
|
+
if (entry.hadRecentInput) return;
|
|
141
|
+
var sources = entry.sources || [];
|
|
142
|
+
var owner = null;
|
|
143
|
+
for (var i = 0; i < sources.length && owner === null; i++) {
|
|
144
|
+
if (sources[i].node) owner = ownerOf(sources[i].node);
|
|
145
|
+
}
|
|
146
|
+
var key = owner === null ? '' : owner;
|
|
147
|
+
shiftByOwner.set(key, (shiftByOwner.get(key) || 0) + entry.value);
|
|
148
|
+
|
|
149
|
+
var worstKey = '';
|
|
150
|
+
var total = 0;
|
|
151
|
+
shiftByOwner.forEach(function (score, candidate) {
|
|
152
|
+
total += score;
|
|
153
|
+
if (score > (shiftByOwner.get(worstKey) || 0)) worstKey = candidate;
|
|
154
|
+
});
|
|
155
|
+
metrics.set('CLS', { name: 'CLS', value: total, fragmentId: worstKey || null });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
observe('event', function (entry) {
|
|
159
|
+
var current = metrics.get('INP');
|
|
160
|
+
if (!current || entry.duration > current.value) record('INP', entry.duration, entry.target);
|
|
161
|
+
}, { durationThreshold: 40 });
|
|
162
|
+
|
|
163
|
+
// Reported when the page goes away, which is the only moment the numbers are final. pagehide
|
|
164
|
+
// covers the bfcache path that visibilitychange alone misses on iOS.
|
|
165
|
+
function flush() {
|
|
166
|
+
if (sent || metrics.size === 0) return;
|
|
167
|
+
sent = true;
|
|
168
|
+
var body = JSON.stringify({ pathname: location.pathname, metrics: Array.from(metrics.values()) });
|
|
169
|
+
if (navigator.sendBeacon) navigator.sendBeacon(${JSON.stringify(endpoint)}, new Blob([body], { type: 'application/json' }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flush(); });
|
|
173
|
+
addEventListener('pagehide', flush);
|
|
174
|
+
})();
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=telemetry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telemetry.js","sourceRoot":"","sources":["../../../../libs/braid-gateway/src/telemetry.ts"],"names":[],"mappings":"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"}
|