@braidlabs/gateway 0.7.1 → 0.8.0
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 +33 -1
- package/src/gateway.js +147 -35
- 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 +2 -0
- 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.0",
|
|
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
|
*
|
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
|
|
@@ -698,6 +687,9 @@ export function createGateway(options) {
|
|
|
698
687
|
// browser's `zstd` would come back decoded by some runtimes and not others (see `forwardable`)
|
|
699
688
|
if (fragmentFetch === globalThis.fetch)
|
|
700
689
|
fragmentRequest.headers.set('accept-encoding', [...FETCH_DECODED_CODINGS].join(', '));
|
|
690
|
+
// A browser (the gateway in a service worker) forbids the header and drops it without a word:
|
|
691
|
+
// the browser then picks the codings itself and decodes every one it picked.
|
|
692
|
+
const codingsPinned = fragmentRequest.headers.has('accept-encoding');
|
|
701
693
|
fragmentRequest.headers.set('x-braid-fragment-mode', 'embedded');
|
|
702
694
|
/**
|
|
703
695
|
* In development, present the request as coming from the endpoint's own origin.
|
|
@@ -769,7 +761,7 @@ export function createGateway(options) {
|
|
|
769
761
|
};
|
|
770
762
|
const sentResponse = key ? await singleFlight.run(key, send) : await send();
|
|
771
763
|
// a function endpoint hands over its own bytes; only the platform fetch decodes them
|
|
772
|
-
const response = forwardable(sentResponse, fragmentFetch === globalThis.fetch);
|
|
764
|
+
const response = forwardable(sentResponse, fragmentFetch === globalThis.fetch, codingsPinned);
|
|
773
765
|
// A 5xx is a reachable endpoint, but it is not a working one — a fragment returning 500 on
|
|
774
766
|
// every request would otherwise hold the circuit closed forever and keep paying for it.
|
|
775
767
|
if (response.status >= 500)
|
|
@@ -805,6 +797,83 @@ export function createGateway(options) {
|
|
|
805
797
|
return { ok: false, error, timedOut: timeoutSignal.aborted };
|
|
806
798
|
}
|
|
807
799
|
}
|
|
800
|
+
/** A request outside the Braid namespace: a page, composed or passed to the shell. */
|
|
801
|
+
async function pageResponse(request, requestUrl, next) {
|
|
802
|
+
// HEAD gets the same answer without the body, so a monitor or link checker in front of a
|
|
803
|
+
// gateway with no app behind it sees the page a browser would.
|
|
804
|
+
if (options.documents && request.method === 'HEAD') {
|
|
805
|
+
const asGet = new Request(request.url, { method: 'GET', headers: request.headers });
|
|
806
|
+
const document = await options.documents(asGet, { manifests: await registry.current() });
|
|
807
|
+
if (document) {
|
|
808
|
+
const headers = new Headers(document.headers);
|
|
809
|
+
headers.append('vary', 'sec-fetch-dest');
|
|
810
|
+
applyPierceCacheControl(headers);
|
|
811
|
+
await document.body?.cancel();
|
|
812
|
+
return new Response(null, { status: document.status, headers });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const document = options.documents && isDocumentRequest(request)
|
|
816
|
+
? await options.documents(request, { manifests: await registry.current() })
|
|
817
|
+
: null;
|
|
818
|
+
if (document) {
|
|
819
|
+
// The defined document is the shell: pierced like an app's response when fragments match,
|
|
820
|
+
// and served as it is when none do.
|
|
821
|
+
const composed = await handleShellRequest(request, requestUrl, async () => document);
|
|
822
|
+
if (composed)
|
|
823
|
+
return composed;
|
|
824
|
+
const served = new Response(document.body, document);
|
|
825
|
+
served.headers.append('vary', 'sec-fetch-dest');
|
|
826
|
+
applyPierceCacheControl(served.headers);
|
|
827
|
+
return served;
|
|
828
|
+
}
|
|
829
|
+
return next ? handleShellRequest(request, requestUrl, next) : null;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* The headers a page carries because of this gateway, for a browser navigation: today, the
|
|
833
|
+
* pointer to the speculation rules.
|
|
834
|
+
*
|
|
835
|
+
* Every page, not only composed ones: the page that *links* to a fragment's route is the one the
|
|
836
|
+
* browser speculates from, and it is usually not that route. The value is the same for every
|
|
837
|
+
* caller, so it never makes a page private, and a browser ignores it on anything but HTML.
|
|
838
|
+
*/
|
|
839
|
+
function pageHeaders(request) {
|
|
840
|
+
if (!options.speculationRules || !isNavigation(request))
|
|
841
|
+
return null;
|
|
842
|
+
return { 'speculation-rules': `"${BRAID_SPECULATION_RULES_PATH}"` };
|
|
843
|
+
}
|
|
844
|
+
/** A page the gateway produced, with {@link pageHeaders} on it. */
|
|
845
|
+
function withPageHeaders(request, page) {
|
|
846
|
+
const headers = page && !isOpaque(page) ? pageHeaders(request) : null;
|
|
847
|
+
return headers ? withHeaders(page, headers) : page;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* The rules themselves: the pierce patterns of every fragment that asks for speculation, less
|
|
851
|
+
* those the caller may not list or fetch — as discovery and the namespace keep them out of sight.
|
|
852
|
+
*
|
|
853
|
+
* Private whenever the gateway can tell callers apart (a `principal` resolver) or any fragment
|
|
854
|
+
* has access rules: decided from configuration, so a shared cache never holds a list that a
|
|
855
|
+
* registry refresh has since made personal. Never stored in development, where nothing is filtered.
|
|
856
|
+
*/
|
|
857
|
+
async function speculationRulesResponse(request) {
|
|
858
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
859
|
+
return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
860
|
+
}
|
|
861
|
+
const manifests = await registry.listFragments();
|
|
862
|
+
const personal = !isDevelopment && (options.principal !== undefined || manifests.some(hasAccessRules));
|
|
863
|
+
const principal = personal ? await resolvePrincipal(request) : undefined;
|
|
864
|
+
const visible = manifests.filter((manifest) => isDevelopment || !hasAccessRules(manifest) || (canList(manifest, principal) && canFetch(manifest, principal)));
|
|
865
|
+
return new Response(request.method === 'HEAD' ? null : JSON.stringify(speculationRulesFor(visible)), {
|
|
866
|
+
headers: {
|
|
867
|
+
'content-type': 'application/speculationrules+json',
|
|
868
|
+
...(isDevelopment
|
|
869
|
+
? { 'cache-control': 'no-store' }
|
|
870
|
+
: personal
|
|
871
|
+
? { 'cache-control': 'private, no-store', vary: 'cookie, authorization' }
|
|
872
|
+
: // short: the registry it comes from refreshes on its own schedule
|
|
873
|
+
{ 'cache-control': 'public, max-age=60' }),
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
}
|
|
808
877
|
/**
|
|
809
878
|
* Handles a request bound for the shell application.
|
|
810
879
|
*
|
|
@@ -848,6 +917,8 @@ export function createGateway(options) {
|
|
|
848
917
|
}
|
|
849
918
|
}
|
|
850
919
|
const shell = await next();
|
|
920
|
+
if (isOpaque(shell))
|
|
921
|
+
return shell;
|
|
851
922
|
const isNullBody = shell.status === 204 ||
|
|
852
923
|
shell.status === 205 ||
|
|
853
924
|
shell.status === 304 ||
|
|
@@ -893,6 +964,10 @@ export function createGateway(options) {
|
|
|
893
964
|
cancelFragmentBodies();
|
|
894
965
|
throw error;
|
|
895
966
|
}
|
|
967
|
+
if (isOpaque(shellResponse)) {
|
|
968
|
+
cancelFragmentBodies();
|
|
969
|
+
return shellResponse;
|
|
970
|
+
}
|
|
896
971
|
const isShellNullBody = shellResponse.status === 204 ||
|
|
897
972
|
shellResponse.status === 205 ||
|
|
898
973
|
shellResponse.status === 304 ||
|
|
@@ -1093,7 +1168,7 @@ export function createGateway(options) {
|
|
|
1093
1168
|
export function toWebMiddleware(gateway) {
|
|
1094
1169
|
return async (request, next) => {
|
|
1095
1170
|
const shellOnce = once(next);
|
|
1096
|
-
return (await gateway.handle(request, shellOnce)) ?? shellOnce();
|
|
1171
|
+
return (await gateway.handle(request, shellOnce)) ?? passThrough(gateway, request, await shellOnce());
|
|
1097
1172
|
};
|
|
1098
1173
|
}
|
|
1099
1174
|
/**
|
|
@@ -1113,9 +1188,14 @@ export function toWebMiddleware(gateway) {
|
|
|
1113
1188
|
export function toFetchHandler(gateway, appHandler) {
|
|
1114
1189
|
return async (request) => {
|
|
1115
1190
|
const shellOnce = once(() => Promise.resolve(appHandler(request)));
|
|
1116
|
-
return (await gateway.handle(request, shellOnce)) ?? shellOnce();
|
|
1191
|
+
return (await gateway.handle(request, shellOnce)) ?? passThrough(gateway, request, await shellOnce());
|
|
1117
1192
|
};
|
|
1118
1193
|
}
|
|
1194
|
+
/** The app's own response to a request the gateway passed to it, with the gateway's page headers. */
|
|
1195
|
+
function passThrough(gateway, request, response) {
|
|
1196
|
+
const headers = isOpaque(response) ? null : gateway.pageHeaders(request);
|
|
1197
|
+
return headers ? withHeaders(response, headers) : response;
|
|
1198
|
+
}
|
|
1119
1199
|
/** Memoizes an async thunk so the wrapped work happens at most once. */
|
|
1120
1200
|
function once(fn) {
|
|
1121
1201
|
let pending;
|
|
@@ -1244,6 +1324,35 @@ function escapeHtml(value) {
|
|
|
1244
1324
|
function appendHop(request) {
|
|
1245
1325
|
return [request.headers.get(BRAID_VIA_HEADER), 'braid'].filter(Boolean).join(', ');
|
|
1246
1326
|
}
|
|
1327
|
+
/**
|
|
1328
|
+
* A response with nothing to compose or annotate: a browser's opaque redirect (status 0), which is
|
|
1329
|
+
* what a navigation's own `fetch` returns when the shell redirects, or a network error. Neither can
|
|
1330
|
+
* be copied into a new `Response`, and both are for the browser to act on as they are.
|
|
1331
|
+
*/
|
|
1332
|
+
function isOpaque(response) {
|
|
1333
|
+
return response.status === 0;
|
|
1334
|
+
}
|
|
1335
|
+
/** A top-level browser navigation: the request a page's own headers are for. */
|
|
1336
|
+
function isNavigation(request) {
|
|
1337
|
+
return request.method === 'GET' && request.headers.get('sec-fetch-dest') === 'document';
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* The response with these headers set. A response's headers are often immutable (one `fetch`
|
|
1341
|
+
* returned), so it is copied then — the body streams through the copy untouched.
|
|
1342
|
+
*/
|
|
1343
|
+
function withHeaders(response, headers) {
|
|
1344
|
+
try {
|
|
1345
|
+
for (const [name, value] of Object.entries(headers))
|
|
1346
|
+
response.headers.set(name, value);
|
|
1347
|
+
return response;
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
const copy = new Response(response.body, response);
|
|
1351
|
+
for (const [name, value] of Object.entries(headers))
|
|
1352
|
+
copy.headers.set(name, value);
|
|
1353
|
+
return copy;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1247
1356
|
/** How many Braid gateways a request has already crossed, from its `x-braid-via` entries. */
|
|
1248
1357
|
function hopCount(request) {
|
|
1249
1358
|
const via = request.headers.get(BRAID_VIA_HEADER);
|
|
@@ -1274,8 +1383,11 @@ const HOP_BY_HOP_HEADERS = ['connection', 'keep-alive', 'proxy-connection', 'tra
|
|
|
1274
1383
|
* Only what the platform `fetch` returned: a function endpoint hands over its own bytes and is
|
|
1275
1384
|
* trusted to describe them — one that wraps `fetch` must drop these headers itself. On Workers,
|
|
1276
1385
|
* which would re-encode a passed-through body, this costs the compression of that one hop.
|
|
1386
|
+
*
|
|
1387
|
+
* Where the codings could not be pinned (`codingsPinned` false: a browser, which forbids setting
|
|
1388
|
+
* `accept-encoding`), the browser asked for them and decodes all it asked for, so none is refused.
|
|
1277
1389
|
*/
|
|
1278
|
-
function forwardable(response, decodedByFetch) {
|
|
1390
|
+
function forwardable(response, decodedByFetch, codingsPinned = true) {
|
|
1279
1391
|
const encoding = response.headers.get('content-encoding');
|
|
1280
1392
|
const connection = response.headers.get('connection');
|
|
1281
1393
|
const reframe = decodedByFetch && encoding !== null;
|
|
@@ -1283,7 +1395,7 @@ function forwardable(response, decodedByFetch) {
|
|
|
1283
1395
|
// runtime's fetch decoded it varies — Node's decodes zstd — so neither the length nor the coding
|
|
1284
1396
|
// can be said to describe the body. Refused, the same on every runtime, rather than guessed at.
|
|
1285
1397
|
const codings = encoding?.split(',').map((coding) => coding.trim().toLowerCase()).filter(Boolean) ?? [];
|
|
1286
|
-
if (reframe && codings.some((coding) => coding !== 'identity' && !FETCH_DECODED_CODINGS.has(coding))) {
|
|
1398
|
+
if (reframe && codingsPinned && codings.some((coding) => coding !== 'identity' && !FETCH_DECODED_CODINGS.has(coding))) {
|
|
1287
1399
|
void response.body?.cancel();
|
|
1288
1400
|
return new Response(`braid-gateway: the endpoint answered in content-encoding "${encoding}", which was not requested`, {
|
|
1289
1401
|
status: 502,
|