@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
|
@@ -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('&', '&').replaceAll('"', '"').replaceAll('<', '<');
|
|
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;
|