@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 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;