@braidlabs/gateway 0.7.0 → 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 +206 -33
- 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 +24 -9
- 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 +66 -2
- 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":"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,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,KAAqD,CAAC;QACvF,IAAI,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,KAAK,EAAE;YAAE,OAAO,SAAS,CAAC;QACxE,kFAAkF;QAClF,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,SAAS;YAAE,OAAO,QAAQ,CAAC,EAAE,CAAC;IACxE,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAAiB;IACjD,MAAM,OAAO,GAAG,kBAAkB,CAAC,SAAS,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;IACxH,OAAO,GAAG,UAAU,GAAG,OAAO,GAAG,CAAC;AACpC,CAAC;AAED,gGAAgG;AAChG,SAAS,kBAAkB,CAAC,IAAY;IACtC,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAC5G,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;AACzF,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,OAAO,kBAAkB,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;AACnE,CAAC;AAED,SAAS,iBAAiB,CAAC,MAAc;IACvC,OAAO,MAAM;SACV,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;SAC1B,MAAM,CAAC,OAAO,CAAC;SACf,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE;QACd,MAAM,SAAS,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACtC,OAAO,EAAE,IAAI,EAAE,SAAS,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IAClF,CAAC,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,SAAiB;IAC5D,MAAM,CAAC,IAAI,EAAE,GAAG,UAAU,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACpC,+FAA+F;IAC/F,IAAI,SAAS,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IAC9B,MAAM,CAAC,aAAa,EAAE,IAAI,CAAC,GAAG,kBAAkB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAClF,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC;IAExC,MAAM,IAAI,GAAG,UAAU;SACpB,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;SACpC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,KAAK,EAAE,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC;IAEzF,OAAO,CAAC,GAAG,aAAa,GAAG,iBAAiB,CAAC,SAAS,CAAC,GAAG,IAAI,IAAI,KAAK,EAAE,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC3G,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAqB,EAAE,SAAiB,EAAE,WAAoB;IAChG,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,MAAM,GAAG,GAAG,iBAAiB,CAAC,SAAS,CAAC,CAAC;IACzC,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,MAAM,SAAS,GAAG,IAAI,GAAG,EAAU,CAAC;IACpC,MAAM,IAAI,GAAuC,EAAE,CAAC;IAEpD,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,iBAAiB,CAAC,MAAM,CAAC,EAAE,CAAC;QAClE,MAAM,CAAC,aAAa,EAAE,IAAI,CAAC,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;QAC1D,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YACzB,MAAM,OAAO,GAAG,GAAG,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAC5D,IAAI,CAAC,IAAI,CAAC,GAAG,OAAO,GAAG,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YACvD,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACzB,CAAC;aAAM,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,IAAI,WAAW,EAAE,CAAC;YACvD,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;QACvC,CAAC;IACH,CAAC;IAED,6FAA6F;IAC7F,gDAAgD;IAChD,MAAM,GAAG,GAAG,CAAC,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IACtG,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAChD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAqB;IACxD,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,MAAM,GAAG,GAAG,iBAAiB,CAAC,MAAM,CAAC;SAClC,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC;SAC/C,GAAG,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC;IAC/B,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAChD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAgB,EAAE,SAAiB;IACtE,OAAO,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC;IAClC,MAAM,KAAK,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IAC/B,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QAC/C,IAAI,MAAM;YAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;IACnD,CAAC;AACH,CAAC"}
|
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
|
|
@@ -694,6 +683,13 @@ export function createGateway(options) {
|
|
|
694
683
|
}
|
|
695
684
|
// the endpoint serves an embedded fragment, not a full document
|
|
696
685
|
fragmentRequest.headers.set('sec-fetch-dest', 'empty');
|
|
686
|
+
// only codings every platform fetch decodes, so the coding of what comes back is known: a
|
|
687
|
+
// browser's `zstd` would come back decoded by some runtimes and not others (see `forwardable`)
|
|
688
|
+
if (fragmentFetch === globalThis.fetch)
|
|
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');
|
|
697
693
|
fragmentRequest.headers.set('x-braid-fragment-mode', 'embedded');
|
|
698
694
|
/**
|
|
699
695
|
* In development, present the request as coming from the endpoint's own origin.
|
|
@@ -763,7 +759,9 @@ export function createGateway(options) {
|
|
|
763
759
|
sent = true;
|
|
764
760
|
return runFetch(fetchTelemetry, () => fragmentFetch(fragmentRequest, { redirect: 'manual', signal: timeoutSignal }));
|
|
765
761
|
};
|
|
766
|
-
const
|
|
762
|
+
const sentResponse = key ? await singleFlight.run(key, send) : await send();
|
|
763
|
+
// a function endpoint hands over its own bytes; only the platform fetch decodes them
|
|
764
|
+
const response = forwardable(sentResponse, fragmentFetch === globalThis.fetch, codingsPinned);
|
|
767
765
|
// A 5xx is a reachable endpoint, but it is not a working one — a fragment returning 500 on
|
|
768
766
|
// every request would otherwise hold the circuit closed forever and keep paying for it.
|
|
769
767
|
if (response.status >= 500)
|
|
@@ -799,6 +797,83 @@ export function createGateway(options) {
|
|
|
799
797
|
return { ok: false, error, timedOut: timeoutSignal.aborted };
|
|
800
798
|
}
|
|
801
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
|
+
}
|
|
802
877
|
/**
|
|
803
878
|
* Handles a request bound for the shell application.
|
|
804
879
|
*
|
|
@@ -842,6 +917,8 @@ export function createGateway(options) {
|
|
|
842
917
|
}
|
|
843
918
|
}
|
|
844
919
|
const shell = await next();
|
|
920
|
+
if (isOpaque(shell))
|
|
921
|
+
return shell;
|
|
845
922
|
const isNullBody = shell.status === 204 ||
|
|
846
923
|
shell.status === 205 ||
|
|
847
924
|
shell.status === 304 ||
|
|
@@ -887,6 +964,10 @@ export function createGateway(options) {
|
|
|
887
964
|
cancelFragmentBodies();
|
|
888
965
|
throw error;
|
|
889
966
|
}
|
|
967
|
+
if (isOpaque(shellResponse)) {
|
|
968
|
+
cancelFragmentBodies();
|
|
969
|
+
return shellResponse;
|
|
970
|
+
}
|
|
890
971
|
const isShellNullBody = shellResponse.status === 204 ||
|
|
891
972
|
shellResponse.status === 205 ||
|
|
892
973
|
shellResponse.status === 304 ||
|
|
@@ -1087,7 +1168,7 @@ export function createGateway(options) {
|
|
|
1087
1168
|
export function toWebMiddleware(gateway) {
|
|
1088
1169
|
return async (request, next) => {
|
|
1089
1170
|
const shellOnce = once(next);
|
|
1090
|
-
return (await gateway.handle(request, shellOnce)) ?? shellOnce();
|
|
1171
|
+
return (await gateway.handle(request, shellOnce)) ?? passThrough(gateway, request, await shellOnce());
|
|
1091
1172
|
};
|
|
1092
1173
|
}
|
|
1093
1174
|
/**
|
|
@@ -1107,9 +1188,14 @@ export function toWebMiddleware(gateway) {
|
|
|
1107
1188
|
export function toFetchHandler(gateway, appHandler) {
|
|
1108
1189
|
return async (request) => {
|
|
1109
1190
|
const shellOnce = once(() => Promise.resolve(appHandler(request)));
|
|
1110
|
-
return (await gateway.handle(request, shellOnce)) ?? shellOnce();
|
|
1191
|
+
return (await gateway.handle(request, shellOnce)) ?? passThrough(gateway, request, await shellOnce());
|
|
1111
1192
|
};
|
|
1112
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
|
+
}
|
|
1113
1199
|
/** Memoizes an async thunk so the wrapped work happens at most once. */
|
|
1114
1200
|
function once(fn) {
|
|
1115
1201
|
let pending;
|
|
@@ -1238,11 +1324,98 @@ function escapeHtml(value) {
|
|
|
1238
1324
|
function appendHop(request) {
|
|
1239
1325
|
return [request.headers.get(BRAID_VIA_HEADER), 'braid'].filter(Boolean).join(', ');
|
|
1240
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
|
+
}
|
|
1241
1356
|
/** How many Braid gateways a request has already crossed, from its `x-braid-via` entries. */
|
|
1242
1357
|
function hopCount(request) {
|
|
1243
1358
|
const via = request.headers.get(BRAID_VIA_HEADER);
|
|
1244
1359
|
return via ? via.split(',').filter((entry) => entry.trim()).length : 0;
|
|
1245
1360
|
}
|
|
1361
|
+
/** The content codings every platform `fetch` decodes before handing over the body. */
|
|
1362
|
+
const FETCH_DECODED_CODINGS = new Set(['gzip', 'x-gzip', 'deflate', 'br']);
|
|
1363
|
+
/**
|
|
1364
|
+
* Headers that describe one connection, not the response (RFC 9110 §7.6.1). The gateway's
|
|
1365
|
+
* connection to the client is its own; forwarding the endpoint's would let the endpoint frame it —
|
|
1366
|
+
* a `transfer-encoding: identity` with the length removed leaves the body unframed on a keep-alive
|
|
1367
|
+
* connection, and a proxy in front may read the rest as its next response.
|
|
1368
|
+
*/
|
|
1369
|
+
const HOP_BY_HOP_HEADERS = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'proxy-authenticate', 'proxy-authorization'];
|
|
1370
|
+
/**
|
|
1371
|
+
* An endpoint's response with headers that describe what the gateway will actually send.
|
|
1372
|
+
*
|
|
1373
|
+
* Hop-by-hop headers always go, with any the endpoint's `connection` names. And when the platform
|
|
1374
|
+
* `fetch` produced the response (`decodedByFetch`), the body was decoded but `content-encoding` and
|
|
1375
|
+
* `content-length` still describe the bytes on the wire. Forwarded as they are, the client gets a
|
|
1376
|
+
* body it cannot decode and — worse, on Node, which writes whatever bytes it is given — more bytes
|
|
1377
|
+
* than the declared length on a keep-alive connection. The surplus is read as the start of the
|
|
1378
|
+
* *next* response on that socket, so a different request fails. So for a coding `fetch` decodes,
|
|
1379
|
+
* both go and the server frames the body itself; any other coding is refused (below). An identity
|
|
1380
|
+
* response keeps its length. (The Node binding also refuses to write past a declared length,
|
|
1381
|
+
* whatever produced it.)
|
|
1382
|
+
*
|
|
1383
|
+
* Only what the platform `fetch` returned: a function endpoint hands over its own bytes and is
|
|
1384
|
+
* trusted to describe them — one that wraps `fetch` must drop these headers itself. On Workers,
|
|
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.
|
|
1389
|
+
*/
|
|
1390
|
+
function forwardable(response, decodedByFetch, codingsPinned = true) {
|
|
1391
|
+
const encoding = response.headers.get('content-encoding');
|
|
1392
|
+
const connection = response.headers.get('connection');
|
|
1393
|
+
const reframe = decodedByFetch && encoding !== null;
|
|
1394
|
+
// A coding the gateway did not ask for (it asks for exactly FETCH_DECODED_CODINGS): whether this
|
|
1395
|
+
// runtime's fetch decoded it varies — Node's decodes zstd — so neither the length nor the coding
|
|
1396
|
+
// can be said to describe the body. Refused, the same on every runtime, rather than guessed at.
|
|
1397
|
+
const codings = encoding?.split(',').map((coding) => coding.trim().toLowerCase()).filter(Boolean) ?? [];
|
|
1398
|
+
if (reframe && codingsPinned && codings.some((coding) => coding !== 'identity' && !FETCH_DECODED_CODINGS.has(coding))) {
|
|
1399
|
+
void response.body?.cancel();
|
|
1400
|
+
return new Response(`braid-gateway: the endpoint answered in content-encoding "${encoding}", which was not requested`, {
|
|
1401
|
+
status: 502,
|
|
1402
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
if (!reframe && !HOP_BY_HOP_HEADERS.some((name) => response.headers.has(name)))
|
|
1406
|
+
return response;
|
|
1407
|
+
const headers = new Headers(response.headers);
|
|
1408
|
+
for (const name of connection?.split(',') ?? [])
|
|
1409
|
+
if (name.trim())
|
|
1410
|
+
headers.delete(name.trim());
|
|
1411
|
+
for (const name of HOP_BY_HOP_HEADERS)
|
|
1412
|
+
headers.delete(name);
|
|
1413
|
+
if (reframe) {
|
|
1414
|
+
headers.delete('content-length');
|
|
1415
|
+
headers.delete('content-encoding');
|
|
1416
|
+
}
|
|
1417
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
1418
|
+
}
|
|
1246
1419
|
/** Realm stub ETags by stub content, so a hot path does not hash the same few hundred bytes twice. */
|
|
1247
1420
|
const stubEtags = new Map();
|
|
1248
1421
|
/** A strong ETag over a realm stub's content. */
|