@braidlabs/gateway 0.7.1 → 0.8.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 +28 -0
- package/package.json +1 -1
- package/src/cookie-scope.js +4 -1
- package/src/cookie-scope.js.map +1 -1
- package/src/gateway.d.ts +36 -1
- package/src/gateway.js +211 -37
- package/src/gateway.js.map +1 -1
- package/src/index.d.ts +2 -0
- package/src/index.js +1 -0
- package/src/index.js.map +1 -1
- package/src/node.d.ts +2 -0
- package/src/node.js +8 -1
- package/src/node.js.map +1 -1
- package/src/preferences-file.d.ts +15 -0
- package/src/preferences-file.js +76 -0
- package/src/preferences-file.js.map +1 -0
- package/src/preferences.d.ts +73 -0
- package/src/preferences.js +206 -0
- package/src/preferences.js.map +1 -0
- package/src/protocol.d.ts +2 -0
- package/src/protocol.js +11 -5
- package/src/protocol.js.map +1 -1
- package/src/registry.d.ts +20 -1
- package/src/registry.js +4 -0
- package/src/registry.js.map +1 -1
- package/src/rewriter/transforms.d.ts +9 -2
- package/src/rewriter/transforms.js +39 -14
- package/src/rewriter/transforms.js.map +1 -1
- package/src/speculation.d.ts +56 -0
- package/src/speculation.js +85 -0
- package/src/speculation.js.map +1 -0
package/README.md
CHANGED
|
@@ -303,6 +303,34 @@ It only works end to end once the fragment can see the user: `forwardCredentials
|
|
|
303
303
|
header via `additionalHeaders`. The gateway warns when a fragment opts in and neither is set — at startup for an inline registry, on the
|
|
304
304
|
first redirect for a URL or loader registry.
|
|
305
305
|
|
|
306
|
+
## Per-user preferences (opt-in)
|
|
307
|
+
|
|
308
|
+
`preferences` serves each signed-in user's own JSON entries at `/__braid/preferences/*`. It is what
|
|
309
|
+
`serverStore()` from `@braidlabs/console/workspace` talks to, so preferences, app settings and
|
|
310
|
+
saved layouts follow the user to any browser.
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
import { filePreferenceBackend } from '@braidlabs/gateway/node';
|
|
314
|
+
|
|
315
|
+
createGateway({
|
|
316
|
+
registry,
|
|
317
|
+
principal: async (request) => {
|
|
318
|
+
const session = await sessions.read(request);
|
|
319
|
+
return session && { subject: session.userId, roles: session.roles };
|
|
320
|
+
},
|
|
321
|
+
preferences: { backend: filePreferenceBackend({ directory: '/var/lib/braid/preferences' }) },
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
- Entries are keyed by the principal's `subject`, which must come from your session and be
|
|
326
|
+
globally unique (`${iss}|${sub}` across identity providers). No subject, 401.
|
|
327
|
+
- Same-origin only: a cross-site `Sec-Fetch-Site`, or a foreign `Origin` when that is absent, gets
|
|
328
|
+
403, reads included.
|
|
329
|
+
- Bounded: 64 KiB a value, 1000 entries and 1 MiB a user, enforced by the backend in the same
|
|
330
|
+
write (413 past any of them). Every answer is `private, no-store`.
|
|
331
|
+
- Backends: `memoryPreferenceBackend()` for tests, `filePreferenceBackend()` for one Node process,
|
|
332
|
+
or your own `PreferenceBackend` over a database behind several instances. Full details: [gateway docs](../../docs/packages/gateway.md#per-user-preferences-opt-in).
|
|
333
|
+
|
|
306
334
|
## Discovery endpoint (optional)
|
|
307
335
|
|
|
308
336
|
For shells that build their UI from the registry rather than hard-coding slot names — a launcher,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@braidlabs/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Braid gateway — fetch-native origin-front middleware that routes fragment traffic by exact id under the reserved /__braid/frag/ namespace, loads fragment manifests, and passes everything else through to your existing app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"micro-frontends",
|
package/src/cookie-scope.js
CHANGED
|
@@ -34,9 +34,12 @@ export function storagePartitionOf(fragment) {
|
|
|
34
34
|
if (grant === 'partitioned')
|
|
35
35
|
return fragment.id;
|
|
36
36
|
if (grant && typeof grant === 'object') {
|
|
37
|
-
const partition = grant
|
|
37
|
+
const { partition, databases } = grant;
|
|
38
38
|
if (typeof partition === 'string' && partition !== '')
|
|
39
39
|
return partition;
|
|
40
|
+
// as on the client: databases of its own imply a partition named for the fragment
|
|
41
|
+
if (databases === true && partition === undefined)
|
|
42
|
+
return fragment.id;
|
|
40
43
|
}
|
|
41
44
|
return null;
|
|
42
45
|
}
|
package/src/cookie-scope.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cookie-scope.js","sourceRoot":"","sources":["../../../../libs/braid-gateway/src/cookie-scope.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,UAAU,GAAG,QAAQ,CAAC;AAC5B,2FAA2F;AAC3F,MAAM,gBAAgB,GAAG,CAAC,SAAS,EAAE,WAAW,CAAU,CAAC;AAE3D;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,QAAmE;IACpG,IAAI,CAAC,QAAQ,CAAC,OAAO,IAAI,QAAQ,CAAC,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC7D,MAAM,KAAK,GAAY,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IACtD,IAAI,KAAK,KAAK,aAAa;QAAE,OAAO,QAAQ,CAAC,EAAE,CAAC;IAChD,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACvC,MAAM,SAAS,
|
|
1
|
+
{"version":3,"file":"cookie-scope.js","sourceRoot":"","sources":["../../../../libs/braid-gateway/src/cookie-scope.ts"],"names":[],"mappings":"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"}
|
package/src/gateway.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Principal, RegistryRefresh, RegistrySource, FragmentManifest } from './registry.js';
|
|
2
2
|
import { DiscoveryOptions } from './discovery.js';
|
|
3
|
+
import { type PreferencesOptions } from './preferences.js';
|
|
3
4
|
import { type BreakerOptions } from './breaker.js';
|
|
4
5
|
import { type BaggageOptions } from './baggage.js';
|
|
5
6
|
import { type TelemetryOptions } from './telemetry.js';
|
|
@@ -29,7 +30,13 @@ export interface GatewayOptions {
|
|
|
29
30
|
* for document requests (GET, `Sec-Fetch-Dest: document` or an HTML `Accept`).
|
|
30
31
|
*/
|
|
31
32
|
documents?: (request: Request, context: DocumentsContext) => Response | null | Promise<Response | null>;
|
|
32
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Defaults to 'production'.
|
|
35
|
+
*
|
|
36
|
+
* 'development' is for a gateway on your own machine: it **skips every `access` rule**, lists
|
|
37
|
+
* every fragment with its endpoint in discovery, sends verbose error bodies, and rewrites
|
|
38
|
+
* `Origin` to the endpoint's own. Never set it on a gateway other people can reach.
|
|
39
|
+
*/
|
|
33
40
|
mode?: 'production' | 'development';
|
|
34
41
|
/**
|
|
35
42
|
* Additional headers set on every request forwarded to a fragment endpoint.
|
|
@@ -92,6 +99,13 @@ export interface GatewayOptions {
|
|
|
92
99
|
* See {@link DiscoveryOptions}.
|
|
93
100
|
*/
|
|
94
101
|
discovery?: DiscoveryOptions;
|
|
102
|
+
/**
|
|
103
|
+
* Serves each signed-in user's preferences at `/__braid/preferences/*`, for the workspace's
|
|
104
|
+
* `serverStore` (`@braidlabs/console/workspace`): preferences, app settings and saved layouts. Entries are keyed by
|
|
105
|
+
* the `subject` your `principal` resolver returns; a request without one is refused with 401.
|
|
106
|
+
* Off unless configured. See {@link PreferencesOptions}.
|
|
107
|
+
*/
|
|
108
|
+
preferences?: PreferencesOptions;
|
|
95
109
|
/**
|
|
96
110
|
* Resolves who is asking, for manifests that declare `access` rules.
|
|
97
111
|
*
|
|
@@ -227,6 +241,17 @@ export interface GatewayOptions {
|
|
|
227
241
|
* on an origin it does not own.
|
|
228
242
|
*/
|
|
229
243
|
serviceWorker?: boolean | ServiceWorkerOptions;
|
|
244
|
+
/**
|
|
245
|
+
* Tell the browser which links it may fetch or render ahead of a click (Speculation Rules,
|
|
246
|
+
* Chromium), from the fragments whose manifest sets `speculate`.
|
|
247
|
+
*
|
|
248
|
+
* Every HTML document the gateway serves or passes through then carries
|
|
249
|
+
* `Speculation-Rules: "/__braid/speculation-rules.json"`. The header is the same for everyone,
|
|
250
|
+
* so a cached page stays shareable. The rules it points to are built per request from the
|
|
251
|
+
* registry: a fragment the caller may not fetch is left out, and when any fragment has access
|
|
252
|
+
* rules the response is private to the caller. Off by default.
|
|
253
|
+
*/
|
|
254
|
+
speculationRules?: boolean;
|
|
230
255
|
/**
|
|
231
256
|
* The path this gateway is mounted under, for a host that shares its domain and does not own the
|
|
232
257
|
* root (`/manage`). The reserved namespaces move under it — `/manage/__braid/frag/…` — and the
|
|
@@ -328,6 +353,13 @@ export interface BraidGateway {
|
|
|
328
353
|
* document requests are passed through and fragments boot client-side instead.
|
|
329
354
|
*/
|
|
330
355
|
handle(request: Request, next?: () => Promise<Response>): Promise<Response | null>;
|
|
356
|
+
/**
|
|
357
|
+
* Headers a binding adds to a page it passes to the app when `handle` returned null — the
|
|
358
|
+
* pointer to the speculation rules, when `speculationRules` is on — or null for none. Set on the
|
|
359
|
+
* way out rather than by reading the app's response, so a passed-through page streams exactly as
|
|
360
|
+
* it would without the gateway. Browsers ignore them on anything but HTML.
|
|
361
|
+
*/
|
|
362
|
+
pageHeaders(request: Request): Record<string, string> | null;
|
|
331
363
|
/**
|
|
332
364
|
* Resolves a websocket upgrade addressed to a fragment.
|
|
333
365
|
*
|
|
@@ -393,5 +425,8 @@ export declare function toFetchHandler(gateway: BraidGateway, appHandler: (reque
|
|
|
393
425
|
* ones (`%2e%2e`), are already normalized when the incoming request URL is parsed, which takes
|
|
394
426
|
* such a request out of the fragment namespace entirely before it ever reaches here. The check
|
|
395
427
|
* costs nothing and does not assume every runtime normalizes identically.
|
|
428
|
+
*
|
|
429
|
+
* What URL parsing does not normalize is a separator or dot segment hidden inside a segment;
|
|
430
|
+
* see {@link assertForwardablePath}, which runs first.
|
|
396
431
|
*/
|
|
397
432
|
export declare function resolveEndpointUrl(endpoint: string, strippedUrl: URL, fragmentId: string): URL;
|
package/src/gateway.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { BRAID_ADAPTER_META, BRAID_ADAPTER_OPTIONS_META, BRAID_FRAGMENT_ID_HEADER, BRAID_MAX_HOPS, BRAID_PROTOCOL_META, BRAID_PROTOCOL_VERSION, BRAID_REDIRECT_LOCATION_HEADER, BRAID_RETURN_URL_HEADER, braidFragmentUrl, parseBraidPathname, BRAID_SERVICE_WORKER_PATH, BRAID_VITALS_BEACON_PATH, BRAID_VITALS_SCRIPT_PATH, BRAID_VIA_HEADER, } from './protocol.js';
|
|
1
|
+
import { BRAID_ADAPTER_META, BRAID_ADAPTER_OPTIONS_META, BRAID_FRAGMENT_ID_HEADER, BRAID_MAX_HOPS, BRAID_PROTOCOL_META, BRAID_PROTOCOL_VERSION, BRAID_REDIRECT_LOCATION_HEADER, BRAID_RETURN_URL_HEADER, braidFragmentUrl, parseBraidPathname, BRAID_SERVICE_WORKER_PATH, BRAID_SPECULATION_RULES_PATH, BRAID_VITALS_BEACON_PATH, BRAID_VITALS_SCRIPT_PATH, BRAID_VIA_HEADER, } from './protocol.js';
|
|
2
2
|
import { canFetch, canList, hasAccessRules, isOriginSource, Registry, } from './registry.js';
|
|
3
3
|
import { createDiscoveryHandler } from './discovery.js';
|
|
4
|
+
import { createPreferencesHandler } from './preferences.js';
|
|
5
|
+
import { speculationRulesFor } from './speculation.js';
|
|
4
6
|
import { createBreaker } from './breaker.js';
|
|
5
7
|
import { createSingleFlight, singleFlightKey } from './single-flight.js';
|
|
6
8
|
import { scopeRequestCookies, scopeResponseCookies, scopeSetCookie, storagePartitionOf, withoutScopedCookies } from './cookie-scope.js';
|
|
@@ -33,7 +35,9 @@ export function createGateway(options) {
|
|
|
33
35
|
throw new Error(`braid-gateway: registryRefreshMs must be a positive number of milliseconds, got ${options.registryRefreshMs}`);
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
// Production unless asked: development skips access rules, so it must never be what a
|
|
39
|
+
// deployment gets for forgetting an option.
|
|
40
|
+
const mode = options.mode ?? 'production';
|
|
37
41
|
const pierceCacheControl = options.pierceCacheControl ?? 'private';
|
|
38
42
|
const realmPolicy = options.realmPolicy;
|
|
39
43
|
if (realmPolicy !== undefined && realmPolicy !== 'report' && realmPolicy !== 'enforce') {
|
|
@@ -147,7 +151,9 @@ export function createGateway(options) {
|
|
|
147
151
|
const unsupported = [
|
|
148
152
|
serviceWorker && 'serviceWorker',
|
|
149
153
|
options.discovery && 'discovery',
|
|
154
|
+
options.preferences && 'preferences',
|
|
150
155
|
vitalsEnabled && 'telemetry.webVitals',
|
|
156
|
+
options.speculationRules && 'speculationRules',
|
|
151
157
|
].filter(Boolean);
|
|
152
158
|
if (unsupported.length) {
|
|
153
159
|
throw new Error(`braid-gateway: basePath "${basePath}" is not supported together with ${unsupported.join(', ')} yet — ` +
|
|
@@ -259,8 +265,10 @@ export function createGateway(options) {
|
|
|
259
265
|
*/
|
|
260
266
|
const resolvePrincipal = async (request) => options.principal ? ((await options.principal(request)) ?? undefined) : undefined;
|
|
261
267
|
const discovery = createDiscoveryHandler(registry, options.discovery, mode, resolvePrincipal);
|
|
268
|
+
const preferences = createPreferencesHandler(options.preferences, resolvePrincipal);
|
|
262
269
|
return {
|
|
263
270
|
refreshRegistry,
|
|
271
|
+
pageHeaders,
|
|
264
272
|
close() {
|
|
265
273
|
closed = true;
|
|
266
274
|
clearTimeout(refreshTimer);
|
|
@@ -275,6 +283,9 @@ export function createGateway(options) {
|
|
|
275
283
|
if (discovery?.owns(requestUrl.pathname)) {
|
|
276
284
|
return discovery.handle(request, requestUrl);
|
|
277
285
|
}
|
|
286
|
+
if (preferences?.owns(requestUrl.pathname)) {
|
|
287
|
+
return preferences.handle(request, requestUrl);
|
|
288
|
+
}
|
|
278
289
|
if (serviceWorker && requestUrl.pathname === BRAID_SERVICE_WORKER_PATH) {
|
|
279
290
|
return serviceWorkerResponse(serviceWorker);
|
|
280
291
|
}
|
|
@@ -291,36 +302,14 @@ export function createGateway(options) {
|
|
|
291
302
|
if (vitalsEnabled && requestUrl.pathname === BRAID_VITALS_BEACON_PATH) {
|
|
292
303
|
return handleVitalsBeacon(request);
|
|
293
304
|
}
|
|
305
|
+
if (options.speculationRules && requestUrl.pathname === BRAID_SPECULATION_RULES_PATH) {
|
|
306
|
+
return speculationRulesResponse(request);
|
|
307
|
+
}
|
|
294
308
|
const route = braidRoute(requestUrl.pathname);
|
|
295
309
|
if (!route) {
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
const asGet = new Request(request.url, { method: 'GET', headers: request.headers });
|
|
300
|
-
const document = await options.documents(asGet, { manifests: await registry.current() });
|
|
301
|
-
if (document) {
|
|
302
|
-
const headers = new Headers(document.headers);
|
|
303
|
-
headers.append('vary', 'sec-fetch-dest');
|
|
304
|
-
applyPierceCacheControl(headers);
|
|
305
|
-
await document.body?.cancel();
|
|
306
|
-
return new Response(null, { status: document.status, headers });
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
const document = options.documents && isDocumentRequest(request)
|
|
310
|
-
? await options.documents(request, { manifests: await registry.current() })
|
|
311
|
-
: null;
|
|
312
|
-
if (document) {
|
|
313
|
-
// The defined document is the shell: pierced like an app's response when fragments match,
|
|
314
|
-
// and served as it is when none do.
|
|
315
|
-
const composed = await handleShellRequest(request, requestUrl, async () => document);
|
|
316
|
-
if (composed)
|
|
317
|
-
return composed;
|
|
318
|
-
const served = new Response(document.body, document);
|
|
319
|
-
served.headers.append('vary', 'sec-fetch-dest');
|
|
320
|
-
applyPierceCacheControl(served.headers);
|
|
321
|
-
return served;
|
|
322
|
-
}
|
|
323
|
-
return next ? handleShellRequest(request, requestUrl, next) : null;
|
|
310
|
+
// A page the gateway leaves to the app comes back null, untouched: the binding adds the
|
|
311
|
+
// page headers on its way through, without the gateway reading the app's response.
|
|
312
|
+
return withPageHeaders(request, await pageResponse(request, requestUrl, next));
|
|
324
313
|
}
|
|
325
314
|
/**
|
|
326
315
|
* Braid requests are routed to the addressed fragment exactly: an unknown fragment id is a
|
|
@@ -480,7 +469,7 @@ export function createGateway(options) {
|
|
|
480
469
|
if (!result.ok && result.outOfScope) {
|
|
481
470
|
console.warn(String(result.error));
|
|
482
471
|
return htmlResponse(mode === 'development'
|
|
483
|
-
? `<p>braid-gateway: that path is outside the endpoint declared by fragment "${escapeHtml(fragment.id)}".</p>`
|
|
472
|
+
? `<p>braid-gateway: that path is outside the endpoint declared by fragment "${escapeHtml(fragment.id)}", or hides a path separator or dot segment.</p>`
|
|
484
473
|
: '<p>Not found</p>', 404, { [BRAID_FRAGMENT_ID_HEADER]: fragment.id });
|
|
485
474
|
}
|
|
486
475
|
if (!result.ok) {
|
|
@@ -631,7 +620,19 @@ export function createGateway(options) {
|
|
|
631
620
|
// The caller composes the whole path, query included. Whether the page's query belongs on this
|
|
632
621
|
// request is a question only the caller can answer — a namespace request forwards it, a bound
|
|
633
622
|
// fragment renders it, and to a widget the host's `?tab=` means nothing at all.
|
|
634
|
-
|
|
623
|
+
// Assigned, never parsed against the request: as a string, a path starting `//` names another
|
|
624
|
+
// host, and a function endpoint would be handed a request for it.
|
|
625
|
+
const queryAt = path.indexOf('?');
|
|
626
|
+
const strippedUrl = new URL(requestUrl.origin);
|
|
627
|
+
strippedUrl.pathname = queryAt === -1 ? path : path.slice(0, queryAt);
|
|
628
|
+
strippedUrl.search = queryAt === -1 ? '' : path.slice(queryAt);
|
|
629
|
+
// every endpoint, a function too: it has no path to stay inside, and may still proxy or serve files
|
|
630
|
+
try {
|
|
631
|
+
assertForwardablePath(strippedUrl.pathname, fragment.id);
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
return { ok: false, error, timedOut: false, outOfScope: error instanceof EndpointScopeError };
|
|
635
|
+
}
|
|
635
636
|
let fragmentRequestUrl;
|
|
636
637
|
let fragmentFetch;
|
|
637
638
|
if (typeof endpoint === 'function') {
|
|
@@ -698,6 +699,9 @@ export function createGateway(options) {
|
|
|
698
699
|
// browser's `zstd` would come back decoded by some runtimes and not others (see `forwardable`)
|
|
699
700
|
if (fragmentFetch === globalThis.fetch)
|
|
700
701
|
fragmentRequest.headers.set('accept-encoding', [...FETCH_DECODED_CODINGS].join(', '));
|
|
702
|
+
// A browser (the gateway in a service worker) forbids the header and drops it without a word:
|
|
703
|
+
// the browser then picks the codings itself and decodes every one it picked.
|
|
704
|
+
const codingsPinned = fragmentRequest.headers.has('accept-encoding');
|
|
701
705
|
fragmentRequest.headers.set('x-braid-fragment-mode', 'embedded');
|
|
702
706
|
/**
|
|
703
707
|
* In development, present the request as coming from the endpoint's own origin.
|
|
@@ -769,7 +773,7 @@ export function createGateway(options) {
|
|
|
769
773
|
};
|
|
770
774
|
const sentResponse = key ? await singleFlight.run(key, send) : await send();
|
|
771
775
|
// a function endpoint hands over its own bytes; only the platform fetch decodes them
|
|
772
|
-
const response = forwardable(sentResponse, fragmentFetch === globalThis.fetch);
|
|
776
|
+
const response = forwardable(sentResponse, fragmentFetch === globalThis.fetch, codingsPinned);
|
|
773
777
|
// A 5xx is a reachable endpoint, but it is not a working one — a fragment returning 500 on
|
|
774
778
|
// every request would otherwise hold the circuit closed forever and keep paying for it.
|
|
775
779
|
if (response.status >= 500)
|
|
@@ -805,6 +809,83 @@ export function createGateway(options) {
|
|
|
805
809
|
return { ok: false, error, timedOut: timeoutSignal.aborted };
|
|
806
810
|
}
|
|
807
811
|
}
|
|
812
|
+
/** A request outside the Braid namespace: a page, composed or passed to the shell. */
|
|
813
|
+
async function pageResponse(request, requestUrl, next) {
|
|
814
|
+
// HEAD gets the same answer without the body, so a monitor or link checker in front of a
|
|
815
|
+
// gateway with no app behind it sees the page a browser would.
|
|
816
|
+
if (options.documents && request.method === 'HEAD') {
|
|
817
|
+
const asGet = new Request(request.url, { method: 'GET', headers: request.headers });
|
|
818
|
+
const document = await options.documents(asGet, { manifests: await registry.current() });
|
|
819
|
+
if (document) {
|
|
820
|
+
const headers = new Headers(document.headers);
|
|
821
|
+
headers.append('vary', 'sec-fetch-dest');
|
|
822
|
+
applyPierceCacheControl(headers);
|
|
823
|
+
await document.body?.cancel();
|
|
824
|
+
return new Response(null, { status: document.status, headers });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const document = options.documents && isDocumentRequest(request)
|
|
828
|
+
? await options.documents(request, { manifests: await registry.current() })
|
|
829
|
+
: null;
|
|
830
|
+
if (document) {
|
|
831
|
+
// The defined document is the shell: pierced like an app's response when fragments match,
|
|
832
|
+
// and served as it is when none do.
|
|
833
|
+
const composed = await handleShellRequest(request, requestUrl, async () => document);
|
|
834
|
+
if (composed)
|
|
835
|
+
return composed;
|
|
836
|
+
const served = new Response(document.body, document);
|
|
837
|
+
served.headers.append('vary', 'sec-fetch-dest');
|
|
838
|
+
applyPierceCacheControl(served.headers);
|
|
839
|
+
return served;
|
|
840
|
+
}
|
|
841
|
+
return next ? handleShellRequest(request, requestUrl, next) : null;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* The headers a page carries because of this gateway, for a browser navigation: today, the
|
|
845
|
+
* pointer to the speculation rules.
|
|
846
|
+
*
|
|
847
|
+
* Every page, not only composed ones: the page that *links* to a fragment's route is the one the
|
|
848
|
+
* browser speculates from, and it is usually not that route. The value is the same for every
|
|
849
|
+
* caller, so it never makes a page private, and a browser ignores it on anything but HTML.
|
|
850
|
+
*/
|
|
851
|
+
function pageHeaders(request) {
|
|
852
|
+
if (!options.speculationRules || !isNavigation(request))
|
|
853
|
+
return null;
|
|
854
|
+
return { 'speculation-rules': `"${BRAID_SPECULATION_RULES_PATH}"` };
|
|
855
|
+
}
|
|
856
|
+
/** A page the gateway produced, with {@link pageHeaders} on it. */
|
|
857
|
+
function withPageHeaders(request, page) {
|
|
858
|
+
const headers = page && !isOpaque(page) ? pageHeaders(request) : null;
|
|
859
|
+
return headers ? withHeaders(page, headers) : page;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The rules themselves: the pierce patterns of every fragment that asks for speculation, less
|
|
863
|
+
* those the caller may not list or fetch — as discovery and the namespace keep them out of sight.
|
|
864
|
+
*
|
|
865
|
+
* Private whenever the gateway can tell callers apart (a `principal` resolver) or any fragment
|
|
866
|
+
* has access rules: decided from configuration, so a shared cache never holds a list that a
|
|
867
|
+
* registry refresh has since made personal. Never stored in development, where nothing is filtered.
|
|
868
|
+
*/
|
|
869
|
+
async function speculationRulesResponse(request) {
|
|
870
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
871
|
+
return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
872
|
+
}
|
|
873
|
+
const manifests = await registry.listFragments();
|
|
874
|
+
const personal = !isDevelopment && (options.principal !== undefined || manifests.some(hasAccessRules));
|
|
875
|
+
const principal = personal ? await resolvePrincipal(request) : undefined;
|
|
876
|
+
const visible = manifests.filter((manifest) => isDevelopment || !hasAccessRules(manifest) || (canList(manifest, principal) && canFetch(manifest, principal)));
|
|
877
|
+
return new Response(request.method === 'HEAD' ? null : JSON.stringify(speculationRulesFor(visible)), {
|
|
878
|
+
headers: {
|
|
879
|
+
'content-type': 'application/speculationrules+json',
|
|
880
|
+
...(isDevelopment
|
|
881
|
+
? { 'cache-control': 'no-store' }
|
|
882
|
+
: personal
|
|
883
|
+
? { 'cache-control': 'private, no-store', vary: 'cookie, authorization' }
|
|
884
|
+
: // short: the registry it comes from refreshes on its own schedule
|
|
885
|
+
{ 'cache-control': 'public, max-age=60' }),
|
|
886
|
+
},
|
|
887
|
+
});
|
|
888
|
+
}
|
|
808
889
|
/**
|
|
809
890
|
* Handles a request bound for the shell application.
|
|
810
891
|
*
|
|
@@ -848,6 +929,8 @@ export function createGateway(options) {
|
|
|
848
929
|
}
|
|
849
930
|
}
|
|
850
931
|
const shell = await next();
|
|
932
|
+
if (isOpaque(shell))
|
|
933
|
+
return shell;
|
|
851
934
|
const isNullBody = shell.status === 204 ||
|
|
852
935
|
shell.status === 205 ||
|
|
853
936
|
shell.status === 304 ||
|
|
@@ -893,6 +976,10 @@ export function createGateway(options) {
|
|
|
893
976
|
cancelFragmentBodies();
|
|
894
977
|
throw error;
|
|
895
978
|
}
|
|
979
|
+
if (isOpaque(shellResponse)) {
|
|
980
|
+
cancelFragmentBodies();
|
|
981
|
+
return shellResponse;
|
|
982
|
+
}
|
|
896
983
|
const isShellNullBody = shellResponse.status === 204 ||
|
|
897
984
|
shellResponse.status === 205 ||
|
|
898
985
|
shellResponse.status === 304 ||
|
|
@@ -1093,7 +1180,7 @@ export function createGateway(options) {
|
|
|
1093
1180
|
export function toWebMiddleware(gateway) {
|
|
1094
1181
|
return async (request, next) => {
|
|
1095
1182
|
const shellOnce = once(next);
|
|
1096
|
-
return (await gateway.handle(request, shellOnce)) ?? shellOnce();
|
|
1183
|
+
return (await gateway.handle(request, shellOnce)) ?? passThrough(gateway, request, await shellOnce());
|
|
1097
1184
|
};
|
|
1098
1185
|
}
|
|
1099
1186
|
/**
|
|
@@ -1113,9 +1200,14 @@ export function toWebMiddleware(gateway) {
|
|
|
1113
1200
|
export function toFetchHandler(gateway, appHandler) {
|
|
1114
1201
|
return async (request) => {
|
|
1115
1202
|
const shellOnce = once(() => Promise.resolve(appHandler(request)));
|
|
1116
|
-
return (await gateway.handle(request, shellOnce)) ?? shellOnce();
|
|
1203
|
+
return (await gateway.handle(request, shellOnce)) ?? passThrough(gateway, request, await shellOnce());
|
|
1117
1204
|
};
|
|
1118
1205
|
}
|
|
1206
|
+
/** The app's own response to a request the gateway passed to it, with the gateway's page headers. */
|
|
1207
|
+
function passThrough(gateway, request, response) {
|
|
1208
|
+
const headers = isOpaque(response) ? null : gateway.pageHeaders(request);
|
|
1209
|
+
return headers ? withHeaders(response, headers) : response;
|
|
1210
|
+
}
|
|
1119
1211
|
/** Memoizes an async thunk so the wrapped work happens at most once. */
|
|
1120
1212
|
function once(fn) {
|
|
1121
1213
|
let pending;
|
|
@@ -1163,8 +1255,12 @@ function normalizeBasePath(basePath) {
|
|
|
1163
1255
|
* ones (`%2e%2e`), are already normalized when the incoming request URL is parsed, which takes
|
|
1164
1256
|
* such a request out of the fragment namespace entirely before it ever reaches here. The check
|
|
1165
1257
|
* costs nothing and does not assume every runtime normalizes identically.
|
|
1258
|
+
*
|
|
1259
|
+
* What URL parsing does not normalize is a separator or dot segment hidden inside a segment;
|
|
1260
|
+
* see {@link assertForwardablePath}, which runs first.
|
|
1166
1261
|
*/
|
|
1167
1262
|
export function resolveEndpointUrl(endpoint, strippedUrl, fragmentId) {
|
|
1263
|
+
assertForwardablePath(strippedUrl.pathname, fragmentId);
|
|
1168
1264
|
const endpointUrl = new URL(endpoint);
|
|
1169
1265
|
const basePath = endpointUrl.pathname.endsWith('/') ? endpointUrl.pathname.slice(0, -1) : endpointUrl.pathname;
|
|
1170
1266
|
// Assigned, never parsed: as a string, a path starting `//` is a protocol-relative URL naming
|
|
@@ -1181,6 +1277,52 @@ export function resolveEndpointUrl(endpoint, strippedUrl, fragmentId) {
|
|
|
1181
1277
|
/** Thrown when a namespace request would reach outside its fragment endpoint's declared path. */
|
|
1182
1278
|
class EndpointScopeError extends Error {
|
|
1183
1279
|
}
|
|
1280
|
+
/** How many layers of percent-encoding are looked through. An upstream chain rarely decodes more than twice. */
|
|
1281
|
+
const MAX_DECODE_PASSES = 3;
|
|
1282
|
+
/**
|
|
1283
|
+
* Refuses a path that could mean something else to an upstream than it does as a string: one with
|
|
1284
|
+
* a segment that — once decoded, as far as it decodes — is a dot segment, or holds a path
|
|
1285
|
+
* separator or a NUL. Applied to every fragment request, whatever kind of endpoint serves it.
|
|
1286
|
+
*
|
|
1287
|
+
* - `..%2f..%2fadmin` is one segment to a URL parser, and `../../admin` to an upstream that
|
|
1288
|
+
* decodes `%2F` before resolving.
|
|
1289
|
+
* - `..;/` is not a dot segment to a URL parser, and is one to a servlet container, which drops
|
|
1290
|
+
* `;parameters` before resolving. So the part before the first `;` is what is tested.
|
|
1291
|
+
* - Compatibility forms are folded (NFKC) before testing: `..` and `/` are `..` and `/` to a
|
|
1292
|
+
* file system that best-fits them.
|
|
1293
|
+
*
|
|
1294
|
+
* A segment that is not valid percent-encoding (a bare `%`) is refused too, rather than guessed at.
|
|
1295
|
+
*/
|
|
1296
|
+
function assertForwardablePath(pathname, fragmentId) {
|
|
1297
|
+
const refuse = (why) => {
|
|
1298
|
+
throw new EndpointScopeError(`braid-gateway: a request for fragment "${fragmentId}" has ${why}, which could resolve outside the fragment's ` +
|
|
1299
|
+
`endpoint upstream — refusing to forward it`);
|
|
1300
|
+
};
|
|
1301
|
+
for (const segment of pathname.split('/')) {
|
|
1302
|
+
let decoded = segment;
|
|
1303
|
+
for (let pass = 0; pass < MAX_DECODE_PASSES && decoded.includes('%'); pass++) {
|
|
1304
|
+
let next;
|
|
1305
|
+
try {
|
|
1306
|
+
next = decodeURIComponent(decoded);
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
// the segment as sent must decode; a later layer that does not is just text
|
|
1310
|
+
if (pass === 0)
|
|
1311
|
+
refuse('a path segment that is not valid percent-encoding');
|
|
1312
|
+
break;
|
|
1313
|
+
}
|
|
1314
|
+
if (next === decoded)
|
|
1315
|
+
break;
|
|
1316
|
+
decoded = next;
|
|
1317
|
+
}
|
|
1318
|
+
const folded = decoded.normalize('NFKC');
|
|
1319
|
+
const bare = folded.split(';', 1)[0].replace(/[ \t]+$/, '');
|
|
1320
|
+
if (bare === '.' || bare === '..')
|
|
1321
|
+
refuse('a dot segment hidden by encoding or path parameters');
|
|
1322
|
+
if (/[/\\\0]/.test(folded))
|
|
1323
|
+
refuse('an encoded path separator');
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1184
1326
|
/**
|
|
1185
1327
|
* The breaker refused a request without making it.
|
|
1186
1328
|
*
|
|
@@ -1244,6 +1386,35 @@ function escapeHtml(value) {
|
|
|
1244
1386
|
function appendHop(request) {
|
|
1245
1387
|
return [request.headers.get(BRAID_VIA_HEADER), 'braid'].filter(Boolean).join(', ');
|
|
1246
1388
|
}
|
|
1389
|
+
/**
|
|
1390
|
+
* A response with nothing to compose or annotate: a browser's opaque redirect (status 0), which is
|
|
1391
|
+
* what a navigation's own `fetch` returns when the shell redirects, or a network error. Neither can
|
|
1392
|
+
* be copied into a new `Response`, and both are for the browser to act on as they are.
|
|
1393
|
+
*/
|
|
1394
|
+
function isOpaque(response) {
|
|
1395
|
+
return response.status === 0;
|
|
1396
|
+
}
|
|
1397
|
+
/** A top-level browser navigation: the request a page's own headers are for. */
|
|
1398
|
+
function isNavigation(request) {
|
|
1399
|
+
return request.method === 'GET' && request.headers.get('sec-fetch-dest') === 'document';
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* The response with these headers set. A response's headers are often immutable (one `fetch`
|
|
1403
|
+
* returned), so it is copied then — the body streams through the copy untouched.
|
|
1404
|
+
*/
|
|
1405
|
+
function withHeaders(response, headers) {
|
|
1406
|
+
try {
|
|
1407
|
+
for (const [name, value] of Object.entries(headers))
|
|
1408
|
+
response.headers.set(name, value);
|
|
1409
|
+
return response;
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
const copy = new Response(response.body, response);
|
|
1413
|
+
for (const [name, value] of Object.entries(headers))
|
|
1414
|
+
copy.headers.set(name, value);
|
|
1415
|
+
return copy;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1247
1418
|
/** How many Braid gateways a request has already crossed, from its `x-braid-via` entries. */
|
|
1248
1419
|
function hopCount(request) {
|
|
1249
1420
|
const via = request.headers.get(BRAID_VIA_HEADER);
|
|
@@ -1274,8 +1445,11 @@ const HOP_BY_HOP_HEADERS = ['connection', 'keep-alive', 'proxy-connection', 'tra
|
|
|
1274
1445
|
* Only what the platform `fetch` returned: a function endpoint hands over its own bytes and is
|
|
1275
1446
|
* trusted to describe them — one that wraps `fetch` must drop these headers itself. On Workers,
|
|
1276
1447
|
* which would re-encode a passed-through body, this costs the compression of that one hop.
|
|
1448
|
+
*
|
|
1449
|
+
* Where the codings could not be pinned (`codingsPinned` false: a browser, which forbids setting
|
|
1450
|
+
* `accept-encoding`), the browser asked for them and decodes all it asked for, so none is refused.
|
|
1277
1451
|
*/
|
|
1278
|
-
function forwardable(response, decodedByFetch) {
|
|
1452
|
+
function forwardable(response, decodedByFetch, codingsPinned = true) {
|
|
1279
1453
|
const encoding = response.headers.get('content-encoding');
|
|
1280
1454
|
const connection = response.headers.get('connection');
|
|
1281
1455
|
const reframe = decodedByFetch && encoding !== null;
|
|
@@ -1283,7 +1457,7 @@ function forwardable(response, decodedByFetch) {
|
|
|
1283
1457
|
// runtime's fetch decoded it varies — Node's decodes zstd — so neither the length nor the coding
|
|
1284
1458
|
// can be said to describe the body. Refused, the same on every runtime, rather than guessed at.
|
|
1285
1459
|
const codings = encoding?.split(',').map((coding) => coding.trim().toLowerCase()).filter(Boolean) ?? [];
|
|
1286
|
-
if (reframe && codings.some((coding) => coding !== 'identity' && !FETCH_DECODED_CODINGS.has(coding))) {
|
|
1460
|
+
if (reframe && codingsPinned && codings.some((coding) => coding !== 'identity' && !FETCH_DECODED_CODINGS.has(coding))) {
|
|
1287
1461
|
void response.body?.cancel();
|
|
1288
1462
|
return new Response(`braid-gateway: the endpoint answered in content-encoding "${encoding}", which was not requested`, {
|
|
1289
1463
|
status: 502,
|