@bgub/fig-server 0.1.0-alpha.0 → 0.1.0-alpha.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @bgub/fig-server
2
2
 
3
- Fig server renderer.
3
+ Fig server renderer for HTML and Payload component trees.
4
4
 
5
5
  ## Installation
6
6
 
@@ -109,18 +109,65 @@ Pass `identifierPrefix` when multiple streaming renders share a document. Fig
109
109
  uses it in generated Suspense marker and script identifiers, and it defaults to
110
110
  an empty string.
111
111
 
112
+ Streams respect consumer backpressure: once the stream's internal queue holds
113
+ `highWaterMark` encoded bytes (default 65536), completed Suspense content
114
+ waits in segment form and flushes as the consumer reads. Rendering itself
115
+ never pauses, so `shellReady`/`allReady` settle at the same time regardless of
116
+ how fast the stream is consumed.
117
+
118
+ ## Content Security Policy
119
+
120
+ Every script Fig streams is inline: the Suspense reveal runtime, the
121
+ per-boundary reveal ops, and the early event-capture script that opens a
122
+ document `<head>`. Under a CSP that restricts `script-src`, generate a
123
+ per-request nonce, pass it as the `nonce` option, and allow it in the header —
124
+ Fig adds it to every inline script and to emitted `<script>`/`<link>` resource
125
+ tags:
126
+
127
+ ```ts
128
+ const nonce = crypto.randomUUID();
129
+ const result = renderToDocumentStream(<App />, { nonce });
130
+
131
+ return new Response(result.stream, {
132
+ headers: {
133
+ "content-type": result.contentType,
134
+ "content-security-policy": `script-src 'self' 'nonce-${nonce}'`,
135
+ },
136
+ });
137
+ ```
138
+
139
+ The nonce is the whole CSP story. Fig intentionally ships no external-runtime
140
+ alternative for nonce-less strict CSP (per-render op scripts also rule out
141
+ static `script-src` hashes), so streamed Suspense requires the nonce. If you
142
+ cannot attach one, use `prerender`: fragment mode emits no scripts at all, and
143
+ document mode emits only the nonce-carrying early event-capture script.
144
+
145
+ Framework adapters that add companion markup use the focused HTML helpers:
146
+
147
+ ```ts
148
+ import {
149
+ escapeAttribute,
150
+ escapeScriptJson,
151
+ escapeScriptText,
152
+ escapeText,
153
+ } from "@bgub/fig-server/html";
154
+ ```
155
+
156
+ `escapeScriptText` preserves raw script content while preventing a literal
157
+ `</script>` terminator; `escapeScriptJson` serializes a value and applies the
158
+ same protection.
159
+
112
160
  ## Data Resources
113
161
 
114
- Server render requests have their own data-resource store. Reads through
115
- `@bgub/fig` dedupe by key within the request and fulfilled entries are
162
+ Server render requests have their own data-resource store by default. Reads
163
+ through `@bgub/fig` dedupe by key within the request and fulfilled entries are
116
164
  available through `result.getData()`:
117
165
 
118
166
  ```tsx
119
- import { readData } from "@bgub/fig";
120
- import { serverDataResource } from "@bgub/fig/server";
167
+ import { dataResource, readData } from "@bgub/fig";
121
168
  import { renderToStream } from "@bgub/fig-server";
122
169
 
123
- const userResource = serverDataResource<[string], { name: string }>({
170
+ const userResource = dataResource<[string], { name: string }>({
124
171
  key: (id) => ["user", id],
125
172
  load: async (id, { signal }) => fetchUser(id, signal),
126
173
  });
@@ -136,6 +183,22 @@ await result.allReady;
136
183
  const initialData = result.getData();
137
184
  ```
138
185
 
