@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/src/gateway.js ADDED
@@ -0,0 +1,735 @@
1
+ import { BRAID_ADAPTER_META, BRAID_ADAPTER_OPTIONS_META, BRAID_FRAGMENT_ID_HEADER, BRAID_PROTOCOL_META, BRAID_PROTOCOL_VERSION, braidFragmentUrl, parseBraidPathname, BRAID_SERVICE_WORKER_PATH, BRAID_VITALS_BEACON_PATH, BRAID_VITALS_SCRIPT_PATH, } from './protocol.js';
2
+ import { canFetch, canList, hasAccessRules, Registry, } from './registry.js';
3
+ import { createDiscoveryHandler } from './discovery.js';
4
+ import { createBreaker } from './breaker.js';
5
+ import { createSingleFlight, singleFlightKey } from './single-flight.js';
6
+ import { parseVitalsBeacon, vitalsCollectorScript, } from './telemetry.js';
7
+ import { cspNonceOf, pierceShellHtml, prepareFragmentHtml } from './rewriter/transforms.js';
8
+ export function createGateway(options) {
9
+ const registry = new Registry(options.registry);
10
+ const mode = options.mode ?? 'development';
11
+ const pierceCacheControl = options.pierceCacheControl ?? 'private';
12
+ const additionalHeaders = options.additionalHeaders ?? {};
13
+ const trustForwardedHeaders = options.trustForwardedHeaders ?? false;
14
+ const serviceWorker = options.serviceWorker
15
+ ? (options.serviceWorker === true ? {} : options.serviceWorker)
16
+ : null;
17
+ const isDevelopment = mode === 'development';
18
+ const telemetry = options.telemetry ?? null;
19
+ const singleFlight = options.coalesceFragmentFetches === false ? null : createSingleFlight();
20
+ const breaker = options.breaker
21
+ ? createBreaker(options.breaker === true ? {} : options.breaker, (transition) =>
22
+ // Routed through the same hook as everything else: a circuit opening is the single most
23
+ // useful thing this gateway can tell an operator, and it should not need its own sink.
24
+ emit({
25
+ kind: 'breaker',
26
+ fragmentId: transition.fragmentId,
27
+ from: transition.from,
28
+ to: transition.to,
29
+ failures: transition.failures,
30
+ at: Date.now(),
31
+ }))
32
+ : null;
33
+ const vitalsEnabled = telemetry?.webVitals === true;
34
+ let telemetryBroken = false;
35
+ /**
36
+ * Emits a telemetry event, and survives a sink that throws.
37
+ *
38
+ * `observe` deliberately does not do this — a broken analysis hook should fail loudly, because
39
+ * its data is used to make decisions and silently recording nothing is worse than an error. This
40
+ * hook has the opposite risk profile: it is meant to be left on in production, in front of every
41
+ * request, so a bad sink taking the site down would make the safe choice "leave telemetry off".
42
+ * Reported once, then muted, so a sink failing per-request cannot flood the log either.
43
+ */
44
+ function emit(event) {
45
+ if (!telemetry || telemetryBroken)
46
+ return;
47
+ try {
48
+ telemetry.on(event);
49
+ }
50
+ catch (error) {
51
+ telemetryBroken = true;
52
+ console.error('[braid-gateway] telemetry hook threw; telemetry disabled for this process', error);
53
+ }
54
+ }
55
+ /**
56
+ * Resolves the caller, but only when some manifest actually restricts something — a fully
57
+ * public registry never pays for session lookup on every asset request.
58
+ */
59
+ const resolvePrincipal = async (request) => options.principal ? ((await options.principal(request)) ?? undefined) : undefined;
60
+ const discovery = createDiscoveryHandler(registry, options.discovery, mode, resolvePrincipal);
61
+ return {
62
+ async handle(request, next) {
63
+ const requestUrl = new URL(request.url);
64
+ if (discovery?.owns(requestUrl.pathname)) {
65
+ return discovery.handle(request, requestUrl);
66
+ }
67
+ if (serviceWorker && requestUrl.pathname === BRAID_SERVICE_WORKER_PATH) {
68
+ return serviceWorkerResponse(serviceWorker);
69
+ }
70
+ if (vitalsEnabled && requestUrl.pathname === BRAID_VITALS_SCRIPT_PATH) {
71
+ return new Response(vitalsCollectorScript(BRAID_VITALS_BEACON_PATH, telemetry?.sampleRate ?? 1), {
72
+ headers: {
73
+ 'content-type': 'text/javascript; charset=utf-8',
74
+ // Immutable: the script is generated from this gateway's config and changes only when
75
+ // that does, at which point the deployment restarts anyway.
76
+ 'cache-control': 'public, max-age=3600',
77
+ },
78
+ });
79
+ }
80
+ if (vitalsEnabled && requestUrl.pathname === BRAID_VITALS_BEACON_PATH) {
81
+ return handleVitalsBeacon(request);
82
+ }
83
+ const route = parseBraidPathname(requestUrl.pathname);
84
+ if (!route) {
85
+ return next ? handleShellRequest(request, requestUrl, next) : null;
86
+ }
87
+ /**
88
+ * Braid requests are routed to the addressed fragment exactly: an unknown fragment id is a
89
+ * 404 (never the app shell, and never a header-based fallback — removed by design).
90
+ */
91
+ const fragment = await registry.getFragment(route.fragmentId);
92
+ if (!fragment) {
93
+ // no protocol meta on purpose: the client's stub verification fails loudly with a named
94
+ // error instead of silently reframing this error document
95
+ return htmlResponse(`<!doctype html><title>Braid: unknown fragment</title>` +
96
+ (mode === 'development'
97
+ ? `<p>braid-gateway: no fragment with id "${escapeHtml(route.fragmentId)}" is registered.<br>` +
98
+ `Register a manifest for it in the gateway registry, and ensure @braidlabs/core and @braidlabs/gateway versions match.</p>`
99
+ : '<p>Unknown fragment</p>'), 404);
100
+ }
101
+ // a fragment may declare who is allowed to load it at all (public unless it says otherwise)
102
+ const denied = await authorizeFetch(request, fragment);
103
+ if (denied)
104
+ return denied;
105
+ switch (route.kind) {
106
+ /**
107
+ * The realm stub: the document the fragment's hidden iframe boots from. It exists so the
108
+ * realm has a real same-origin URL, which is what lets the client `replaceState` it to
109
+ * the fragment's route and make `location`/`history` truthful.
110
+ *
111
+ * Its `<base>` points into the *fragment* namespace, so relative subresource requests
112
+ * made from the fragment's JS context resolve to the fragment's own assets even after
113
+ * the route-url illusion is restored.
114
+ */
115
+ case 'realm':
116
+ return htmlResponse(`<!doctype html><title>Braid realm</title>` +
117
+ `<meta name="${BRAID_PROTOCOL_META}" content="${BRAID_PROTOCOL_VERSION}">` +
118
+ `<meta name="${BRAID_ADAPTER_META}" content="${escapeHtml(fragment.adapter)}">` +
119
+ adapterOptionsMeta(fragment) +
120
+ `<base href="${escapeHtml(braidFragmentUrl(fragment.id, route.pathname))}">`, 200, {
121
+ [BRAID_FRAGMENT_ID_HEADER]: fragment.id,
122
+ // identical for a given url, and now varies on nothing: safe to cache anywhere
123
+ 'Cache-Control': 'max-age=3600, public, stale-while-revalidate=31536000',
124
+ });
125
+ /**
126
+ * The fragment's document, prepared for the host page's DOM: singletons renamed, scripts
127
+ * neutralized, subresource URLs re-rooted into the fragment namespace. Exactly what
128
+ * piercing injects, for the client-boot path.
129
+ */
130
+ case 'document':
131
+ /**
132
+ * A fragment built from an entry module has no document to give: its adapter creates
133
+ * the UI itself. Answering "nothing here" is the truthful response, and keeps a widget
134
+ * that ships only a script from logging a 404 on every boot.
135
+ */
136
+ if (fragment.entry) {
137
+ return new Response(null, {
138
+ status: 204,
139
+ headers: { [BRAID_FRAGMENT_ID_HEADER]: fragment.id },
140
+ });
141
+ }
142
+ return forwardToFragment(request, requestUrl, route.pathname, fragment, { prepare: true });
143
+ /**
144
+ * The fragment's own endpoint — assets, data, anything it serves — forwarded with the
145
+ * prefix stripped so the endpoint sees the paths it would serve standalone.
146
+ */
147
+ case 'fragment':
148
+ return forwardToFragment(request, requestUrl, route.pathname, fragment);
149
+ }
150
+ },
151
+ async resolveUpgrade(request) {
152
+ const requestUrl = new URL(request.url);
153
+ const route = parseBraidPathname(requestUrl.pathname);
154
+ // only the fragment namespace carries live sockets; stubs and documents are plain GETs
155
+ if (!route || route.kind !== 'fragment')
156
+ return null;
157
+ const fragment = await registry.getFragment(route.fragmentId);
158
+ if (!fragment)
159
+ return null;
160
+ // a socket is a load like any other, so the same access rule applies
161
+ if (await authorizeFetch(request, fragment))
162
+ return null;
163
+ // a fetcher-function endpoint has no origin to dial
164
+ if (typeof fragment.endpoint !== 'string')
165
+ return null;
166
+ const strippedUrl = new URL(requestUrl);
167
+ strippedUrl.pathname = route.pathname;
168
+ try {
169
+ return { fragmentId: fragment.id, target: resolveEndpointUrl(fragment.endpoint, strippedUrl, fragment.id) };
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ },
175
+ };
176
+ /**
177
+ * Applies a fragment's `access.fetch` rule, if it declares one.
178
+ *
179
+ * A caller who may not even *list* the fragment gets a 404: to them it does not exist, and
180
+ * saying otherwise would turn the namespace into an inventory of what they cannot reach. A
181
+ * caller who may list it but not load it gets an honest 403.
182
+ *
183
+ * Both rules are public by default, so a registry that declares no `access` never lands here.
184
+ */
185
+ async function authorizeFetch(request, fragment) {
186
+ if (isDevelopment || !hasAccessRules(fragment))
187
+ return null;
188
+ const principal = await resolvePrincipal(request);
189
+ if (canFetch(fragment, principal))
190
+ return null;
191
+ if (!canList(fragment, principal)) {
192
+ return htmlResponse(`<!doctype html><title>Braid: unknown fragment</title><p>Unknown fragment</p>`, 404);
193
+ }
194
+ return htmlResponse(`<!doctype html><title>Braid: forbidden</title><p>You do not have access to this fragment.</p>`, 403, { [BRAID_FRAGMENT_ID_HEADER]: fragment.id });
195
+ }
196
+ async function forwardToFragment(request, requestUrl, strippedPathname, fragment, options = {}) {
197
+ const result = await fetchFragment(request, requestUrl, `${strippedPathname}${requestUrl.search}`, fragment);
198
+ if (!result.ok && result.outOfScope) {
199
+ console.warn(String(result.error));
200
+ return htmlResponse(mode === 'development'
201
+ ? `<p>braid-gateway: that path is outside the endpoint declared by fragment "${escapeHtml(fragment.id)}".</p>`
202
+ : '<p>Not found</p>', 404, { [BRAID_FRAGMENT_ID_HEADER]: fragment.id });
203
+ }
204
+ if (!result.ok) {
205
+ return htmlResponse(mode === 'development'
206
+ ? `<p>braid-gateway: ${describeFragmentFailure(fragment, result)}.<br>` +
207
+ `Endpoint: ${escapeHtml(describeEndpoint(fragment))}<br>` +
208
+ `Error: ${escapeHtml(String(result.error))}</p>`
209
+ : '<p>There was a problem fulfilling your request.</p>', result.timedOut ? 504 : 502, { [BRAID_FRAGMENT_ID_HEADER]: fragment.id });
210
+ }
211
+ /**
212
+ * A fragment *document* gets exactly the preparation pierced content gets. Without it the
213
+ * same fragment would behave differently depending on whether it was server-rendered into
214
+ * the page or fetched by the slot — its relative asset URLs would resolve against the host
215
+ * page, and its scripts would arrive live in the host realm.
216
+ */
217
+ const prepare = options.prepare && result.response.headers.get('content-type')?.toLowerCase().includes('text/html');
218
+ const body = prepare && result.response.body
219
+ ? prepareFragmentHtml(result.response.body, { fragmentId: fragment.id })
220
+ : result.response.body;
221
+ const isNullBody = result.response.status === 204 ||
222
+ result.response.status === 205 ||
223
+ result.response.status === 304 ||
224
+ (result.response.status >= 100 && result.response.status < 200);
225
+ const forwarded = new Response(isNullBody ? null : body, result.response);
226
+ forwarded.headers.append(BRAID_FRAGMENT_ID_HEADER, fragment.id);
227
+ if (prepare) {
228
+ // the body was transformed, so any length the endpoint declared no longer describes it
229
+ forwarded.headers.delete('content-length');
230
+ }
231
+ return forwarded;
232
+ }
233
+ /**
234
+ * Fetches from a fragment's endpoint. The namespace prefix is already stripped, so the
235
+ * endpoint sees the same path it would serve standalone.
236
+ */
237
+ async function fetchFragment(request, requestUrl,
238
+ /** The path on the fragment's own endpoint, query included. */
239
+ path, fragment,
240
+ /** What this fetch is for. Only used to label telemetry. */
241
+ phase = 'namespace') {
242
+ const { endpoint } = fragment;
243
+ // The caller composes the whole path, query included. Whether the page's query belongs on this
244
+ // request is a question only the caller can answer — a namespace request forwards it, a bound
245
+ // fragment renders it, and to a widget the host's `?tab=` means nothing at all.
246
+ const strippedUrl = new URL(path, requestUrl);
247
+ let fragmentRequestUrl;
248
+ let fragmentFetch;
249
+ if (typeof endpoint === 'function') {
250
+ fragmentRequestUrl = strippedUrl;
251
+ fragmentFetch = endpoint;
252
+ }
253
+ else {
254
+ try {
255
+ fragmentRequestUrl = resolveEndpointUrl(endpoint, strippedUrl, fragment.id);
256
+ }
257
+ catch (error) {
258
+ return { ok: false, error, timedOut: false, outOfScope: error instanceof EndpointScopeError };
259
+ }
260
+ fragmentFetch = globalThis.fetch;
261
+ }
262
+ const fragmentRequest = new Request(fragmentRequestUrl, request);
263
+ // Tell the fragment endpoint the protocol and host the *user* reached us on.
264
+ //
265
+ // These are overwritten, not forwarded: an incoming x-forwarded-host is client-controlled,
266
+ // and fragments routinely build absolute URLs from it. Set `trustForwardedHeaders` only
267
+ // when a proxy you control is the sole path to this gateway.
268
+ fragmentRequest.headers.set('x-forwarded-proto', (trustForwardedHeaders && request.headers.get('x-forwarded-proto')) || requestUrl.protocol.slice(0, -1));
269
+ fragmentRequest.headers.set('x-forwarded-host', (trustForwardedHeaders && request.headers.get('x-forwarded-host')) || requestUrl.host);
270
+ for (const [name, value] of Object.entries(additionalHeaders)) {
271
+ fragmentRequest.headers.set(name, value);
272
+ }
273
+ // the endpoint serves an embedded fragment, not a full document
274
+ fragmentRequest.headers.set('sec-fetch-dest', 'empty');
275
+ fragmentRequest.headers.set('x-braid-fragment-mode', 'embedded');
276
+ /**
277
+ * In development, present the request as coming from the endpoint's own origin.
278
+ *
279
+ * Module scripts are fetched in CORS mode, so the browser attaches the *host page's* origin
280
+ * to every one of a fragment's script requests. Dev servers (Vite, and anything else with
281
+ * cross-origin request protection) reject those with a 403, which shows up as a fragment
282
+ * that boots but renders nothing. The gateway is the origin-front here, so rewriting the
283
+ * header is truthful: from the endpoint's perspective the request did come from its origin.
284
+ *
285
+ * Not done in production, where an endpoint may legitimately want the real origin.
286
+ */
287
+ if (isDevelopment && typeof endpoint === 'string') {
288
+ fragmentRequest.headers.set('origin', new URL(endpoint).origin);
289
+ fragmentRequest.headers.delete('referer');
290
+ }
291
+ // a document request carries validators for the *shell*; they mean nothing to the fragment
292
+ fragmentRequest.headers.delete('if-none-match');
293
+ fragmentRequest.headers.delete('if-modified-since');
294
+ // per-fragment timeout budget from the manifest
295
+ const timeoutSignal = AbortSignal.timeout(fragment.timeoutMs);
296
+ // Refuse before spending the budget. The caller's fallback handling is identical either way —
297
+ // this only changes how long it waits to get there.
298
+ if (breaker && !breaker.allows(fragment.id)) {
299
+ emit({
300
+ kind: 'fragment-fetch',
301
+ fragmentId: fragment.id,
302
+ phase,
303
+ outcome: 'shed',
304
+ durationMs: 0,
305
+ at: Date.now(),
306
+ });
307
+ return { ok: false, error: new BreakerOpenError(fragment.id), timedOut: false };
308
+ }
309
+ // Measured around the fetch only. The header work above is this process's own time, and
310
+ // attributing it to the fragment's endpoint would make every fragment look slower than it is.
311
+ const startedAt = performance.now();
312
+ try {
313
+ // don't follow redirects: they are sent all the way to the client, which can then decide
314
+ // to follow them or not (this keeps window.location correct in the fragment's realm)
315
+ // Coalesced only when the manifest declares no access rules. A gated fragment's response
316
+ // depends on an authorization decision this layer has already made per-request, and sharing
317
+ // one across two callers would share that decision with it.
318
+ const key = singleFlight && fragment.coalesce !== false && !hasAccessRules(fragment)
319
+ ? singleFlightKey(fragmentRequest, fragmentRequestUrl.href)
320
+ : null;
321
+ const response = key
322
+ ? await singleFlight.run(key, () => fragmentFetch(fragmentRequest, { redirect: 'manual', signal: timeoutSignal }))
323
+ : await fragmentFetch(fragmentRequest, { redirect: 'manual', signal: timeoutSignal });
324
+ // A 5xx is a reachable endpoint, but it is not a working one — a fragment returning 500 on
325
+ // every request would otherwise hold the circuit closed forever and keep paying for it.
326
+ if (response.status >= 500)
327
+ breaker?.failed(fragment.id);
328
+ else
329
+ breaker?.succeeded(fragment.id);
330
+ emit({
331
+ kind: 'fragment-fetch',
332
+ fragmentId: fragment.id,
333
+ phase,
334
+ // A 5xx is a response, not a failed fetch. Reported as `ok` with its status rather than as
335
+ // an error, because the distinction the operator needs is "did we reach it" — and folding
336
+ // a reachable-but-broken endpoint into the same bucket as an unreachable one loses it.
337
+ outcome: 'ok',
338
+ status: response.status,
339
+ durationMs: performance.now() - startedAt,
340
+ at: Date.now(),
341
+ });
342
+ return { ok: true, response };
343
+ }
344
+ catch (error) {
345
+ breaker?.failed(fragment.id);
346
+ emit({
347
+ kind: 'fragment-fetch',
348
+ fragmentId: fragment.id,
349
+ phase,
350
+ outcome: timeoutSignal.aborted ? 'timeout' : 'error',
351
+ durationMs: performance.now() - startedAt,
352
+ at: Date.now(),
353
+ });
354
+ return { ok: false, error, timedOut: timeoutSignal.aborted };
355
+ }
356
+ }
357
+ /**
358
+ * Handles a request bound for the shell application.
359
+ *
360
+ * A document navigation to a URL some fragment declares in `pierce` is composed. Any *other*
361
+ * request to such a URL is passed through — but still gets `vary: sec-fetch-dest`, because
362
+ * the same URL now has two representations. Without it a shared cache can store the
363
+ * unpierced shell from a soft-navigation fetch and later serve it to a real navigation,
364
+ * silently dropping the fragment from the page.
365
+ *
366
+ * The unpierced representation is marked shared-cache-unsafe for the same reason the composed
367
+ * one is: it is the *other* half of the pair a cache could confuse. See
368
+ * {@link GatewayOptions.pierceCacheControl}.
369
+ */
370
+ async function handleShellRequest(request, requestUrl, next) {
371
+ if (request.method !== 'GET')
372
+ return null;
373
+ const matches = await registry.matchPierceRoutes(requestUrl.pathname);
374
+ const isDocument = isDocumentRequest(request);
375
+ // Observed before the early return below, so paths that compose *nothing* are recorded too.
376
+ // Only document requests: they are the population that could ever compose, and a soft
377
+ // navigation fetching the same URL is not a second page view.
378
+ if (options.observe && isDocument) {
379
+ options.observe({
380
+ pathname: requestUrl.pathname,
381
+ fragmentIds: matches.map((manifest) => manifest.id),
382
+ at: Date.now(),
383
+ });
384
+ }
385
+ if (matches.length === 0)
386
+ return null;
387
+ if (isDocument) {
388
+ // a fragment the caller may not load is simply not composed into their page; the slot is
389
+ // left for the client, which will get the same 403/404 and can render it as it sees fit
390
+ const permitted = [];
391
+ for (const fragment of matches) {
392
+ if (!(await authorizeFetch(request, fragment)))
393
+ permitted.push(fragment);
394
+ }
395
+ if (permitted.length > 0) {
396
+ return pierceDocument(request, requestUrl, next, permitted);
397
+ }
398
+ }
399
+ const shell = await next();
400
+ const isNullBody = shell.status === 204 ||
401
+ shell.status === 205 ||
402
+ shell.status === 304 ||
403
+ (shell.status >= 100 && shell.status < 200);
404
+ const passthrough = new Response(isNullBody ? null : shell.body, shell);
405
+ passthrough.headers.append('vary', 'sec-fetch-dest');
406
+ applyPierceCacheControl(passthrough.headers);
407
+ return passthrough;
408
+ }
409
+ /**
410
+ * Composes a document response: the shell, with every fragment that declares this page URL
411
+ * pierced into the slot that names it.
412
+ *
413
+ * The shell and all matching fragments are fetched concurrently, and the fragments' HTML is
414
+ * interleaved into the shell's stream as it arrives — so a fragment never serializes behind
415
+ * the shell, and the page paints with fragments already present.
416
+ */
417
+ async function pierceDocument(request, requestUrl, next, matches) {
418
+ const pagePath = `${requestUrl.pathname}${requestUrl.search}`;
419
+ const [shellResponse, ...fragmentResults] = await Promise.all([
420
+ next(),
421
+ // A bound fragment renders the page's own route, so the endpoint gets the page path. An
422
+ // unbound one is chrome: its content lives at one fixed path, and asking a notifications
423
+ // endpoint for `/billing/invoices` is a question it has no answer to.
424
+ ...matches.map((fragment) => fetchFragment(request, requestUrl, fragmentPath(fragment, requestUrl), fragment, 'pierce')),
425
+ ]);
426
+ const isShellNullBody = shellResponse.status === 204 ||
427
+ shellResponse.status === 205 ||
428
+ shellResponse.status === 304 ||
429
+ (shellResponse.status >= 100 && shellResponse.status < 200);
430
+ const shell = new Response(isShellNullBody ? null : shellResponse.body, shellResponse);
431
+ shell.headers.append('vary', 'sec-fetch-dest');
432
+ applyPierceCacheControl(shell.headers);
433
+ const isHtml = shell.headers.get('content-type')?.toLowerCase().includes('text/html');
434
+ if (!shell.ok || !isHtml || !shell.body) {
435
+ // nothing to pierce into: hand the shell back untouched and cancel the fragment bodies
436
+ await Promise.all(fragmentResults.map((result) => (result.ok ? result.response.body?.cancel() : undefined)));
437
+ return shell;
438
+ }
439
+ const targets = matches.map((fragment, index) => {
440
+ const result = fragmentResults[index];
441
+ const failed = !result.ok || !result.response.ok || !result.response.body;
442
+ if (!failed) {
443
+ return {
444
+ fragmentId: fragment.id,
445
+ content: prepareFragmentHtml(result.response.body, { fragmentId: fragment.id }),
446
+ ...(fragment.src === undefined ? {} : { src: fragment.src }),
447
+ };
448
+ }
449
+ const detail = !result.ok
450
+ ? describeFragmentFailure(fragment, result)
451
+ : `fragment "${fragment.id}" responded with HTTP ${result.response.status}`;
452
+ console.warn(`braid-gateway: not piercing ${pagePath} — ${detail}`);
453
+ if (result.ok)
454
+ void result.response.body?.cancel();
455
+ // `error-html` is the only fallback that renders something; `omit` and `placeholder`
456
+ // leave the slot empty, and the client runtime fetches the fragment itself — a failed
457
+ // pierce degrades to the client-side boot path rather than to a broken page
458
+ if (fragment.fallback === 'error-html') {
459
+ return {
460
+ fragmentId: fragment.id,
461
+ content: stringStream(mode === 'development'
462
+ ? `<braid-html><braid-body><p>braid-gateway: ${escapeHtml(detail)}</p></braid-body></braid-html>`
463
+ : '<braid-html><braid-body><p>This section is temporarily unavailable.</p></braid-body></braid-html>'),
464
+ };
465
+ }
466
+ return {
467
+ fragmentId: fragment.id,
468
+ content: null,
469
+ ...(fragment.fallback === 'placeholder' ? { fallbackReason: 'placeholder' } : {}),
470
+ };
471
+ });
472
+ const pierced = new Response(pierceShellHtml({
473
+ shell: shell.body,
474
+ fragments: targets,
475
+ // From the shell's own policy — see `cspNonceOf`. Without this, a strict CSP drops the
476
+ // slot layout rule and the collector silently, leaving a page that renders wrong with
477
+ // nothing in any server log to explain it.
478
+ nonce: cspNonceOf(shell.headers),
479
+ // `defer` rather than `async`: the collector registers buffered observers, so it does not
480
+ // race the paint it measures, and deferring keeps it off the parser's critical path.
481
+ ...(vitalsEnabled ? { headScript: `<script src="${BRAID_VITALS_SCRIPT_PATH}" defer></script>` } : {}),
482
+ }), shell);
483
+ // the body is transformed, so any length/encoding the shell declared no longer describes it
484
+ pierced.headers.delete('content-length');
485
+ pierced.headers.delete('content-encoding');
486
+ for (const target of targets) {
487
+ pierced.headers.append(BRAID_FRAGMENT_ID_HEADER, target.fragmentId);
488
+ }
489
+ return pierced;
490
+ }
491
+ /**
492
+ * The path this fragment's content is fetched from for a given page.
493
+ *
494
+ * The whole difference between a screen and a widget, in one expression.
495
+ */
496
+ function fragmentPath(fragment, requestUrl) {
497
+ // `src` verbatim, query and all: an unbound fragment's content lives at one address, and
498
+ // appending the page's query would give the widget a different cache key on every page it
499
+ // appears on while changing nothing about what it renders.
500
+ if (fragment.bound === false && fragment.src)
501
+ return fragment.src;
502
+ return `${requestUrl.pathname}${requestUrl.search}`;
503
+ }
504
+ /**
505
+ * The generated worker script.
506
+ *
507
+ * **Kept byte-stable across registry publishes**, deliberately. The tempting move is to bake the
508
+ * pinned snapshot id and the fragment ids into it, since this gateway knows them — but then the
509
+ * script changes on every publish, and every change is a worker update with its own waiting and
510
+ * activation lifecycle. Configuration churn must not become worker churn, so anything that varies
511
+ * with the registry is fetched by the worker at runtime instead.
512
+ */
513
+ function serviceWorkerResponse(config) {
514
+ const module = config.module ?? '/braid-sw.js';
515
+ const setup = {
516
+ ...(config.buildId === undefined ? {} : { buildId: config.buildId }),
517
+ ...(config.precache === undefined ? {} : { precache: [...config.precache] }),
518
+ };
519
+ const script = `// Generated by @braidlabs/gateway. Serves the Braid namespace only.\n` +
520
+ `import { setupBraidWorker } from ${JSON.stringify(module)};\n` +
521
+ `setupBraidWorker(${JSON.stringify(setup)});\n`;
522
+ return new Response(script, {
523
+ headers: {
524
+ 'content-type': 'text/javascript; charset=utf-8',
525
+ // The whole reason this lives on the gateway: without it the worker's scope is capped at
526
+ // /__braid/, where it could serve fragment assets but not the shell's own.
527
+ 'service-worker-allowed': config.scope ?? '/',
528
+ // A worker script must not be served stale — the browser's update check is the only thing
529
+ // that ever replaces it.
530
+ 'cache-control': 'no-cache',
531
+ },
532
+ });
533
+ }
534
+ /**
535
+ * Receives a vitals beacon.
536
+ *
537
+ * Always answers 204, including for a body it rejected. The browser cannot act on an error here
538
+ * — `sendBeacon` fires as the page unloads and nothing is listening for the response — so a
539
+ * status code would only be a signal to whoever is probing the endpoint about what it accepts.
540
+ */
541
+ async function handleVitalsBeacon(request) {
542
+ if (request.method !== 'POST')
543
+ return new Response(null, { status: 405 });
544
+ try {
545
+ const body = await request.json();
546
+ const known = new Set((await registry.listFragments()).map((fragment) => fragment.id));
547
+ for (const event of parseVitalsBeacon(body, known))
548
+ emit(event);
549
+ }
550
+ catch {
551
+ // A malformed beacon is not worth a log line: it is either a truncated unload or someone
552
+ // poking the endpoint, and neither is actionable.
553
+ }
554
+ return new Response(null, { status: 204 });
555
+ }
556
+ function describeFragmentFailure(fragment, result) {
557
+ if (result.timedOut)
558
+ return `fragment "${fragment.id}" exceeded its ${fragment.timeoutMs}ms timeout budget`;
559
+ if (breaker?.stateOf(fragment.id) === 'open') {
560
+ return `fragment "${fragment.id}" has an open circuit after repeated failures; serving its fallback without attempting a fetch`;
561
+ }
562
+ return `fetching fragment "${fragment.id}" failed`;
563
+ }
564
+ function describeEndpoint(fragment) {
565
+ return typeof fragment.endpoint === 'function' ? '[fetcher function]' : String(fragment.endpoint);
566
+ }
567
+ /**
568
+ * Keeps a pierce-matched page URL out of shared caches, unless the app opted out.
569
+ *
570
+ * Rewrites rather than replaces: an app's own `max-age`, `no-store`, or
571
+ * `stale-while-revalidate` is its business — only the *shared*-cacheability directives are
572
+ * touched, because those are the ones that can hand one representation of this URL to a
573
+ * request that asked for the other.
574
+ */
575
+ function applyPierceCacheControl(headers) {
576
+ if (pierceCacheControl === 'preserve')
577
+ return;
578
+ const directives = (headers.get('cache-control') ?? '')
579
+ .split(',')
580
+ .map((directive) => directive.trim())
581
+ .filter((directive) => directive && !/^(public|s-maxage=.*|proxy-revalidate)$/i.test(directive));
582
+ if (!directives.some((directive) => /^(private|no-store)$/i.test(directive))) {
583
+ directives.unshift('private');
584
+ }
585
+ headers.set('cache-control', directives.join(', '));
586
+ }
587
+ /**
588
+ * A gateway-authored response inside a braid namespace.
589
+ *
590
+ * No `Vary` here: since realm stubs and prepared documents have their own paths, every braid
591
+ * URL has exactly one representation and caches on URL alone.
592
+ */
593
+ function htmlResponse(body, status, headers = {}) {
594
+ return new Response(body, {
595
+ status,
596
+ headers: {
597
+ 'Content-Type': 'text/html;charset=UTF-8',
598
+ ...headers,
599
+ },
600
+ });
601
+ }
602
+ }
603
+ /**
604
+ * Wraps a gateway as web middleware: braid requests are handled — including document requests
605
+ * that pierce fragments into the shell — and everything else goes to the shell via `next()`.
606
+ *
607
+ * `next` is memoized, so the shell application runs at most once per request no matter how the
608
+ * gateway and the caller interleave.
609
+ */
610
+ export function toWebMiddleware(gateway) {
611
+ return async (request, next) => {
612
+ const shellOnce = once(next);
613
+ return (await gateway.handle(request, shellOnce)) ?? shellOnce();
614
+ };
615
+ }
616
+ /**
617
+ * Wraps a fetch handler — the shell application as `(Request) => Response` — with the gateway.
618
+ *
619
+ * This is the binding for every web-standard runtime: Cloudflare Workers, Deno, Bun, and
620
+ * h3/Nitro (via `toWebHandler(app)`). It is also the only in-process way to pierce on those
621
+ * runtimes, because piercing needs to *read* the shell's response, which a middleware chain
622
+ * with no return value cannot give it.
623
+ *
624
+ * ```ts
625
+ * // h3 / Nitro
626
+ * import { toWebHandler } from 'h3';
627
+ * export default toFetchHandler(gateway, toWebHandler(app));
628
+ * ```
629
+ */
630
+ export function toFetchHandler(gateway, appHandler) {
631
+ return async (request) => {
632
+ const shellOnce = once(() => Promise.resolve(appHandler(request)));
633
+ return (await gateway.handle(request, shellOnce)) ?? shellOnce();
634
+ };
635
+ }
636
+ /** Memoizes an async thunk so the wrapped work happens at most once. */
637
+ function once(fn) {
638
+ let pending;
639
+ return () => (pending ??= fn());
640
+ }
641
+ /**
642
+ * Whether a request is a top-level document navigation, which is what piercing composes.
643
+ *
644
+ * `sec-fetch-dest` is the reliable signal in browsers; the `accept` heuristic covers clients
645
+ * that don't send fetch metadata (curl, some proxies, tests).
646
+ */
647
+ function isDocumentRequest(request) {
648
+ if (request.method !== 'GET')
649
+ return false;
650
+ const destination = request.headers.get('sec-fetch-dest');
651
+ if (destination)
652
+ return destination === 'document';
653
+ return request.headers.get('accept')?.includes('text/html') ?? false;
654
+ }
655
+ /**
656
+ * Resolves a namespace-stripped request against a fragment's endpoint, **within the endpoint's
657
+ * own path**.
658
+ *
659
+ * `new URL('/admin', 'https://internal/apps/billing/')` yields `https://internal/admin`: an
660
+ * absolute path replaces the endpoint's path entirely. Left alone, that turns the gateway into
661
+ * a proxy for the endpoint host's whole origin rather than the subtree the manifest declared.
662
+ * So the endpoint's path is treated as a prefix.
663
+ *
664
+ * The containment check afterwards is belt-and-braces: dot segments, including percent-encoded
665
+ * ones (`%2e%2e`), are already normalized when the incoming request URL is parsed, which takes
666
+ * such a request out of the fragment namespace entirely before it ever reaches here. The check
667
+ * costs nothing and does not assume every runtime normalizes identically.
668
+ */
669
+ export function resolveEndpointUrl(endpoint, strippedUrl, fragmentId) {
670
+ const endpointUrl = new URL(endpoint);
671
+ const basePath = endpointUrl.pathname.endsWith('/') ? endpointUrl.pathname.slice(0, -1) : endpointUrl.pathname;
672
+ const resolved = new URL(`${basePath}${strippedUrl.pathname}${strippedUrl.search}`, endpointUrl.origin);
673
+ if (basePath && resolved.pathname !== basePath && !resolved.pathname.startsWith(`${basePath}/`)) {
674
+ throw new EndpointScopeError(`braid-gateway: a request for fragment "${fragmentId}" resolved to "${resolved.pathname}", outside its ` +
675
+ `endpoint path "${basePath}/" — refusing to forward it`);
676
+ }
677
+ return resolved;
678
+ }
679
+ /** Thrown when a namespace request would reach outside its fragment endpoint's declared path. */
680
+ class EndpointScopeError extends Error {
681
+ }
682
+ /**
683
+ * The breaker refused a request without making it.
684
+ *
685
+ * A distinct type so the failure description can say the circuit is open rather than blame the
686
+ * endpoint for a request it never received — an operator reading "fetching fragment X failed"
687
+ * about a fetch that never happened would go and check the wrong system.
688
+ */
689
+ class BreakerOpenError extends Error {
690
+ constructor(fragmentId) {
691
+ super(`fragment "${fragmentId}" has an open circuit; not attempting a fetch`);
692
+ this.name = 'BreakerOpenError';
693
+ }
694
+ }
695
+ function stringStream(content) {
696
+ return new ReadableStream({
697
+ start(controller) {
698
+ controller.enqueue(new TextEncoder().encode(content));
699
+ controller.close();
700
+ },
701
+ });
702
+ }
703
+ /**
704
+ * The manifest fields an adapter needs, serialized onto the realm stub.
705
+ *
706
+ * Only fields that mean something to *some* adapter travel here — the runtime itself never reads
707
+ * them. Emitted only when there is something to say, so a compat fragment's stub is unchanged.
708
+ */
709
+ function adapterOptionsMeta(fragment) {
710
+ const options = {};
711
+ // `entry` is a path on the fragment's *own* origin, so it is re-rooted into the fragment's
712
+ // namespace exactly as the subresource URLs in its HTML are — a manifest never has to know
713
+ // the gateway's URL layout. URLs with a scheme are somebody else's origin; left alone.
714
+ if (fragment.entry) {
715
+ options['entry'] = /^[a-z][a-z0-9+.-]*:|^\/\//i.test(fragment.entry)
716
+ ? fragment.entry
717
+ : braidFragmentUrl(fragment.id, fragment.entry.startsWith('/') ? fragment.entry : `/${fragment.entry}`);
718
+ }
719
+ if (fragment.element)
720
+ options['element'] = fragment.element;
721
+ if (fragment.events)
722
+ options['events'] = Object.keys(fragment.events);
723
+ if (Object.keys(options).length === 0)
724
+ return '';
725
+ return `<meta name="${BRAID_ADAPTER_OPTIONS_META}" content="${escapeHtml(JSON.stringify(options))}">`;
726
+ }
727
+ function escapeHtml(value) {
728
+ return value
729
+ .replaceAll('&', '&amp;')
730
+ .replaceAll('<', '&lt;')
731
+ .replaceAll('>', '&gt;')
732
+ .replaceAll('"', '&quot;')
733
+ .replaceAll("'", '&#39;');
734
+ }
735
+ //# sourceMappingURL=gateway.js.map