@braidlabs/gateway 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +464 -0
- package/package.json +44 -0
- package/src/appd.d.ts +130 -0
- package/src/appd.js +93 -0
- package/src/appd.js.map +1 -0
- package/src/breaker.d.ts +51 -0
- package/src/breaker.js +96 -0
- package/src/breaker.js.map +1 -0
- package/src/discovery.d.ts +91 -0
- package/src/discovery.js +127 -0
- package/src/discovery.js.map +1 -0
- package/src/gateway.d.ts +241 -0
- package/src/gateway.js +735 -0
- package/src/gateway.js.map +1 -0
- package/src/index.d.ts +36 -0
- package/src/index.js +27 -0
- package/src/index.js.map +1 -0
- package/src/node.d.ts +35 -0
- package/src/node.js +252 -0
- package/src/node.js.map +1 -0
- package/src/protocol.d.ts +79 -0
- package/src/protocol.js +89 -0
- package/src/protocol.js.map +1 -0
- package/src/registry.d.ts +184 -0
- package/src/registry.js +187 -0
- package/src/registry.js.map +1 -0
- package/src/rewriter/html-rewrite-stream.d.ts +70 -0
- package/src/rewriter/html-rewrite-stream.js +483 -0
- package/src/rewriter/html-rewrite-stream.js.map +1 -0
- package/src/rewriter/transforms.d.ts +107 -0
- package/src/rewriter/transforms.js +312 -0
- package/src/rewriter/transforms.js.map +1 -0
- package/src/single-flight.d.ts +53 -0
- package/src/single-flight.js +97 -0
- package/src/single-flight.js.map +1 -0
- package/src/telemetry.d.ts +115 -0
- package/src/telemetry.js +177 -0
- package/src/telemetry.js.map +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# @braidlabs/gateway
|
|
2
|
+
|
|
3
|
+
> Every `/__braid/…` URL this package serves — what asks for it, when, and what comes back — is
|
|
4
|
+
> walked through in [Braid, explained](../../docs/braid-explained.md#4-the-__braid-urls).
|
|
5
|
+
|
|
6
|
+
The Braid gateway: fetch-native, platform-neutral origin-front middleware. Routes fragment
|
|
7
|
+
traffic by **exact id** under the reserved `/__braid/frag/:fragmentId/*` namespace — no route
|
|
8
|
+
pattern sniffing, no header-trust fallback — and passes everything else through to your existing
|
|
9
|
+
app.
|
|
10
|
+
|
|
11
|
+
Braid's founding architecture lives in [`docs/braid-architecture.md`](../../docs/braid-architecture.md).
|
|
12
|
+
|
|
13
|
+
Also see: [failure modes](../../docs/braid-failure-modes.md) ·
|
|
14
|
+
[CDN configuration](../../docs/braid-cdn.md) ·
|
|
15
|
+
[using Braid without the gateway](../../docs/braid-without-gateway.md)
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createGateway } from '@braidlabs/gateway';
|
|
21
|
+
|
|
22
|
+
const gateway = createGateway({
|
|
23
|
+
registry: [
|
|
24
|
+
// adapter defaults to "compat" — zero fragment code required
|
|
25
|
+
{ id: 'legacy-billing', endpoint: 'https://billing.internal', pierce: ['/billing/*'] },
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The registry is data, not code: pass inline manifests, a URL to a JSON array of manifests,
|
|
31
|
+
or an async loader (file/KV/database). Deploying a fragment never redeploys the gateway.
|
|
32
|
+
|
|
33
|
+
A fragment that ships a **web component** rather than a whole app declares the adapter, the module
|
|
34
|
+
to load, and the element to mount:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
{ id: 'rating', endpoint: 'https://widgets.example.com',
|
|
38
|
+
adapter: 'custom-element', entry: '/star-rating.js', element: 'star-rating',
|
|
39
|
+
events: ['rating:change'] }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### The registry as an FDC3 App Directory
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
createGateway({ registry, discovery: { appd: true } });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Serves the same registry in FDC3 App Directory shape at `/__braid/registry/appd/v2/apps` (and
|
|
49
|
+
`/appd/v2/apps/{appId}`). It is a **projection, not a second directory** — same manifests, same
|
|
50
|
+
`access.list` rules — so a caller can never see through AppD a resolver that discovery would have
|
|
51
|
+
hidden. `findIntent` becomes a registry query, and a user only sees resolvers they may use.
|
|
52
|
+
|
|
53
|
+
Manifests contribute intents through an `fdc3` block and listing metadata through `appd`:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
{ id: 'billing', endpoint: '…', pierce: ['/billing/*'],
|
|
57
|
+
fdc3: { listensFor: { ViewInvoice: { contexts: ['fdc3.instrument'] } },
|
|
58
|
+
raises: { ViewChart: ['fdc3.instrument'] } },
|
|
59
|
+
appd: { publisher: 'Payments', contactEmail: 'payments@example.com' } }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Two mapping decisions worth knowing:
|
|
63
|
+
|
|
64
|
+
- **`details.url` is a page, when there is one.** AppD asks where a web app lives; a fragment lives
|
|
65
|
+
inside a host page. If the fragment declares `pierce`, the first concrete pattern names a page it
|
|
66
|
+
actually appears on and that is the URL — follow it and you see the app. Otherwise the mount is
|
|
67
|
+
used, and `hostManifests.braid.standalonePage` says which you got.
|
|
68
|
+
- **Braid launch detail rides in `hostManifests`**, which is what AppD reserves for exactly this.
|
|
69
|
+
A fragment is *mounted into a page*, not opened as a window, so a Braid-aware agent reads
|
|
70
|
+
`hostManifests.braid` and mounts a `<fragment-slot>`; one that does not falls back to the URL.
|
|
71
|
+
|
|
72
|
+
An app the caller may not list 404s exactly as an unregistered one does — distinguishing them would
|
|
73
|
+
let an unauthorized caller enumerate the registry one id at a time.
|
|
74
|
+
|
|
75
|
+
Verify the record shape against the FDC3 AppD v2 spec before depending on it; the schema carries
|
|
76
|
+
more optional members than this projects.
|
|
77
|
+
|
|
78
|
+
A relative `entry` is re-rooted into the fragment's namespace, so the module and its imports are
|
|
79
|
+
fetched through the gateway rather than from the host's root. Such a fragment serves no document,
|
|
80
|
+
and the gateway answers its document request with `204` rather than forwarding it.
|
|
81
|
+
|
|
82
|
+
## Bindings
|
|
83
|
+
|
|
84
|
+
The core is `handle(request, next)` — fetch-native and platform-neutral. Piercing needs to
|
|
85
|
+
*read* the shell's response, so each binding differs in how it obtains it. All three below are
|
|
86
|
+
covered by integration tests against the real frameworks (`bindings.spec.ts`).
|
|
87
|
+
|
|
88
|
+
**Express / Connect / Vite / plain `http`** — mount it first; `next()` runs the rest of the app,
|
|
89
|
+
and the gateway reads what it writes:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { toNodeMiddleware } from '@braidlabs/gateway/node';
|
|
93
|
+
|
|
94
|
+
app.use(toNodeMiddleware(gateway));
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Angular SSR (`@angular/ssr/node`)** — mount `toNodeMiddleware` on Express before the Angular handler, and enable `trustProxyHeaders` on `AngularNodeAppEngine` so forwarded gateway headers are accepted:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const angularApp = new AngularNodeAppEngine({
|
|
101
|
+
allowedHosts: ['localhost', '127.0.0.1'],
|
|
102
|
+
trustProxyHeaders: true,
|
|
103
|
+
});
|
|
104
|
+
app.use(toNodeMiddleware(gateway));
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**NestJS** — the Express adapter is a Connect stack, so the same binding applies:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const app = await NestFactory.create(AppModule);
|
|
111
|
+
app.use(toNodeMiddleware(gateway));
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Nitro / h3 / Workers / Deno / Bun** — wrap the app's fetch handler:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { toFetchHandler } from '@braidlabs/gateway';
|
|
118
|
+
import { toWebHandler } from 'h3';
|
|
119
|
+
|
|
120
|
+
export default toFetchHandler(gateway, toWebHandler(app));
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For a built Nitro app, the gateway also composes in front of the generated Node listener, which
|
|
124
|
+
needs no Nitro internals and matches the "origin-front middleware" model:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { listener } from './.output/server/index.mjs';
|
|
128
|
+
|
|
129
|
+
const braid = toNodeMiddleware(gateway);
|
|
130
|
+
createServer((req, res) => braid(req, res, () => listener(req, res)));
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A fetch-style middleware form is available too:
|
|
134
|
+
`toWebMiddleware(gateway)(request, () => shellResponse)`. In every binding the shell application
|
|
135
|
+
runs at most once per request.
|
|
136
|
+
|
|
137
|
+
## What it serves
|
|
138
|
+
|
|
139
|
+
- **Realm stubs** (`/__braid/realm/:id/*`): a minimal document carrying the composition protocol
|
|
140
|
+
version, the manifest-declared adapter, and a `<base>` that keeps every relative subresource
|
|
141
|
+
request inside the fragment's namespace. Version mismatches fail in the client as named errors
|
|
142
|
+
— no title-check heuristics.
|
|
143
|
+
- **Fragment documents** (`/__braid/doc/:id/*`): the fragment's HTML prepared for the host page's
|
|
144
|
+
DOM — exactly what piercing injects, for the client-boot path.
|
|
145
|
+
- **Fragment assets/data** (`/__braid/frag/:id/*`): forwarded to the endpoint with the prefix
|
|
146
|
+
stripped, so endpoints see the same paths they serve standalone. Redirects pass through
|
|
147
|
+
unfollowed; each fragment gets a manifest-declared timeout budget.
|
|
148
|
+
- **Pierced documents**: see below.
|
|
149
|
+
- **Unknown ids**: 404, never the app shell.
|
|
150
|
+
|
|
151
|
+
## Piercing
|
|
152
|
+
|
|
153
|
+
Add `pierce` patterns to a manifest and the gateway server-renders that fragment into the page:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
{ id: 'legacy-billing', endpoint: 'https://billing.internal', pierce: ['/billing', '/billing/*'] }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
On a matching document request the gateway fetches the shell and every matching fragment
|
|
160
|
+
concurrently, then **interleaves** the fragments into the shell's response stream — a fragment
|
|
161
|
+
never serializes behind the shell. Each fragment lands in the `<fragment-slot>` that names it,
|
|
162
|
+
as a declarative shadow root, so the browser parses it into exactly the shape the client runtime
|
|
163
|
+
would have built; the slot then adopts it instead of fetching. Shells with no matching slot get
|
|
164
|
+
one created before `</body>`.
|
|
165
|
+
|
|
166
|
+
Fragment HTML is transformed on the way through: the doctype is stripped, `<html>/<head>/<body>`
|
|
167
|
+
become `braid-html/braid-head/braid-body` (start *and* end tags), scripts are neutralized to
|
|
168
|
+
`type="inert"`, and script preload links become `rel="inert-*"`. Fragment scripts are therefore
|
|
169
|
+
never live in the host realm, not even between parsing and activation.
|
|
170
|
+
|
|
171
|
+
If a fragment can't be server-rendered, the page still renders and the slot is left for the
|
|
172
|
+
client runtime to fill — a transient SSR failure self-heals rather than becoming a visible
|
|
173
|
+
error. Set `fallback: 'error-html'` on the manifest when a missing section is worse than a
|
|
174
|
+
visible failure.
|
|
175
|
+
|
|
176
|
+
## The three namespaces
|
|
177
|
+
|
|
178
|
+
Each kind of thing the gateway serves has its own path. That is a caching decision: a URL whose
|
|
179
|
+
response depends on a request header needs that header in every cache key between here and the
|
|
180
|
+
browser, and most CDNs ignore `Vary` on anything but `Accept-Encoding`.
|
|
181
|
+
|
|
182
|
+
| Path | Serves | Cacheable |
|
|
183
|
+
| --- | --- | --- |
|
|
184
|
+
| `/__braid/frag/:id/*` | the fragment's own endpoint — assets, data, anything it serves | **yes, on URL alone** — this is nearly all the traffic |
|
|
185
|
+
| `/__braid/realm/:id/*` | the realm stub the fragment's hidden iframe boots from | yes, on URL alone (1h + `stale-while-revalidate`) |
|
|
186
|
+
| `/__braid/doc/:id/*` | the fragment's document, prepared for the host page's DOM | per the fragment's own cache headers |
|
|
187
|
+
|
|
188
|
+
**No braid URL varies on a request header.** Point a CDN at them and they cache correctly with
|
|
189
|
+
no configuration at all.
|
|
190
|
+
|
|
191
|
+
### The one thing that does vary
|
|
192
|
+
|
|
193
|
+
| Header | URL | Response changes to | Why |
|
|
194
|
+
| --- | --- | --- | --- |
|
|
195
|
+
| `sec-fetch-dest: document` | a page URL some fragment `pierce`s | the shell with fragments composed into it | A page navigation gets a complete document with fragments already inside. The same URL fetched by a client-side router wants the SPA's own payload, and injecting a declarative shadow root into that would corrupt it. The browser sets this header; it must reach the origin. |
|
|
196
|
+
|
|
197
|
+
Both representations carry `Vary: sec-fetch-dest` — and, because most CDNs honor `Vary` only for
|
|
198
|
+
`Accept-Encoding`, the gateway also rewrites `Cache-Control` on these URLs to keep them out of
|
|
199
|
+
shared caches: `public` and `s-maxage` are dropped, `private` is added, and your own `max-age` /
|
|
200
|
+
`no-store` / `stale-while-revalidate` are untouched. Browser caching still works.
|
|
201
|
+
|
|
202
|
+
Set `pierceCacheControl: 'preserve'` to opt out — only if the pages are anonymous *and* you have
|
|
203
|
+
put `sec-fetch-dest` into the edge's cache key. See [CDN configuration](../../docs/braid-cdn.md).
|
|
204
|
+
|
|
205
|
+
The gateway sends `Vary: sec-fetch-dest` on those page responses.
|
|
206
|
+
|
|
207
|
+
**Why `Vary` at all.** HTTP caches key on the URL. When one URL can return different bodies
|
|
208
|
+
depending on a request header, the cache must include that header in its key or it hands the
|
|
209
|
+
wrong body to the next caller. `Vary` is how the origin says which headers matter — but it is
|
|
210
|
+
advisory, and many CDNs honor it only for `Accept-Encoding`. Since only *page* URLs vary now,
|
|
211
|
+
and page URLs are usually personalized and uncacheable anyway, the practical advice is simply:
|
|
212
|
+
don't edge-cache pierced pages. See [CDN setup](../../docs/braid-cdn.md).
|
|
213
|
+
|
|
214
|
+
## Discovery endpoint (optional)
|
|
215
|
+
|
|
216
|
+
For shells that build their UI from the registry rather than hard-coding slot names — a launcher,
|
|
217
|
+
an admin console, a directory of available apps — the gateway can publish a paginated listing.
|
|
218
|
+
It is **off by default**, because a registry describes internal topology.
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
const gateway = createGateway({
|
|
222
|
+
registry,
|
|
223
|
+
discovery: {
|
|
224
|
+
path: '/__braid/registry', // default
|
|
225
|
+
pageSize: 100, // default; also the ceiling unless maxPageSize says otherwise
|
|
226
|
+
principal: (request) => sessionFrom(request), // → { roles, permissions }
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
GET /__braid/registry?page=2&pageSize=50
|
|
233
|
+
```
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"items": [{ "id": "billing", "title": "Billing", "adapter": "compat", "mount": "/__braid/frag/billing/" }],
|
|
237
|
+
"page": 2, "pageSize": 50, "total": 137, "totalPages": 3, "hasMore": true,
|
|
238
|
+
"protocolVersion": "1"
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Defaults that protect you.** Internal `endpoint` values are withheld unless you set
|
|
243
|
+
`includeEndpoints`. Listings are `no-store` and vary on `cookie`/`authorization`, so a shared
|
|
244
|
+
cache can never serve one caller's listing to another. Page size is capped however large a number
|
|
245
|
+
the caller asks for.
|
|
246
|
+
|
|
247
|
+
**Development mode lists everything** — every fragment, with endpoints, ignoring `access` rules —
|
|
248
|
+
and logs a warning at startup saying so. That is deliberate for local debugging and must not ship;
|
|
249
|
+
the response carries `"unfiltered": true` so a client can tell.
|
|
250
|
+
|
|
251
|
+
## Access: who may list, who may load
|
|
252
|
+
|
|
253
|
+
**Everything is public by default.** A manifest with no `access` is listed for everyone and
|
|
254
|
+
loadable by everyone. Restrict only what needs restricting, and declare it at registration so a
|
|
255
|
+
fragment's own team owns its exposure rather than every host re-deciding it.
|
|
256
|
+
|
|
257
|
+
```jsonc
|
|
258
|
+
{
|
|
259
|
+
"id": "payroll",
|
|
260
|
+
"endpoint": "https://payroll.internal",
|
|
261
|
+
"access": {
|
|
262
|
+
"list": { "roles": ["finance", "admin"] }, // who sees it in the registry
|
|
263
|
+
"fetch": { "roles": ["finance"] } // who may actually load it
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The two rules are independent, which is the point:
|
|
269
|
+
|
|
270
|
+
| `list` | `fetch` | Behavior |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| open | open | the default — a public fragment |
|
|
273
|
+
| open | restricted | shown in a launcher, refused on load; listings mark it `"loadable": false` so the UI can render that state |
|
|
274
|
+
| restricted | open | kept out of listings, still loadable by anyone with a deep link |
|
|
275
|
+
| restricted | restricted | invisible and unreachable — to that caller it does not exist |
|
|
276
|
+
|
|
277
|
+
Within a rule, **roles are any-of** (holding one of them is enough) and **scopes are all-of** (an
|
|
278
|
+
operation requiring two scopes needs both). Declare either or both.
|
|
279
|
+
|
|
280
|
+
Wire up who is asking once, at the gateway:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
createGateway({
|
|
284
|
+
registry,
|
|
285
|
+
principal: (request) => sessionFrom(request), // → { roles, scopes }
|
|
286
|
+
discovery: {},
|
|
287
|
+
});
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`principal` is only consulted for fragments that actually declare `access`, so a public registry
|
|
291
|
+
never pays for a session lookup on asset requests.
|
|
292
|
+
|
|
293
|
+
**Enforcement.** `access.fetch` applies to every namespace request — the realm stub, the
|
|
294
|
+
document, and every asset — and to piercing, where an unauthorized fragment is simply not
|
|
295
|
+
composed into the page (the slot is left empty rather than the page failing). A caller who may
|
|
296
|
+
list but not load gets `403`; a caller who may not even list it gets `404`, because confirming
|
|
297
|
+
existence would turn the namespace into an inventory of what they cannot reach.
|
|
298
|
+
|
|
299
|
+
**Development mode bypasses access rules entirely**, so local work needs no session wiring.
|
|
300
|
+
|
|
301
|
+
This is authorization for *composition*, not a substitute for the fragment's own. The fragment's
|
|
302
|
+
endpoint should still authorize the requests it receives.
|
|
303
|
+
|
|
304
|
+
## Telemetry (optional)
|
|
305
|
+
|
|
306
|
+
Off unless configured. One hook, two kinds of event — what the gateway did, and what the browser
|
|
307
|
+
experienced — both attributed to the fragment responsible.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
createGateway({
|
|
311
|
+
registry,
|
|
312
|
+
telemetry: {
|
|
313
|
+
on: (event) => otel.emit(event),
|
|
314
|
+
webVitals: true, // also collect LCP/CLS/INP/FCP/TTFB in the browser
|
|
315
|
+
sampleRate: 0.1, // of browser sessions, not of metrics
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
| Event | When | Carries |
|
|
321
|
+
| --- | --- | --- |
|
|
322
|
+
| `fragment-fetch` | the gateway fetched a fragment endpoint | `fragmentId`, `phase`, `outcome`, `status`, `durationMs` |
|
|
323
|
+
| `web-vital` | a browser reported a vital | `name`, `value`, `rating`, `fragmentId`, `pathname` |
|
|
324
|
+
|
|
325
|
+
`phase` separates a **pierce** fetch, which delays the page, from a **namespace** fetch, which
|
|
326
|
+
delays only that fragment. `outcome` reports a 5xx as `ok` with its status: the distinction an
|
|
327
|
+
operator needs is *did we reach it*, and folding a reachable-but-broken endpoint in with an
|
|
328
|
+
unreachable one loses it.
|
|
329
|
+
|
|
330
|
+
### Why per-fragment vitals are the point
|
|
331
|
+
|
|
332
|
+
Server-side timings per fragment are ordinary — a reverse proxy gives you those. Web vitals per
|
|
333
|
+
*fragment* are not, because in a composed page the page-level number is an average over apps owned
|
|
334
|
+
by different teams. "The page has a CLS of 0.31" starts an argument; "ninety percent of the layout
|
|
335
|
+
shift happened inside `reviews`" ends it.
|
|
336
|
+
|
|
337
|
+
Attribution works by climbing from the reported element to its enclosing `<fragment-slot>` —
|
|
338
|
+
**through shadow roots**, since a pierced fragment's DOM lives in a declarative shadow root where
|
|
339
|
+
`closest()` stops at the boundary. `fragmentId: null` is a real answer, not a gap: TTFB belongs to
|
|
340
|
+
the document, and an LCP element in the shell's own markup is genuinely the shell's.
|
|
341
|
+
|
|
342
|
+
`webVitals` is off by default because it serves a collector from `/__braid/vitals.js` and injects
|
|
343
|
+
it into every composed page, which is a deployment's decision rather than a library's. The
|
|
344
|
+
collector is ~2 kB, hand-written rather than pulling in `web-vitals`, and beacons to
|
|
345
|
+
`/__braid/vitals` on `visibilitychange`/`pagehide`.
|
|
346
|
+
|
|
347
|
+
**That endpoint is reachable by anyone who can load the page**, so nothing it receives is trusted:
|
|
348
|
+
metric names are checked against the five, values must be finite and positive, the pathname is
|
|
349
|
+
stripped of its query and length-capped, the batch is capped, and a `fragmentId` the registry does
|
|
350
|
+
not know is nulled rather than relayed. It always answers `204` — the browser is unloading and
|
|
351
|
+
nothing is listening, so a status code would only tell a prober what the endpoint accepts.
|
|
352
|
+
|
|
353
|
+
The hook is wrapped: a throwing sink is logged once and then muted, because a feature meant to be
|
|
354
|
+
left on in production must not be able to take the site down. (`observe` deliberately does *not*
|
|
355
|
+
do this — a broken analysis hook should fail loudly, since silently recording nothing corrupts the
|
|
356
|
+
decisions its data feeds.)
|
|
357
|
+
|
|
358
|
+
### What the listing publishes
|
|
359
|
+
|
|
360
|
+
Each entry carries what a consumer needs to *embed* the fragment, not just name it: `id`, `title`,
|
|
361
|
+
`adapter`, `mount`, `pierce`, `loadable`, and — because they change the required markup — `bound`
|
|
362
|
+
and `src`. A widget embedded without its `src` renders an empty shell on every page, so a listing
|
|
363
|
+
that omitted it would force every consumer to guess. `endpoint` appears only when
|
|
364
|
+
`includeEndpoints` is on, or in development.
|
|
365
|
+
|
|
366
|
+
The registry console uses exactly this to generate copy-pasteable integration code per fragment.
|
|
367
|
+
|
|
368
|
+
## Circuit breaker (optional)
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
createGateway({ registry, breaker: true }); // 3 failures, 10s cooldown
|
|
372
|
+
createGateway({ registry, breaker: { failureThreshold: 5, resetTimeoutMs: 30_000 } });
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**The problem is compounding, not failure.** A fragment endpoint that is down already costs its
|
|
376
|
+
full `timeoutMs` — but it costs that on *every* request, because nothing remembers the last one.
|
|
377
|
+
Four fragments at a 3s budget is a page held hostage to the worst of them, repeatedly, with the
|
|
378
|
+
shell's response waiting behind it. The fallback machinery already handles a fragment that fails;
|
|
379
|
+
what it cannot do is stop paying to rediscover that it fails.
|
|
380
|
+
|
|
381
|
+
So this is a **latency control first, an availability control second**. When the circuit is open
|
|
382
|
+
the manifest's declared fallback is served immediately instead of after the budget expires — the
|
|
383
|
+
rendered result is identical, it just arrives in ~0ms.
|
|
384
|
+
|
|
385
|
+
Per fragment id, never global: fragments are independently deployed, and one team's bad release
|
|
386
|
+
must not shed another team's traffic. A 5xx counts as a failure (reachable is not the same as
|
|
387
|
+
working). A shed request reports `outcome: 'shed'` rather than `'error'`, so an operator counting
|
|
388
|
+
failures does not count our own load-shedding as the endpoint failing again. Transitions arrive on
|
|
389
|
+
the telemetry hook as `kind: 'breaker'` — on the four state edges only, never per request.
|
|
390
|
+
|
|
391
|
+
Off unless configured, because shedding load is a behaviour change a deployment should opt into.
|
|
392
|
+
|
|
393
|
+
## Concurrent-fetch coalescing (on by default)
|
|
394
|
+
|
|
395
|
+
Composing a page costs one origin fetch per fragment. A widget pierced into `/` and `/*` is fetched
|
|
396
|
+
once per render, so fifty concurrent renders are fifty identical fetches of the same header panel.
|
|
397
|
+
The gateway collapses those into one.
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
createGateway({ registry }); // on
|
|
401
|
+
createGateway({ registry, coalesceFragmentFetches: false }); // off
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**Not a cache.** It only notices that a fetch it is already making is the one another request
|
|
405
|
+
wants; nothing outlives the request that started it. No TTL, no invalidation, no shared state, and
|
|
406
|
+
no dependence on which instance a request lands on — which is what makes it safe by default on ECS
|
|
407
|
+
or any horizontally scaled deployment.
|
|
408
|
+
|
|
409
|
+
Two requests share a fetch only when their `cookie`, `authorization`, `user-agent`, and negotiation
|
|
410
|
+
headers match exactly, and a fragment declaring `access` rules is never shared at all. So this helps
|
|
411
|
+
anonymous and shared-identity traffic and does nothing for personalized fragments — two signed-in
|
|
412
|
+
users never share a flight. That is correct rather than a limitation: sharing a render across
|
|
413
|
+
identities is a data leak wearing a performance feature's clothes.
|
|
414
|
+
|
|
415
|
+
For an endpoint that varies on something the gateway cannot see — a tenant or feature-flag header —
|
|
416
|
+
opt out on the manifest with `coalesce: false`.
|
|
417
|
+
|
|
418
|
+
How much it saves scales with endpoint latency × concurrency. Against a 120ms endpoint, ten
|
|
419
|
+
parallel renders collapse to one fetch; against a fragment on localhost answering in 10ms the
|
|
420
|
+
requests barely overlap and it saves nothing. Measure somewhere that resembles production.
|
|
421
|
+
|
|
422
|
+
## Content Security Policy
|
|
423
|
+
|
|
424
|
+
The gateway injects inline markup into someone else's document: the `<style>` that makes slots lay
|
|
425
|
+
out as blocks, and — when `webVitals` is on — the collector `<script>`. Under a strict policy
|
|
426
|
+
(`script-src 'nonce-…'`) the browser drops anything unstamped **silently**, which is the worst
|
|
427
|
+
failure shape available: the page renders, the slot layout rule is gone, and no server log mentions
|
|
428
|
+
it.
|
|
429
|
+
|
|
430
|
+
So the gateway reads the nonce from the shell's *own* `Content-Security-Policy` response header and
|
|
431
|
+
stamps what it injects. Nothing to configure — it works if your shell already sends a nonce.
|
|
432
|
+
|
|
433
|
+
It never mints one. A nonce the shell's policy does not list is not trusted, and a nonce reused
|
|
434
|
+
across responses is not a nonce. A shell with no policy, or one using hashes or `'unsafe-inline'`,
|
|
435
|
+
gets unstamped markup, which is correct for all three.
|
|
436
|
+
|
|
437
|
+
## Security posture
|
|
438
|
+
|
|
439
|
+
The trusted tier is **namespace isolation, not a security boundary**: fragments are same-origin
|
|
440
|
+
with the host and share its cookies, storage, and DOM reachability. What the gateway does
|
|
441
|
+
guarantee is that nothing a fragment sends can execute JavaScript in the *host realm* or
|
|
442
|
+
navigate the host page — scripts are neutralized, inline `on*` handlers are stripped, and
|
|
443
|
+
`<meta http-equiv="refresh">` is defanged. Fragment code runs in the fragment's realm or not at
|
|
444
|
+
all.
|
|
445
|
+
|
|
446
|
+
Deliberately not neutralized, because they require a user to act rather than executing on parse:
|
|
447
|
+
`javascript:` URLs and form `action`s. A trusted fragment can navigate a page the user clicks
|
|
448
|
+
through.
|
|
449
|
+
|
|
450
|
+
Defaults worth knowing: `x-forwarded-proto`/`x-forwarded-host` are **overwritten** from the real
|
|
451
|
+
request (opt into passthrough with `trustForwardedHeaders` only behind a proxy you control); an
|
|
452
|
+
endpoint's path is a boundary, so `endpoint: 'https://internal/apps/billing/'` cannot be used to
|
|
453
|
+
reach the rest of that origin; and every request header — including `Cookie` — is forwarded to
|
|
454
|
+
fragment endpoints, so treat a manifest entry as granting that endpoint the user's session.
|
|
455
|
+
|
|
456
|
+
Not yet implemented from the architecture's security section: allow-listed manifest origins and
|
|
457
|
+
signed manifests. Until those land, treat the registry as trusted configuration.
|
|
458
|
+
|
|
459
|
+
**The rewriter is owned, not forked.** `rewriteHtmlStream` is a small streaming HTML
|
|
460
|
+
rewriter with bounded memory and chunk-boundary safety; its conformance vectors
|
|
461
|
+
(`html-rewrite-stream.spec.ts`) are the oracle any second implementation — a native
|
|
462
|
+
`HTMLRewriter` path on workerd, say — must pass before it is allowed to serve traffic. Only the
|
|
463
|
+
owned path ships today, because `HTMLRewriter` does not exist in Node and an untested dual path
|
|
464
|
+
is how the upstream project got wasm/native drift.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@braidlabs/gateway",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"micro-frontends",
|
|
7
|
+
"gateway",
|
|
8
|
+
"composition",
|
|
9
|
+
"fragments",
|
|
10
|
+
"middleware"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24"
|
|
15
|
+
},
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./src/index.js",
|
|
19
|
+
"types": "./src/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./src/index.d.ts",
|
|
23
|
+
"default": "./src/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./node": {
|
|
26
|
+
"types": "./src/node.d.ts",
|
|
27
|
+
"default": "./src/node.js"
|
|
28
|
+
},
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/braidjs/braid.git",
|
|
37
|
+
"directory": "libs/braid-gateway"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/braidjs/braid#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/braidjs/braid/issues"
|
|
42
|
+
},
|
|
43
|
+
"module": "./src/index.js"
|
|
44
|
+
}
|
package/src/appd.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ResolvedFragmentManifest } from './registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Projecting the fragment registry as an FDC3 **App Directory**.
|
|
4
|
+
*
|
|
5
|
+
* The registry already is one. It holds ids, titles, descriptions, tags, and — for fragments that
|
|
6
|
+
* declare them — the intents each app handles and raises. It is already paginated and already
|
|
7
|
+
* filtered by who is asking. AppD is a *shape*, not a second source of truth, so this is a
|
|
8
|
+
* projection over the same data and the same access rules rather than a parallel service to keep
|
|
9
|
+
* in sync.
|
|
10
|
+
*
|
|
11
|
+
* The payoff for FDC3 is direct: `findIntent` becomes a registry query, and because discovery
|
|
12
|
+
* filters by the caller's principal, a user only ever sees resolvers they are permitted to use.
|
|
13
|
+
*
|
|
14
|
+
* **Verify the record shape against the FDC3 AppD v2 spec before relying on it.** The mapping below
|
|
15
|
+
* is faithful to the standard as understood here, but the schema has more optional members than
|
|
16
|
+
* this uses and is the kind of thing that is easy to be subtly wrong about.
|
|
17
|
+
*/
|
|
18
|
+
/** The `fdc3` block a manifest may carry. A superset of what AppD needs; the rest is for the runtime. */
|
|
19
|
+
export interface FragmentFdc3Metadata {
|
|
20
|
+
/** The FDC3 API surface this app speaks, e.g. `"2.2"`. Reserved for the runtime work. */
|
|
21
|
+
apiVersion?: string;
|
|
22
|
+
/** Context schema versions this app reads and writes. Reserved for the runtime work. */
|
|
23
|
+
contexts?: Record<string, number>;
|
|
24
|
+
/** Intents this app handles, and the context types each accepts. */
|
|
25
|
+
listensFor?: Record<string, {
|
|
26
|
+
contexts?: string[];
|
|
27
|
+
displayName?: string;
|
|
28
|
+
resultType?: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** Intents this app raises, and the context types it raises them with. */
|
|
31
|
+
raises?: Record<string, string[]>;
|
|
32
|
+
/** User channel this app joins on mount. */
|
|
33
|
+
defaultChannel?: string;
|
|
34
|
+
/** Context types broadcast and listened for on user channels. */
|
|
35
|
+
userChannels?: {
|
|
36
|
+
broadcasts?: string[];
|
|
37
|
+
listensFor?: string[];
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Descriptive fields AppD carries that the registry does not otherwise need. */
|
|
41
|
+
export interface FragmentAppdMetadata {
|
|
42
|
+
publisher?: string;
|
|
43
|
+
contactEmail?: string;
|
|
44
|
+
supportEmail?: string;
|
|
45
|
+
moreInfo?: string;
|
|
46
|
+
version?: string;
|
|
47
|
+
tooltip?: string;
|
|
48
|
+
icons?: {
|
|
49
|
+
src: string;
|
|
50
|
+
size?: string;
|
|
51
|
+
type?: string;
|
|
52
|
+
}[];
|
|
53
|
+
}
|
|
54
|
+
export interface AppdApplication {
|
|
55
|
+
appId: string;
|
|
56
|
+
name: string;
|
|
57
|
+
type: 'web';
|
|
58
|
+
details: {
|
|
59
|
+
url: string;
|
|
60
|
+
};
|
|
61
|
+
title?: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
tooltip?: string;
|
|
64
|
+
version?: string;
|
|
65
|
+
categories?: string[];
|
|
66
|
+
publisher?: string;
|
|
67
|
+
contactEmail?: string;
|
|
68
|
+
supportEmail?: string;
|
|
69
|
+
moreInfo?: string;
|
|
70
|
+
icons?: {
|
|
71
|
+
src: string;
|
|
72
|
+
size?: string;
|
|
73
|
+
type?: string;
|
|
74
|
+
}[];
|
|
75
|
+
interop?: {
|
|
76
|
+
intents?: {
|
|
77
|
+
listensFor?: Record<string, {
|
|
78
|
+
name: string;
|
|
79
|
+
displayName?: string;
|
|
80
|
+
contexts: string[];
|
|
81
|
+
resultType?: string;
|
|
82
|
+
}>;
|
|
83
|
+
raises?: Record<string, string[]>;
|
|
84
|
+
};
|
|
85
|
+
userChannels?: {
|
|
86
|
+
broadcasts?: string[];
|
|
87
|
+
listensFor?: string[];
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Braid-specific launch information.
|
|
92
|
+
*
|
|
93
|
+
* `hostManifests` is exactly what AppD reserves for container-specific detail, and this is the
|
|
94
|
+
* honest place for it: a Braid fragment is **mounted into a page**, not opened as a window. An
|
|
95
|
+
* agent that understands Braid reads this and mounts a `<fragment-slot>`; one that does not
|
|
96
|
+
* falls back to `details.url` and gets something reasonable.
|
|
97
|
+
*/
|
|
98
|
+
hostManifests: {
|
|
99
|
+
braid: {
|
|
100
|
+
fragmentId: string;
|
|
101
|
+
/** Where the fragment is addressable — what `<fragment-slot name>` resolves to. */
|
|
102
|
+
mount: string;
|
|
103
|
+
adapter: string;
|
|
104
|
+
/** True when `details.url` is a real page the fragment appears on rather than its mount. */
|
|
105
|
+
standalonePage: boolean;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export interface AppdListResponse {
|
|
110
|
+
applications: AppdApplication[];
|
|
111
|
+
message: string;
|
|
112
|
+
}
|
|
113
|
+
export interface AppdAppResponse {
|
|
114
|
+
application: AppdApplication;
|
|
115
|
+
message: string;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Projects one manifest into an AppD record.
|
|
119
|
+
*
|
|
120
|
+
* `details.url` is the interesting decision. AppD asks where a web app lives, and a fragment does
|
|
121
|
+
* not live anywhere on its own — it appears inside a host page. So:
|
|
122
|
+
*
|
|
123
|
+
* - if the fragment declares `pierce`, the first pattern names a page it actually appears on, and
|
|
124
|
+
* that page *is* the answer: an operator following the link sees the app;
|
|
125
|
+
* - otherwise the fragment's mount is used, which is honest but is a namespace path rather than a
|
|
126
|
+
* page, and `hostManifests.braid.standalonePage` says so.
|
|
127
|
+
*
|
|
128
|
+
* Either way the Braid-aware path is `hostManifests`, because "open this URL" is the wrong verb.
|
|
129
|
+
*/
|
|
130
|
+
export declare function toAppdApplication(manifest: ResolvedFragmentManifest, origin: string): AppdApplication;
|