@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.
@@ -0,0 +1,241 @@
1
+ import { Principal, RegistrySource } from './registry.js';
2
+ import { DiscoveryOptions } from './discovery.js';
3
+ import { type BreakerOptions } from './breaker.js';
4
+ import { type TelemetryOptions } from './telemetry.js';
5
+ /**
6
+ * Gateway core: fetch-native, platform-neutral origin-front middleware.
7
+ *
8
+ * Two responsibilities:
9
+ *
10
+ * 1. **Namespace routing**: requests under `/__braid/frag/:id/*` address a fragment by id,
11
+ * exactly — realm stubs, assets, and data. No pattern matching, no request sniffing.
12
+ * 2. **Piercing**: for document requests whose page URL a fragment declares in its
13
+ * `pierce` patterns, the fragment's server-rendered HTML is interleaved into the shell's
14
+ * response stream, so fragments paint with the shell's first response.
15
+ *
16
+ * Everything else passes through to the shell untouched.
17
+ */
18
+ export interface GatewayOptions {
19
+ /** The fragment registry: inline manifests, a JSON URL, or an async loader. */
20
+ registry: RegistrySource;
21
+ /** 'development' enables verbose error bodies; defaults to 'development'. */
22
+ mode?: 'production' | 'development';
23
+ /** Additional headers set on every request forwarded to a fragment endpoint. */
24
+ additionalHeaders?: Record<string, string>;
25
+ /**
26
+ * Publishes a paginated listing of the fragments this gateway serves, for shells that build
27
+ * their UI from the registry instead of hard-coding slot names.
28
+ *
29
+ * Off unless configured: a registry describes internal topology, so exposing it is a choice.
30
+ * See {@link DiscoveryOptions}.
31
+ */
32
+ discovery?: DiscoveryOptions;
33
+ /**
34
+ * Resolves who is asking, for manifests that declare `access` rules.
35
+ *
36
+ * Wire it to whatever your app already uses for sessions. It is only called for fragments that
37
+ * actually restrict something, so fully public registries never pay for it — and a fragment
38
+ * with `access` rules but no resolver treats every caller as anonymous.
39
+ */
40
+ principal?: (request: Request) => Principal | undefined | Promise<Principal | undefined>;
41
+ /**
42
+ * Whether to pass an incoming `x-forwarded-proto` / `x-forwarded-host` through to fragment
43
+ * endpoints instead of overwriting them from the request's own URL. Defaults to false.
44
+ *
45
+ * Leave it off unless a proxy you control is the only way requests reach this gateway.
46
+ * Otherwise a client can name any host it likes, and a fragment that builds absolute URLs
47
+ * from those headers (reset links, cache keys, redirects) will build them for the attacker's
48
+ * host.
49
+ */
50
+ trustForwardedHeaders?: boolean;
51
+ /**
52
+ * What to do with `Cache-Control` on a page URL some fragment declares in `pierce`.
53
+ *
54
+ * - `'private'` (default) — keep it out of shared caches: `public` and `s-maxage` are dropped
55
+ * and `private` is added. The browser may still cache it.
56
+ * - `'preserve'` — leave the shell's own headers untouched.
57
+ *
58
+ * The default exists because such a URL has two representations (composed for a navigation,
59
+ * plain for a soft-navigation fetch) and `Vary: sec-fetch-dest` is not enough to protect them:
60
+ * most CDNs, Cloudflare among them, honor `Vary` only for `Accept-Encoding`. A shared cache
61
+ * that ignores it will store one representation and serve it as the other — the fragment
62
+ * silently vanishes from the page, or appears in a payload a router is trying to parse as JSON.
63
+ * Composed pages are also usually personalized, and caching one freezes the fragment's HTML at
64
+ * the shell's TTL, so a fragment deploy stays invisible until the page expires.
65
+ *
66
+ * Choose `'preserve'` only when the pages are genuinely anonymous **and** you have put
67
+ * `sec-fetch-dest` into the edge's cache key. See `docs/braid-cdn.md`.
68
+ */
69
+ pierceCacheControl?: 'private' | 'preserve';
70
+ /**
71
+ * Called for every document request that reaches the shell, with which fragments composed into
72
+ * it. This is what makes traffic-informed impact analysis possible: "after this change, 43 of
73
+ * the 412 page URLs we actually serve stop composing billing".
74
+ *
75
+ * **On the request path, so it must be cheap.** It is called synchronously and never awaited;
76
+ * a sink that does real work should buffer and flush elsewhere. It is also called *outside* any
77
+ * try/catch — a throwing observer will fail the request, which is the honest behavior for a
78
+ * broken observer rather than one that silently records nothing.
79
+ *
80
+ * Off unless configured. Recording the paths a site serves is a data-retention decision, not a
81
+ * default: paths carry identifiers, and sometimes personal ones.
82
+ */
83
+ observe?: (event: RoutingEvent) => void;
84
+ /**
85
+ * Report what the gateway did, and optionally what the browser experienced — per fragment.
86
+ *
87
+ * Distinct from {@link GatewayOptions.observe}, which records *which* fragments composed where
88
+ * so a registry change can be analysed offline. This one records how they *behaved*: fetch
89
+ * outcomes and durations on the server, and web vitals in the browser attributed to the fragment
90
+ * whose subtree they happened in.
91
+ *
92
+ * Off unless configured. Unlike `observe`, the hook is wrapped — a throwing telemetry sink is
93
+ * reported once and then ignored, because a feature meant to be left on in production must not
94
+ * be able to take the site down with it.
95
+ */
96
+ telemetry?: TelemetryOptions;
97
+ /**
98
+ * Stop paying a broken fragment's timeout on every request.
99
+ *
100
+ * A fragment endpoint that is down already costs its full `timeoutMs`; without a breaker it
101
+ * costs that *again* on the next request, and the one after. This converts a slow repeated
102
+ * failure into a fast one — the fallback the manifest already declares is served immediately
103
+ * instead of after the budget expires.
104
+ *
105
+ * `true` takes the defaults (3 consecutive failures, 10s cooldown). Off unless configured,
106
+ * because shedding load is a behaviour change and a deployment should opt into it knowingly.
107
+ */
108
+ breaker?: boolean | Partial<BreakerOptions>;
109
+ /**
110
+ * Collapse concurrent identical fragment fetches into one.
111
+ *
112
+ * A CDN in front of this gateway caches the composed document and does nothing for the origin
113
+ * fetches a cache *miss* still costs. An unbound widget pierced into every page is fetched once
114
+ * per render; fifty concurrent renders are fifty identical fetches. This makes them one.
115
+ *
116
+ * Not a cache: nothing outlives the request that started it, so there is no TTL, no
117
+ * invalidation, and no dependence on which instance a request lands on — which is what makes it
118
+ * safe on ECS or any horizontally scaled deployment.
119
+ *
120
+ * Requests only share a fetch when their `cookie`, `authorization`, `user-agent`, and
121
+ * negotiation headers match exactly, so personalized fragments are never shared between users,
122
+ * and a fragment declaring `access` rules is never shared at all.
123
+ *
124
+ * **On by default**, because it changes how many times an identical fetch is made and nothing
125
+ * else: no response is stored, reused later, or shared across identities. A fragment whose
126
+ * endpoint varies on something the gateway cannot see sets `coalesce: false` on its manifest;
127
+ * set this to `false` to turn the whole mechanism off.
128
+ */
129
+ coalesceFragmentFetches?: boolean;
130
+ /**
131
+ * Serve Braid's service worker from `/__braid/sw.js`. Off by default.
132
+ *
133
+ * Worth doing here for a reason that is not convenience. **A worker's scope is capped by the path
134
+ * it is served from**, so a script at `/__braid/sw.js` defaults to controlling `/__braid/` — which
135
+ * would intercept fragment assets but not the shell's own, making it useless for the chunk-failure
136
+ * case that matters most. Widening it needs a `Service-Worker-Allowed` header on the script
137
+ * response, and the gateway is the one component already sitting in front of the origin that can
138
+ * send it without additional infrastructure configuration.
139
+ *
140
+ * The default scope is `/`. That sounds broad and costs nothing: scope precedence is
141
+ * longest-match, so a worker registered at `/legacy/` still controls clients under `/legacy/`
142
+ * whatever Braid claims — the root is a fallback, not an exclusion, and the handler ignores
143
+ * everything outside the namespace anyway. The override exists for a gateway mounted under a path
144
+ * on an origin it does not own.
145
+ */
146
+ serviceWorker?: boolean | ServiceWorkerOptions;
147
+ }
148
+ export interface ServiceWorkerOptions {
149
+ /** The scope to claim, sent as `Service-Worker-Allowed`. Defaults to `/`. */
150
+ scope?: string;
151
+ /**
152
+ * The module the generated worker imports `setupBraidWorker` from.
153
+ *
154
+ * A URL your origin serves — the worker runs in its own realm and cannot resolve a bare specifier.
155
+ * Defaults to `/braid-sw.js`, which is where a build that copies `@braidlabs/sw` normally
156
+ * lands it.
157
+ */
158
+ module?: string;
159
+ /** Fragment ids whose realm stubs the worker precaches at install. */
160
+ precache?: readonly string[];
161
+ /** Stamped into the worker so it can report disagreement with the page it is serving. */
162
+ buildId?: string;
163
+ }
164
+ /**
165
+ * One document request, and what composed into it.
166
+ *
167
+ * Emitted for *every* document request the shell handles, not only pierce-matched ones. A path
168
+ * that composes nothing today is exactly the path that a widened pattern would start composing
169
+ * tomorrow, and analysis needs both sides to report a gain.
170
+ */
171
+ export interface RoutingEvent {
172
+ /** Page pathname. Search is excluded — it never affects pierce matching. */
173
+ pathname: string;
174
+ /** Fragment ids that composed into this response, in registration order. */
175
+ fragmentIds: string[];
176
+ at: number;
177
+ }
178
+ export interface BraidGateway {
179
+ /**
180
+ * Handles a request if it belongs to Braid: a fragment-namespace request, or a document
181
+ * request that pierces one or more fragments. Returns null for everything else — the caller
182
+ * passes those through to the shell.
183
+ *
184
+ * @param next fetches the shell application's response. Required for piercing; without it,
185
+ * document requests are passed through and fragments boot client-side instead.
186
+ */
187
+ handle(request: Request, next?: () => Promise<Response>): Promise<Response | null>;
188
+ /**
189
+ * Resolves a websocket upgrade addressed to a fragment.
190
+ *
191
+ * Dev servers push reloads over websockets, and live apps use them for real work. Both are
192
+ * addressed through the fragment namespace, so the gateway has to say where they go — the
193
+ * socket plumbing itself belongs to the platform binding.
194
+ *
195
+ * @returns the endpoint URL to dial, or null when the request is not a fragment upgrade, the
196
+ * fragment is unknown, or the caller may not load it.
197
+ */
198
+ resolveUpgrade(request: Request): Promise<{
199
+ fragmentId: string;
200
+ target: URL;
201
+ } | null>;
202
+ }
203
+ export declare function createGateway(options: GatewayOptions): BraidGateway;
204
+ /**
205
+ * Wraps a gateway as web middleware: braid requests are handled — including document requests
206
+ * that pierce fragments into the shell — and everything else goes to the shell via `next()`.
207
+ *
208
+ * `next` is memoized, so the shell application runs at most once per request no matter how the
209
+ * gateway and the caller interleave.
210
+ */
211
+ export declare function toWebMiddleware(gateway: BraidGateway): (request: Request, next: () => Promise<Response>) => Promise<Response>;
212
+ /**
213
+ * Wraps a fetch handler — the shell application as `(Request) => Response` — with the gateway.
214
+ *
215
+ * This is the binding for every web-standard runtime: Cloudflare Workers, Deno, Bun, and
216
+ * h3/Nitro (via `toWebHandler(app)`). It is also the only in-process way to pierce on those
217
+ * runtimes, because piercing needs to *read* the shell's response, which a middleware chain
218
+ * with no return value cannot give it.
219
+ *
220
+ * ```ts
221
+ * // h3 / Nitro
222
+ * import { toWebHandler } from 'h3';
223
+ * export default toFetchHandler(gateway, toWebHandler(app));
224
+ * ```
225
+ */
226
+ export declare function toFetchHandler(gateway: BraidGateway, appHandler: (request: Request) => Response | Promise<Response>): (request: Request) => Promise<Response>;
227
+ /**
228
+ * Resolves a namespace-stripped request against a fragment's endpoint, **within the endpoint's
229
+ * own path**.
230
+ *
231
+ * `new URL('/admin', 'https://internal/apps/billing/')` yields `https://internal/admin`: an
232
+ * absolute path replaces the endpoint's path entirely. Left alone, that turns the gateway into
233
+ * a proxy for the endpoint host's whole origin rather than the subtree the manifest declared.
234
+ * So the endpoint's path is treated as a prefix.
235
+ *
236
+ * The containment check afterwards is belt-and-braces: dot segments, including percent-encoded
237
+ * ones (`%2e%2e`), are already normalized when the incoming request URL is parsed, which takes
238
+ * such a request out of the fragment namespace entirely before it ever reaches here. The check
239
+ * costs nothing and does not assume every runtime normalizes identically.
240
+ */
241
+ export declare function resolveEndpointUrl(endpoint: string, strippedUrl: URL, fragmentId: string): URL;