@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.
@@ -0,0 +1,312 @@
1
+ import { BRAID_FRAGMENT_PREFIX } from '../protocol.js';
2
+ import { concatStreams, rewriteHtmlStream } from './html-rewrite-stream.js';
3
+ /**
4
+ * The composition protocol's HTML transforms, expressed against the owned rewriter.
5
+ *
6
+ * These are the normative behaviors a Braid gateway must implement; the conformance vectors in
7
+ * `transforms.spec.ts` are their oracle, and any second implementation (a native `HTMLRewriter`
8
+ * path on workerd, say) must pass the same vectors before it is allowed to serve traffic.
9
+ */
10
+ /** The `<style>` the shell needs for slots and fragment stand-ins to lay out as blocks. */
11
+ export const BRAID_SHELL_STYLES = '<style>fragment-slot { display: block; }</style>';
12
+ /**
13
+ * The shell's CSP nonce, read from its own response headers.
14
+ *
15
+ * **The gateway injects inline markup into someone else's document**, and under a strict policy —
16
+ * `script-src 'nonce-…'`, `style-src 'nonce-…'` — anything without the right nonce is dropped
17
+ * silently by the browser. That is the worst possible failure shape: the page renders, the slot
18
+ * layout rule is gone, and nothing in the server logs mentions it.
19
+ *
20
+ * Reading the nonce off the shell's *own* CSP is the only correct source. Minting one here would
21
+ * be worse than useless: a nonce the shell's policy does not list is not trusted, and a nonce
22
+ * reused across responses is not a nonce. If the shell does not send a policy, or sends one
23
+ * without a nonce, this returns null and nothing is stamped — an unrestricted page needs no
24
+ * attribute, and a policy using hashes or `'unsafe-inline'` is not ours to second-guess.
25
+ */
26
+ export function cspNonceOf(headers) {
27
+ const policy = headers.get('content-security-policy');
28
+ if (!policy)
29
+ return null;
30
+ // Only script-src and style-src matter here; those are the two directives governing what this
31
+ // layer injects. `default-src` counts when the specific directive is absent, per CSP fallback.
32
+ const match = /(?:^|;)\s*(?:script-src|style-src|default-src)[^;]*'nonce-([A-Za-z0-9+/_-]+=*)'/.exec(policy);
33
+ return match?.[1] ?? null;
34
+ }
35
+ /** Stamps a nonce onto generated inline markup. A no-op when the shell has no policy. */
36
+ export function withNonce(markup, nonce) {
37
+ if (!nonce)
38
+ return markup;
39
+ return markup.replace(/<(script|style)(?=[\s>])/g, `<$1 nonce="${escapeAttribute(nonce)}"`);
40
+ }
41
+ /** The `<style>` that goes inside every fragment's shadow root. */
42
+ export const BRAID_FRAGMENT_STYLES = '<style>:host, braid-document, braid-html, braid-body { display: block; } braid-head { display: none; }</style>';
43
+ /**
44
+ * Inline event handler content attributes (`onclick`, `onerror`, …).
45
+ *
46
+ * Deliberately `on` + letters only: the browser compiles these into functions **in the document
47
+ * that owns the node**, which for fragment content is the host page — so they are a wrong-realm
48
+ * execution path. Framework binding syntaxes that merely look similar (`on-click`, `x-on:click`,
49
+ * `@click`, `(click)`) contain a non-letter and are left alone; they are interpreted by the
50
+ * fragment's own framework inside the fragment's realm, so removing them would break the app
51
+ * without buying any isolation.
52
+ */
53
+ const INLINE_EVENT_HANDLER = /^on[a-z]+$/;
54
+ /**
55
+ * Attributes that point at a **subresource** the fragment owns, per element.
56
+ *
57
+ * A fragment's markup ends up in the host page's DOM, so these URLs resolve against the *host*
58
+ * page rather than the fragment — `styles.css` on a host page at `/billing/invoices` would be
59
+ * fetched from `/billing/styles.css`, which belongs to the host. Rewriting them into the
60
+ * fragment's namespace is what makes "the fragment is served through the gateway" true for
61
+ * everything its markup references, not just the code the realm executes.
62
+ *
63
+ * Navigation targets (`a[href]`, `form[action]`, `area[href]`) are deliberately absent: those
64
+ * are page navigations, and in bound mode they belong to the host's URL space. `base[href]` is
65
+ * absent too — it is what the fragment's own router reads to know its base path, and rewriting
66
+ * it into the namespace would break routing.
67
+ */
68
+ const SUBRESOURCE_ATTRIBUTES = {
69
+ audio: ['src'],
70
+ embed: ['src'],
71
+ iframe: ['src'],
72
+ img: ['src', 'srcset'],
73
+ input: ['src'],
74
+ link: ['href'],
75
+ object: ['data'],
76
+ script: ['src'],
77
+ source: ['src', 'srcset'],
78
+ track: ['src'],
79
+ use: ['href'],
80
+ video: ['src', 'poster'],
81
+ };
82
+ const SRCSET_ATTRIBUTES = new Set(['srcset']);
83
+ /**
84
+ * Prepares a fragment's HTML for life inside the host page:
85
+ *
86
+ * - the doctype is stripped (a nested doctype makes some parsers choke, and it materializes
87
+ * nothing in the DOM anyway);
88
+ * - `<html>`/`<head>`/`<body>` become `braid-html`/`braid-head`/`braid-body`, because the DOM
89
+ * forbids duplicates of those singletons and would silently drop them;
90
+ * - every `<script>` is neutralized (`type="inert"`, real type parked in `data-script-type`) so
91
+ * it cannot execute in the host's JS context — the client activates it in the fragment's realm;
92
+ * - script preload/prefetch/modulepreload links become `rel="inert-*"` so they don't trigger a
93
+ * duplicate load in the host context;
94
+ * - inline event handler attributes are removed, and `<meta http-equiv="refresh">` is defanged.
95
+ *
96
+ * This is the server half of the born-inert invariant, and the last two rules are what make the
97
+ * invariant true rather than merely true-of-`<script>`: **no markup a fragment sends can execute
98
+ * JavaScript in the host realm or navigate the host page.** Both were verified executing in the
99
+ * host realm before this transform existed.
100
+ *
101
+ * Still not neutralized, because they require user interaction rather than executing on parse:
102
+ * `javascript:` URLs and form `action`s. Those remain within the trusted tier's stated model —
103
+ * a trusted fragment can navigate the page a user clicks through — and are called out in the
104
+ * security section of the README.
105
+ */
106
+ export function prepareFragmentHtml(body, options) {
107
+ const { fragmentId } = options;
108
+ // The fragment's own <base href>, which its subresource URLs resolve against. It appears in
109
+ // <head> before anything that references it, so tracking it as the stream passes is enough.
110
+ let fragmentBaseHref = '/';
111
+ return rewriteHtmlStream(body, {
112
+ stripDoctype: true,
113
+ handlers: {
114
+ '*': {
115
+ element(tag) {
116
+ for (const attributeName of tag.attributeNames) {
117
+ if (INLINE_EVENT_HANDLER.test(attributeName)) {
118
+ tag.removeAttribute(attributeName);
119
+ }
120
+ }
121
+ rewriteSubresourceUrls(tag, fragmentId, fragmentBaseHref);
122
+ },
123
+ },
124
+ base: {
125
+ element(tag) {
126
+ // read, never rewrite: this is what the fragment's router reads as its base path
127
+ const href = tag.getAttribute('href');
128
+ if (href)
129
+ fragmentBaseHref = href;
130
+ },
131
+ },
132
+ html: { element: (tag) => void (tag.tagName = 'braid-html') },
133
+ head: { element: (tag) => void (tag.tagName = 'braid-head') },
134
+ body: { element: (tag) => void (tag.tagName = 'braid-body') },
135
+ script: {
136
+ element(tag) {
137
+ const type = tag.getAttribute('type');
138
+ if (type)
139
+ tag.setAttribute('data-script-type', type);
140
+ tag.setAttribute('type', 'inert');
141
+ },
142
+ },
143
+ link: {
144
+ element(tag) {
145
+ const rel = tag.getAttribute('rel');
146
+ if (rel === 'preload' || rel === 'prefetch' || rel === 'modulepreload') {
147
+ tag.setAttribute('rel', `inert-${rel}`);
148
+ }
149
+ },
150
+ },
151
+ meta: {
152
+ element(tag) {
153
+ // a meta refresh anywhere in the page navigates the whole host document, throwing away
154
+ // the shell and every other fragment on it
155
+ if (tag.getAttribute('http-equiv')?.trim().toLowerCase() !== 'refresh')
156
+ return;
157
+ tag.removeAttribute('http-equiv');
158
+ tag.setAttribute('data-braid-blocked', 'meta-refresh');
159
+ },
160
+ },
161
+ },
162
+ });
163
+ }
164
+ /** Rewrites a tag's subresource URLs into the fragment's namespace. */
165
+ function rewriteSubresourceUrls(tag, fragmentId, baseHref) {
166
+ const attributes = SUBRESOURCE_ATTRIBUTES[tag.tagName];
167
+ if (!attributes)
168
+ return;
169
+ for (const attributeName of attributes) {
170
+ const value = tag.getAttribute(attributeName);
171
+ if (!value)
172
+ continue;
173
+ const rewritten = SRCSET_ATTRIBUTES.has(attributeName)
174
+ ? rewriteSrcset(value, fragmentId, baseHref)
175
+ : namespaceUrl(value, fragmentId, baseHref);
176
+ if (rewritten !== null) {
177
+ tag.setAttribute(attributeName, rewritten);
178
+ }
179
+ }
180
+ }
181
+ /**
182
+ * Maps one URL into the fragment's namespace, or returns null to leave it untouched.
183
+ *
184
+ * Left alone: anything with a scheme (`https:`, `data:`, `blob:`), protocol-relative URLs, and
185
+ * pure fragment identifiers — none of those are the fragment's own subresources.
186
+ */
187
+ function namespaceUrl(rawUrl, fragmentId, baseHref) {
188
+ const url = rawUrl.trim();
189
+ if (!url || url.startsWith('#') || url.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(url)) {
190
+ return null;
191
+ }
192
+ // resolve exactly as the browser would have in the fragment's own document, then re-root it
193
+ const resolved = new URL(url, `http://braid.invalid${baseHref.startsWith('/') ? baseHref : `/${baseHref}`}`);
194
+ return `${BRAID_FRAGMENT_PREFIX}${encodeURIComponent(fragmentId)}${resolved.pathname}${resolved.search}${resolved.hash}`;
195
+ }
196
+ /** Rewrites each candidate in a `srcset`, preserving its density/width descriptors. */
197
+ function rewriteSrcset(value, fragmentId, baseHref) {
198
+ const candidates = value.split(',');
199
+ let changed = false;
200
+ const rewritten = candidates.map((candidate) => {
201
+ const match = /^(\s*)(\S+)(\s*.*)$/.exec(candidate);
202
+ if (!match)
203
+ return candidate;
204
+ const [, leading, url, descriptor] = match;
205
+ const namespaced = namespaceUrl(url, fragmentId, baseHref);
206
+ if (namespaced === null)
207
+ return candidate;
208
+ changed = true;
209
+ return `${leading}${namespaced}${descriptor}`;
210
+ });
211
+ return changed ? rewritten.join(',') : null;
212
+ }
213
+ /**
214
+ * Pierces a fragment into the shell's HTML stream.
215
+ *
216
+ * The fragment's server-rendered content is injected into the matching `<fragment-slot>` as a
217
+ * declarative shadow root, so the browser parses it into exactly the shape the client runtime
218
+ * would have built — the slot then adopts it instead of fetching, and the fragment paints as
219
+ * part of the shell's first response rather than a round trip later.
220
+ *
221
+ * Injection is stream-interleaved: the shell streams out until the slot is reached, the
222
+ * fragment's stream is spliced in as it arrives, then the rest of the shell follows.
223
+ *
224
+ * If the shell contains no matching slot, the fragment is appended before `</body>` (or at the
225
+ * end of the document — `</body>` is optional in HTML and often omitted) inside a slot element
226
+ * the gateway creates. This keeps piercing working for shells that haven't been marked up yet.
227
+ */
228
+ export function pierceShellHtml(options) {
229
+ const { shell, headScript, nonce } = options;
230
+ const pending = new Map(options.fragments.map((fragment) => [fragment.fragmentId, fragment]));
231
+ let stylesInjected = false;
232
+ const shadowRoot = (target) => [
233
+ '<template shadowrootmode="open">',
234
+ BRAID_FRAGMENT_STYLES,
235
+ '<braid-document>',
236
+ target.content ?? '',
237
+ '</braid-document></template>',
238
+ ];
239
+ /** A slot element the gateway creates because the shell didn't mark one up. */
240
+ const orphanSlot = (target) => concatStreams([
241
+ `<fragment-slot name="${escapeAttribute(target.fragmentId)}"` +
242
+ // A slot the gateway invents has no template to have declared `src`, so the manifest's is
243
+ // the only source there is.
244
+ `${target.src ? ` src="${escapeAttribute(target.src)}"` : ''} data-braid-pierced="">`,
245
+ ...shadowRoot(target),
246
+ '</fragment-slot>',
247
+ ]);
248
+ /** Every fragment that never found a slot, appended in registration order. */
249
+ const remainingOrphans = () => {
250
+ const orphans = [...pending.values()].filter((target) => target.content);
251
+ pending.clear();
252
+ return orphans.length ? concatStreams(orphans.map(orphanSlot)) : undefined;
253
+ };
254
+ return rewriteHtmlStream(shell, {
255
+ handlers: {
256
+ head: {
257
+ element(tag) {
258
+ if (stylesInjected)
259
+ return;
260
+ stylesInjected = true;
261
+ // Styles first: the slot layout rules must land before any fragment content is parsed,
262
+ // and an async measurement script must never be what delays them.
263
+ tag.prepend(withNonce(BRAID_SHELL_STYLES + (headScript ?? ''), nonce ?? null));
264
+ },
265
+ },
266
+ 'fragment-slot': {
267
+ element(tag) {
268
+ const name = tag.getAttribute('name');
269
+ const target = name ? pending.get(name) : undefined;
270
+ if (!target)
271
+ return;
272
+ pending.delete(target.fragmentId);
273
+ const declared = tag.getAttribute('src');
274
+ if (target.src && declared && declared !== target.src) {
275
+ // Two sources of truth that disagree: the gateway pierced content from one path while
276
+ // the client will boot the fragment at another, so the widget changes under the user
277
+ // the moment it hydrates. Cheap to say, and invisible otherwise.
278
+ console.warn(`braid-gateway: slot for fragment "${target.fragmentId}" declares src="${declared}" ` +
279
+ `but its manifest declares src="${target.src}" — the pierced content and the client ` +
280
+ `boot would come from different paths`);
281
+ }
282
+ if (target.src && !declared)
283
+ tag.setAttribute('src', target.src);
284
+ if (!target.content) {
285
+ // nothing to pierce: mark the slot so the page can style a skeleton, and let the
286
+ // client runtime fetch the fragment itself
287
+ if (target.fallbackReason)
288
+ tag.setAttribute('data-braid-fallback', target.fallbackReason);
289
+ return;
290
+ }
291
+ tag.setAttribute('data-braid-pierced', '');
292
+ for (const part of shadowRoot(target)) {
293
+ tag.prepend(part);
294
+ }
295
+ },
296
+ },
297
+ body: {
298
+ endTag(tag) {
299
+ const orphans = remainingOrphans();
300
+ if (orphans)
301
+ tag.before(orphans);
302
+ },
303
+ },
304
+ },
305
+ // `</body>` is optional in HTML and frequently omitted — this is the safety net
306
+ onEnd: remainingOrphans,
307
+ });
308
+ }
309
+ function escapeAttribute(value) {
310
+ return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
311
+ }
312
+ //# sourceMappingURL=transforms.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transforms.js","sourceRoot":"","sources":["../../../../../libs/braid-gateway/src/rewriter/transforms.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Collapses concurrent identical fragment fetches into one.
3
+ *
4
+ * **Not a cache, and the distinction is the whole design.** A cache answers from the past and has
5
+ * to reason about staleness, invalidation, and — on ECS or any multi-instance deployment — which
6
+ * instance holds the entry. This holds nothing: it only notices that a fetch it is *already making*
7
+ * is the one a second request wants, and lets both use the result. Nothing outlives the request
8
+ * that started it, so there is no TTL to tune, no invalidation to get wrong, and instance affinity
9
+ * is irrelevant.
10
+ *
11
+ * That is why this belongs in the gateway while response caching belongs in a CDN. A CDN in front
12
+ * of the gateway caches the composed document beautifully and does nothing at all for the N origin
13
+ * fetches a cache *miss* still costs. An unbound widget pierced into `/` and `/*` is fetched once
14
+ * per page render; fifty concurrent renders are fifty fetches of identical content. This makes them
15
+ * one.
16
+ */
17
+ export interface SingleFlight {
18
+ /**
19
+ * Runs `fetcher`, or joins the identical call already in progress.
20
+ *
21
+ * Every participant receives its own `Response` — bodies are single-use, so joiners cannot be
22
+ * handed the same object.
23
+ */
24
+ run(key: string, fetcher: () => Promise<Response>): Promise<Response>;
25
+ /** In-flight entries. For tests and for a health endpoint that wants to show the collapse rate. */
26
+ size(): number;
27
+ }
28
+ export declare function createSingleFlight(): SingleFlight;
29
+ /**
30
+ * The key two requests must agree on before their fetches may be shared.
31
+ *
32
+ * **This is a correctness boundary, not an optimization knob.** Two requests get one response, so
33
+ * anything that could make the endpoint answer differently has to be in the key — otherwise one
34
+ * user is served a render personalized for another, which is a data leak wearing a performance
35
+ * feature's clothes.
36
+ *
37
+ * So `cookie` and `authorization` are included verbatim. The honest consequence is that
38
+ * coalescing helps anonymous and shared-identity traffic a great deal and personalized fragments
39
+ * not at all: two signed-in users have different cookies and therefore never share a flight. That
40
+ * is the correct behaviour, not a limitation to tune away.
41
+ *
42
+ * `user-agent` is in the key for the same reason, and it is the one that matters now that this is
43
+ * on by default: server-side device detection is common and completely invisible from the
44
+ * manifest, so an endpoint rendering a mobile layout would otherwise have it served to the next
45
+ * desktop request that happened to arrive alongside it. It costs collapse rate — user-agent
46
+ * strings are diverse — and that is the right trade for a default.
47
+ *
48
+ * What this cannot see is an endpoint varying on something unusual: a tenant header, a
49
+ * feature-flag header, anything bespoke. Those set `coalesce: false` on the manifest.
50
+ *
51
+ * Returns null when the request must not be shared at all.
52
+ */
53
+ export declare function singleFlightKey(request: Request, url: string): string | null;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Collapses concurrent identical fragment fetches into one.
3
+ *
4
+ * **Not a cache, and the distinction is the whole design.** A cache answers from the past and has
5
+ * to reason about staleness, invalidation, and — on ECS or any multi-instance deployment — which
6
+ * instance holds the entry. This holds nothing: it only notices that a fetch it is *already making*
7
+ * is the one a second request wants, and lets both use the result. Nothing outlives the request
8
+ * that started it, so there is no TTL to tune, no invalidation to get wrong, and instance affinity
9
+ * is irrelevant.
10
+ *
11
+ * That is why this belongs in the gateway while response caching belongs in a CDN. A CDN in front
12
+ * of the gateway caches the composed document beautifully and does nothing at all for the N origin
13
+ * fetches a cache *miss* still costs. An unbound widget pierced into `/` and `/*` is fetched once
14
+ * per page render; fifty concurrent renders are fifty fetches of identical content. This makes them
15
+ * one.
16
+ */
17
+ export function createSingleFlight() {
18
+ const inflight = new Map();
19
+ return {
20
+ async run(key, fetcher) {
21
+ const existing = inflight.get(key);
22
+ if (existing)
23
+ return (await existing).clone();
24
+ const flight = fetcher();
25
+ inflight.set(key, flight);
26
+ let response;
27
+ try {
28
+ response = await flight;
29
+ }
30
+ finally {
31
+ // Removed as soon as it settles: a request arriving after this point must start its own
32
+ // fetch rather than join a completed one, which is what keeps this a coalescer and not a
33
+ // cache with an accidental TTL.
34
+ inflight.delete(key);
35
+ }
36
+ const mine = response.clone();
37
+ /**
38
+ * Release the branch nobody reads.
39
+ *
40
+ * Every participant — including this one — takes a clone, so the original is left unread.
41
+ * `clone()` tees the body, and a tee buffers without bound for whichever branch lags, so an
42
+ * abandoned original would hold the whole response in memory. Cancelling it stops that.
43
+ *
44
+ * On a macrotask rather than a microtask because the joiners' `await` continuations are
45
+ * microtasks: cancelling sooner would race them and destroy the body they are about to
46
+ * clone. Cancelling one tee branch does not disturb the others.
47
+ */
48
+ setTimeout(() => void response.body?.cancel().catch(() => undefined), 0);
49
+ return mine;
50
+ },
51
+ size: () => inflight.size,
52
+ };
53
+ }
54
+ /**
55
+ * The key two requests must agree on before their fetches may be shared.
56
+ *
57
+ * **This is a correctness boundary, not an optimization knob.** Two requests get one response, so
58
+ * anything that could make the endpoint answer differently has to be in the key — otherwise one
59
+ * user is served a render personalized for another, which is a data leak wearing a performance
60
+ * feature's clothes.
61
+ *
62
+ * So `cookie` and `authorization` are included verbatim. The honest consequence is that
63
+ * coalescing helps anonymous and shared-identity traffic a great deal and personalized fragments
64
+ * not at all: two signed-in users have different cookies and therefore never share a flight. That
65
+ * is the correct behaviour, not a limitation to tune away.
66
+ *
67
+ * `user-agent` is in the key for the same reason, and it is the one that matters now that this is
68
+ * on by default: server-side device detection is common and completely invisible from the
69
+ * manifest, so an endpoint rendering a mobile layout would otherwise have it served to the next
70
+ * desktop request that happened to arrive alongside it. It costs collapse rate — user-agent
71
+ * strings are diverse — and that is the right trade for a default.
72
+ *
73
+ * What this cannot see is an endpoint varying on something unusual: a tenant header, a
74
+ * feature-flag header, anything bespoke. Those set `coalesce: false` on the manifest.
75
+ *
76
+ * Returns null when the request must not be shared at all.
77
+ */
78
+ export function singleFlightKey(request, url) {
79
+ // Only safe methods. A POST is an action, and two identical actions are still two actions.
80
+ if (request.method !== 'GET' && request.method !== 'HEAD')
81
+ return null;
82
+ // A range request is a slice of a body; sharing one response across different ranges would hand
83
+ // callers bytes they did not ask for.
84
+ if (request.headers.has('range'))
85
+ return null;
86
+ return [
87
+ request.method,
88
+ url,
89
+ request.headers.get('cookie') ?? '',
90
+ request.headers.get('authorization') ?? '',
91
+ // Content negotiation the endpoint may legitimately vary on.
92
+ request.headers.get('accept-language') ?? '',
93
+ request.headers.get('accept') ?? '',
94
+ request.headers.get('user-agent') ?? '',
95
+ ].join('\n');
96
+ }
97
+ //# sourceMappingURL=single-flight.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"single-flight.js","sourceRoot":"","sources":["../../../../libs/braid-gateway/src/single-flight.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAcH,MAAM,UAAU,kBAAkB;IAChC,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA6B,CAAC;IAEtD,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO;YACpB,MAAM,QAAQ,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YACnC,IAAI,QAAQ;gBAAE,OAAO,CAAC,MAAM,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;YAE9C,MAAM,MAAM,GAAG,OAAO,EAAE,CAAC;YACzB,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;YAE1B,IAAI,QAAkB,CAAC;YACvB,IAAI,CAAC;gBACH,QAAQ,GAAG,MAAM,MAAM,CAAC;YAC1B,CAAC;oBAAS,CAAC;gBACT,wFAAwF;gBACxF,yFAAyF;gBACzF,gCAAgC;gBAChC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACvB,CAAC;YAED,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,EAAE,CAAC;YAE9B;;;;;;;;;;eAUG;YACH,UAAU,CAAC,GAAG,EAAE,CAAC,KAAK,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC;YAEzE,OAAO,IAAI,CAAC;QACd,CAAC;QAED,IAAI,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI;KAC1B,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,eAAe,CAAC,OAAgB,EAAE,GAAW;IAC3D,2FAA2F;IAC3F,IAAI,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IAEvE,gGAAgG;IAChG,sCAAsC;IACtC,IAAI,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAE9C,OAAO;QACL,OAAO,CAAC,MAAM;QACd,GAAG;QACH,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE;QACnC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,IAAI,EAAE;QAC1C,6DAA6D;QAC7D,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,IAAI,EAAE;QAC5C,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE;QACnC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,IAAI,EAAE;KACxC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
@@ -0,0 +1,115 @@
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
+ /** Fires when the gateway fetches a fragment's endpoint. Server-side, always available. */
15
+ export interface FragmentFetchEvent {
16
+ kind: 'fragment-fetch';
17
+ fragmentId: string;
18
+ /**
19
+ * Why the fetch happened: composing a document, or serving a request addressed to the
20
+ * fragment's namespace. Worth distinguishing — a slow pierce delays the page, while a slow
21
+ * namespace asset delays only that fragment.
22
+ */
23
+ phase: 'pierce' | 'namespace';
24
+ /**
25
+ * `'shed'` means the breaker refused the request without making it — no endpoint was contacted,
26
+ * so there is no status and the duration is ~0. Distinct from `error` on purpose: an operator
27
+ * counting failures must not count our own load-shedding as the endpoint failing again.
28
+ */
29
+ outcome: 'ok' | 'error' | 'timeout' | 'shed';
30
+ /** Absent when the fetch threw before a response existed. */
31
+ status?: number;
32
+ durationMs: number;
33
+ at: number;
34
+ }
35
+ export type WebVitalName = 'LCP' | 'CLS' | 'INP' | 'FCP' | 'TTFB';
36
+ /** Fires when a browser reports a web vital, attributed to a fragment where one can be blamed. */
37
+ export interface WebVitalEvent {
38
+ kind: 'web-vital';
39
+ name: WebVitalName;
40
+ value: number;
41
+ rating: 'good' | 'needs-improvement' | 'poor';
42
+ /**
43
+ * The fragment whose subtree the metric was attributed to.
44
+ *
45
+ * `null` is a real answer, not a gap: TTFB belongs to the document, and an LCP element in the
46
+ * shell's own markup is genuinely the shell's. Blaming a fragment for those would be worse than
47
+ * saying nothing.
48
+ */
49
+ fragmentId: string | null;
50
+ pathname: string;
51
+ at: number;
52
+ }
53
+ /** Fires when a fragment's circuit breaker changes state. Never per request — only on the edges. */
54
+ export interface BreakerEvent {
55
+ kind: 'breaker';
56
+ fragmentId: string;
57
+ from: 'closed' | 'open' | 'half-open';
58
+ to: 'closed' | 'open' | 'half-open';
59
+ /** Consecutive failures at the moment of the transition. */
60
+ failures: number;
61
+ at: number;
62
+ }
63
+ export type TelemetryEvent = FragmentFetchEvent | WebVitalEvent | BreakerEvent;
64
+ export interface TelemetryOptions {
65
+ /**
66
+ * Called for every event.
67
+ *
68
+ * **On the request path for server events, so it must be cheap.** Called synchronously and never
69
+ * awaited; a sink that does real work should buffer and flush elsewhere. Unlike `observe`, this
70
+ * one *is* wrapped — a broken telemetry sink must not fail the request it is describing, because
71
+ * the whole point is to be safe to leave on in production.
72
+ */
73
+ on: (event: TelemetryEvent) => void;
74
+ /**
75
+ * Collect web vitals in the browser and report them here. Off by default.
76
+ *
77
+ * Turning this on serves a small collector from the gateway and injects it into composed
78
+ * documents. It is off by default because it adds a script to every pierced page, and that is
79
+ * not a decision a library should make for a deployment.
80
+ */
81
+ webVitals?: boolean;
82
+ /**
83
+ * Fraction of *browser sessions* that report, 0–1. Defaults to 1.
84
+ *
85
+ * Sampled per session rather than per metric on purpose: a session that reports its LCP but not
86
+ * its CLS produces a dataset where the two cannot be correlated, which quietly ruins the
87
+ * analysis they were collected for.
88
+ */
89
+ sampleRate?: number;
90
+ }
91
+ export declare function rateVital(name: WebVitalName, value: number): WebVitalEvent['rating'];
92
+ /**
93
+ * Parses a beacon body into events.
94
+ *
95
+ * **Everything here arrives from a browser, so none of it is trusted.** A metric name that is not
96
+ * one of the five, a value that is not finite, or a fragment id that is not registered is dropped
97
+ * rather than forwarded — a telemetry endpoint that relays whatever it is posted is an open
98
+ * ingestion API for anyone who can reach it, and the sink downstream would be the thing paying for
99
+ * that. `known` is the registry's fragment ids; pass an empty set to accept none.
100
+ */
101
+ export declare function parseVitalsBeacon(body: unknown, known: ReadonlySet<string>, now?: number): WebVitalEvent[];
102
+ /**
103
+ * The browser-side collector, as a source string.
104
+ *
105
+ * Hand-written rather than pulling in `web-vitals`: the library is excellent and mostly solves a
106
+ * problem this does not have — cross-browser normalization for metrics whose polyfills matter on
107
+ * browsers that cannot run Braid's realms anyway. What is left is four `PerformanceObserver`
108
+ * registrations, and shipping them inline keeps this dependency-free and keeps the injected script
109
+ * small enough that measuring a page does not measurably change it.
110
+ *
111
+ * The part that is not boilerplate is {@link ownerOf}: attributing an element to the fragment that
112
+ * rendered it means climbing *out of shadow roots*, since a pierced fragment's DOM lives inside a
113
+ * declarative shadow root and `closest()` stops at that boundary.
114
+ */
115
+ export declare function vitalsCollectorScript(endpoint: string, sampleRate: number): string;