186
+ Adapters that run loaders before rendering can create the store themselves.
187
+ The renderer adopts that exact store instead of copying its entries:
188
+
189
+ ```tsx
190
+ import { createDataStore } from "@bgub/fig";
191
+ import { renderToStream } from "@bgub/fig-server";
192
+
193
+ const dataStore = createDataStore();
194
+ await dataStore.ensureData(userResource, "one");
195
+
196
+ const result = renderToStream(<Profile id="one" />, { dataStore });
197
+ result.data === dataStore; // true
198
+ ```
199
+
200
+ One renderer may adopt a store, and that renderer owns its lifetime.
201
+
139
202
  Pass `initialData` to `createRoot(...)` or `hydrateRoot(...)` on the client to
140
203
  hydrate those values by key. Client imports can use a loader-less resource with
141
204
  the same key; if no client loader exists, `refreshData(...)` reports
@@ -162,10 +225,10 @@ and injects `title()` and `meta()` before `</head>`. With the lower-level
162
225
  `renderToStream`, those tags are available through `result.getHead()`
163
226
  after `headReady`; they are never emitted into segment HTML. `headReady`
164
227
  resolves with the shell and seals the initial document head; `getHead()` keeps
165
- returning that sealed snapshot afterward. If a new head resource is found
166
- later, such as behind pending Suspense, Fig reports it through `onAssetError`
167
- instead of adding it to the already-flushed head, so required shell metadata
168
- should render before `headReady`.
228
+ returning that sealed snapshot afterward. Metadata discovered later stays staged
229
+ with its Suspense content. When that boundary reveals, Fig replaces the visible
230
+ fallback metadata with the completed branch's title/meta in the same reveal
231
+ operation; failed or abandoned branches never mutate the document head.
169
232
 
170
233
  ```ts
171
234
  const result = renderToStream(<Page />);
@@ -190,12 +253,11 @@ function Page() {
190
253
  }
191
254
  ```
192
255
 
193
- Metadata discovered before `headReady` is injected into the initial document
194
- head. Metadata discovered after `headReady`, for example inside pending
195
- Suspense content, is reported through `onAssetError` and is not added to the
196
- already-flushed shell. Stylesheets discovered for later Suspense segments remain
197
- stream-safe: Fig emits them near the segment and gates reveal until they load
198
- unless `{ blocking: "none" }` opts out.
256
+ Metadata for the shell's visible branch is injected into the initial document
257
+ head. Metadata discovered in pending Suspense content is carried by its final
258
+ boundary reveal; partial segment fills do not publish it. Stylesheets discovered
259
+ for later Suspense segments remain stream-safe: Fig emits them near the segment
260
+ and gates reveal until they load unless `{ blocking: "none" }` opts out.
199
261
 
200
262
  Stylesheets, preloads, fonts, preconnects, and scripts are body-stream-safe
201
263
  resources. Fig hoists them before the HTML segment that depends on them and
@@ -203,13 +265,13 @@ dedupes identical resources. Stylesheets block streamed Suspense reveals by
203
265
  default; pass `{ blocking: "none" }` to opt out for non-critical styles.
204
266
 
205
267
  Resource duplicates are checked by key and behavior. Identical duplicates dedupe
206
- silently. Conflicting duplicates throw: for example, the same stylesheet `href`
207
- with a different `media`, the same `title` key with a different value, or the
208
- same meta `name` with different `content`. Preloads are keyed by `href` plus
209
- `as`, so the same URL can be preloaded for distinct targets, but behavior fields
210
- such as `type`, `crossOrigin`, and `fetchPriority` must match for duplicates.
211
- Conflict errors include the resource key plus the existing and incoming
212
- resources.
268
+ silently. Conflicting delivery assets throw: for example, the same stylesheet
269
+ `href` with a different `media`. Title/meta instead use visible-owner claims, so
270
+ a revealed primary branch can replace its fallback and releasing a claim can
271
+ restore the previous value. Preloads are keyed by `href` plus `as`, so the same
272
+ URL can be preloaded for distinct targets, but behavior fields such as `type`,
273
+ `crossorigin`, and `fetchpriority` must match for duplicates. Conflict errors
274
+ include the resource key plus the existing and incoming resources.
213
275
 
