@braidlabs/gateway 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +464 -0
- package/package.json +44 -0
- package/src/appd.d.ts +130 -0
- package/src/appd.js +93 -0
- package/src/appd.js.map +1 -0
- package/src/breaker.d.ts +51 -0
- package/src/breaker.js +96 -0
- package/src/breaker.js.map +1 -0
- package/src/discovery.d.ts +91 -0
- package/src/discovery.js +127 -0
- package/src/discovery.js.map +1 -0
- package/src/gateway.d.ts +241 -0
- package/src/gateway.js +735 -0
- package/src/gateway.js.map +1 -0
- package/src/index.d.ts +36 -0
- package/src/index.js +27 -0
- package/src/index.js.map +1 -0
- package/src/node.d.ts +35 -0
- package/src/node.js +252 -0
- package/src/node.js.map +1 -0
- package/src/protocol.d.ts +79 -0
- package/src/protocol.js +89 -0
- package/src/protocol.js.map +1 -0
- package/src/registry.d.ts +184 -0
- package/src/registry.js +187 -0
- package/src/registry.js.map +1 -0
- package/src/rewriter/html-rewrite-stream.d.ts +70 -0
- package/src/rewriter/html-rewrite-stream.js +483 -0
- package/src/rewriter/html-rewrite-stream.js.map +1 -0
- package/src/rewriter/transforms.d.ts +107 -0
- package/src/rewriter/transforms.js +312 -0
- package/src/rewriter/transforms.js.map +1 -0
- package/src/single-flight.d.ts +53 -0
- package/src/single-flight.js +97 -0
- package/src/single-flight.js.map +1 -0
- package/src/telemetry.d.ts +115 -0
- package/src/telemetry.js +177 -0
- package/src/telemetry.js.map +1 -0
package/src/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('&', '&')
|
|
730
|
+
.replaceAll('<', '<')
|
|
731
|
+
.replaceAll('>', '>')
|
|
732
|
+
.replaceAll('"', '"')
|
|
733
|
+
.replaceAll("'", ''');
|
|
734
|
+
}
|
|
735
|
+
//# sourceMappingURL=gateway.js.map
|