214
276
  The `assets` option can attach asset resources to component modules without
215
277
  wrapping the component tree. It is a string-keyed record intended for
@@ -228,59 +290,24 @@ renderToStream(<Page />, {
228
290
  Manifest assets use the same registry, destination rules, dedupe checks, and
229
291
  Suspense reveal gating as explicit `assets(...)` wrappers.
230
292
 
231
- ## Payload (server components)
293
+ ## Payload components
232
294
 
233
295
  ```ts
234
- import {
235
- createPayloadResponse,
236
- decodePayloadValue,
237
- encodePayloadValue,
238
- fetchPayload,
239
- jsonPayloadCodec,
240
- PAYLOAD_BOUNDARY_HEADER,
241
- renderToPayloadStream,
242
- PayloadBoundary,
243
- PayloadFetchError,
244
- type PayloadCodec,
245
- } from "@bgub/fig-server/payload";
246
- ```
247
-
248
- `renderToPayloadStream(node, options?)` renders a server-component payload. The
249
- result exposes `abort(reason?)`, and `options.signal` also cancels the render;
250
- both paths reject `allReady`. The default `jsonPayloadCodec` writes one
251
- readable JSON row per newline and identifies itself with
252
- `text/x-fig-payload; codec=json; charset=utf-8`. Pass a custom `PayloadCodec`
253
- to both `renderToPayloadStream(node, { codec })` and
254
- `createPayloadResponse({ codec })` when both ends should use a different byte
255
- encoding. Codec ids are implementation ids, not stable public wire formats.
256
-
257
- Pass `refreshBoundary` to render a targeted boundary refresh:
258
-
259
- ```tsx
260
- renderToPayloadStream(<FeedItems />, { refreshBoundary: "feed" });
296
+ import { renderToPayloadStream } from "@bgub/fig-server/payload";
261
297
  ```
262
298
 
263
- The rendered node must be the replacement content for that boundary. Do not
264
- include a nested `<PayloadBoundary id="feed">` wrapper in the refresh payload.
265
-
266
- `createPayloadResponse()` decodes streamed rows on the client, and
267
- `fetchPayload(response, input, options?)` fetches and processes a payload. Pass
268
- `refreshBoundary` to `fetchPayload` to request and apply a boundary refresh.
269
- `fetchPayload` sends the response codec in `Accept` and checks the response
270
- `codec=` content-type parameter before decoding. Server integrations can read
271
- the exported `PAYLOAD_BOUNDARY_HEADER` constant for targeted refresh requests.
272
- Non-2xx responses reject with `PayloadFetchError`, which exposes `status` and
273
- `response` and cancels the response body before throwing.
274
- Decoded client references require `loadClientReference` or a matching
275
- `resolveClientReference` before render; metadata-only decodes can still inspect
276
- rows without configuring a loader.
277
-
278
- `encodePayloadValue` / `decodePayloadValue` are low-level helpers for payload
279
- integrations that need the same data-value fidelity as payload data rows:
280
- `undefined`, `Date`, `Map`, `Set`, `BigInt`, non-finite numbers, `-0`, and
281
- global `Symbol.for` symbols round-trip. Shared references and cycles across
282
- arrays, plain objects, `Map`, and `Set` also round-trip; functions, class
283
- instances, and non-global symbols are rejected.
299
+ `renderToPayloadStream(node, options?)` renders a component tree to Payload. The
300
+ result exposes `stream`, `contentType`, and `allReady`; cancellation is
301
+ signal-only — aborting `options.signal` (or cancelling the stream) cancels the
302
+ render and rejects `allReady`. Pass the stream and content type directly to a `Response`. Browser
303
+ code decodes it with `decodePayloadStream` from `@bgub/fig/payload`, normally
304
+ through fig-dom's `createPayloadComponent`. Rows, codecs, value encoding, and
305
+ framework document transports are internal implementation details.
306
+
307
+ The public options cover error sanitization (`onError`), bundler-provided
308
+ Payload component assets (`componentAssets`), manifest-provided client assets
309
+ (`clientReferenceAssets`), data-store partitioning, byte backpressure
310
+ (`highWaterMark`), and cancellation (`signal`).
284
311
 
285
312
  The server renderer supports function components, fragments, context providers,
286
313
  `useState` initial values, `useSyncExternalStore` server snapshots, no-op server
@@ -0,0 +1,26 @@
1
+ //#region src/escaping.ts
2
+ function escapeText(value) {
3
+ return value.replace(/[&<>]/g, (character) => {
4
+ if (character === "&") return "&amp;";
5
+ if (character === "<") return "&lt;";
6
+ return "&gt;";
7
+ });
8
+ }
9
+ function escapeAttribute(value) {
10
+ return value.replace(/[&"<>]/g, (character) => {
11
+ if (character === "&") return "&amp;";
12
+ if (character === "\"") return "&quot;";
13
+ if (character === "<") return "&lt;";
14
+ return "&gt;";
15
+ });
16
+ }
17
+ function escapeScriptText(value) {
18
+ return value.replaceAll("<", "\\u003C").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
19
+ }
20
+ function escapeScriptJson(value) {
21
+ return escapeScriptText(JSON.stringify(value));
22
+ }
23
+ //#endregion
24
+ export { escapeText as i, escapeScriptJson as n, escapeScriptText as r, escapeAttribute as t };
25
+
26
+ //# sourceMappingURL=escaping-DAot8sFu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escaping-DAot8sFu.js","names":[],"sources":["../src/escaping.ts"],"sourcesContent":["export function escapeText(value: string): string {\n return value.replace(/[&<>]/g, (character) => {\n if (character === \"&\") return \"&amp;\";\n if (character === \"<\") return \"&lt;\";\n return \"&gt;\";\n });\n}\n\nexport function escapeAttribute(value: string): string {\n return value.replace(/[&\"<>]/g, (character) => {\n if (character === \"&\") return \"&amp;\";\n if (character === '\"') return \"&quot;\";\n if (character === \"<\") return \"&lt;\";\n return \"&gt;\";\n });\n}\n\n// Script elements are raw-text elements: HTML entities are not decoded in\n// their contents, so escape only characters that can terminate the element\n// or executable source.\nexport function escapeScriptText(value: string): string {\n return value\n .replaceAll(\"<\", \"\\\\u003C\")\n .replaceAll(\"\\u2028\", \"\\\\u2028\")\n .replaceAll(\"\\u2029\", \"\\\\u2029\");\n}\n\nexport function escapeScriptJson(value: unknown): string {\n return escapeScriptText(JSON.stringify(value));\n}\n"],"mappings":";AAAA,SAAgB,WAAW,OAAuB;CAChD,OAAO,MAAM,QAAQ,WAAW,cAAc;EAC5C,IAAI,cAAc,KAAK,OAAO;EAC9B,IAAI,cAAc,KAAK,OAAO;EAC9B,OAAO;CACT,CAAC;AACH;AAEA,SAAgB,gBAAgB,OAAuB;CACrD,OAAO,MAAM,QAAQ,YAAY,cAAc;EAC7C,IAAI,cAAc,KAAK,OAAO;EAC9B,IAAI,cAAc,MAAK,OAAO;EAC9B,IAAI,cAAc,KAAK,OAAO;EAC9B,OAAO;CACT,CAAC;AACH;AAKA,SAAgB,iBAAiB,OAAuB;CACtD,OAAO,MACJ,WAAW,KAAK,SAAS,CAAC,CAC1B,WAAW,UAAU,SAAS,CAAC,CAC/B,WAAW,UAAU,SAAS;AACnC;AAEA,SAAgB,iBAAiB,OAAwB;CACvD,OAAO,iBAAiB,KAAK,UAAU,KAAK,CAAC;AAC/C"}
@@ -0,0 +1,7 @@
1
+ //#region src/escaping.d.ts
2
+ declare function escapeText(value: string): string;
3
+ declare function escapeAttribute(value: string): string;
4
+ declare function escapeScriptText(value: string): string;
5
+ declare function escapeScriptJson(value: unknown): string;
6
+ //#endregion
7
+ export { escapeAttribute, escapeScriptJson, escapeScriptText, escapeText };
@@ -0,0 +1,2 @@
1
+ import { i as escapeText, n as escapeScriptJson, r as escapeScriptText, t as escapeAttribute } from "./escaping-DAot8sFu.js";
2
+ export { escapeAttribute, escapeScriptJson, escapeScriptText, escapeText };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,5 @@
1
- import { a as ServerErrorPayload, c as ServerPrerenderResult, d as RenderTreeKind, f as RenderTreeNode, i as ServerErrorInfo, l as ServerRenderOptions, n as ServerAssetErrorInfo, o as ServerFragmentRenderResult, p as createRenderTreeCollector, r as ServerDocumentRenderResult, s as ServerPrerenderOptions, t as ServerAssetDestination, u as RenderTreeCollector } from "./types-4X0JT0wt.js";
2
- import { FigNode, Props } from "@bgub/fig";
3
- //#region src/html.d.ts
4
- declare function escapeText(value: string): string;
5
- declare function escapeAttribute(value: string): string;
6
- //#endregion
1
+ import { a as ServerPreloadHeaderOptions, c as ServerPrerenderResult, d as RenderTreeKind, f as RenderTreeNode, i as ServerFragmentRenderResult, l as ServerRenderOptions, n as ServerErrorInfo, o as ServerPreloadHeaderResource, p as createRenderTreeCollector, r as ServerErrorPayload, s as ServerPrerenderOptions, t as ServerDocumentRenderResult, u as RenderTreeCollector } from "./types-CimwEFsf.js";
2
+ import { FigNode } from "@bgub/fig";
7
3
  //#region src/index.d.ts
8
4
  declare function renderToStream(node: FigNode, options?: ServerRenderOptions): ServerFragmentRenderResult;
9
5
  declare function renderToDocumentStream(node: FigNode, options?: ServerRenderOptions): ServerDocumentRenderResult;
@@ -24,4 +20,4 @@ declare function renderToDocumentHtml(node: FigNode, options?: ServerRenderOptio
24
20
  */
25
21
  declare function prerender(node: FigNode, options?: ServerPrerenderOptions): Promise<ServerPrerenderResult>;
26
22
  //#endregion
27
- export { type RenderTreeCollector, type RenderTreeKind, type RenderTreeNode, type ServerAssetDestination, type ServerAssetErrorInfo, type ServerDocumentRenderResult, type ServerErrorInfo, type ServerErrorPayload, type ServerFragmentRenderResult, type ServerPrerenderOptions, type ServerPrerenderResult, type ServerRenderOptions, createRenderTreeCollector, escapeAttribute, escapeText, prerender, renderToDocumentHtml, renderToDocumentStream, renderToHtml, renderToStream };
23
+ export { type RenderTreeCollector, type RenderTreeKind, type RenderTreeNode, type ServerDocumentRenderResult, type ServerErrorInfo, type ServerErrorPayload, type ServerFragmentRenderResult, type ServerPreloadHeaderOptions, type ServerPreloadHeaderResource, type ServerPrerenderOptions, type ServerPrerenderResult, type ServerRenderOptions, createRenderTreeCollector, prerender, renderToDocumentHtml, renderToDocumentStream, renderToHtml, renderToStream };