@fictjs/ssr 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ import { FictNode } from '@fictjs/runtime';
2
+
3
+ interface SSRDom {
4
+ window: Window;
5
+ document: Document;
6
+ }
7
+ interface RenderToStringOptions {
8
+ /**
9
+ * Provide a pre-created DOM (document + window). If omitted, a new DOM is
10
+ * created per render using `html`.
11
+ */
12
+ dom?: SSRDom;
13
+ /**
14
+ * Provide a document directly. If `window` is omitted, `document.defaultView`
15
+ * will be used when available.
16
+ */
17
+ document?: Document;
18
+ /**
19
+ * Provide a window directly. If `document` is omitted, `window.document` is used.
20
+ */
21
+ window?: Window;
22
+ /**
23
+ * HTML template used when creating a new DOM.
24
+ */
25
+ html?: string;
26
+ /**
27
+ * Provide a container element to render into.
28
+ */
29
+ container?: HTMLElement;
30
+ /**
31
+ * Tag name for the auto-created container.
32
+ */
33
+ containerTag?: string;
34
+ /**
35
+ * id applied to the auto-created container.
36
+ */
37
+ containerId?: string;
38
+ /**
39
+ * Additional attributes applied to the auto-created container.
40
+ */
41
+ containerAttributes?: Record<string, string | number | boolean | null | undefined>;
42
+ /**
43
+ * Return the container element including its outer tag.
44
+ */
45
+ includeContainer?: boolean;
46
+ /**
47
+ * Return a full HTML document string (doctype + documentElement.outerHTML).
48
+ */
49
+ fullDocument?: boolean;
50
+ /**
51
+ * Override doctype when `fullDocument` is true. Use `null` to omit.
52
+ */
53
+ doctype?: string | null;
54
+ /**
55
+ * Expose DOM globals (window/document/Node/Element/etc) during render.
56
+ * Defaults to false. Set to true only for compatibility with components
57
+ * that still read process-global DOM objects during server rendering.
58
+ */
59
+ exposeGlobals?: boolean;
60
+ /**
61
+ * Manifest mapping module URLs to built client chunk URLs.
62
+ * Can be an object or a path to a JSON file.
63
+ * File path mode requires Deno sync filesystem access or a CommonJS
64
+ * environment where `require('node:fs')` is available. Pass an object when
65
+ * rendering from Node ESM or edge runtimes.
66
+ */
67
+ manifest?: Record<string, string> | string;
68
+ /**
69
+ * Include the SSR snapshot script for resumability.
70
+ * Defaults to true.
71
+ */
72
+ includeSnapshot?: boolean;
73
+ /**
74
+ * Script element id for the snapshot.
75
+ */
76
+ snapshotScriptId?: string;
77
+ /**
78
+ * Where to append the snapshot script when not returning full document.
79
+ * Defaults to 'container'.
80
+ */
81
+ snapshotTarget?: 'container' | 'body' | 'head';
82
+ /**
83
+ * Nonce applied to generated <script> tags for CSP compatibility.
84
+ */
85
+ scriptNonce?: string;
86
+ }
87
+ interface RenderToStreamOptions extends RenderToStringOptions {
88
+ /**
89
+ * Streaming mode:
90
+ * - 'shell': send fallback shell first, then patch resolved boundaries
91
+ * - 'all': wait for all suspense boundaries, then send full HTML
92
+ */
93
+ mode?: 'shell' | 'all';
94
+ /**
95
+ * Called once the initial shell has been written.
96
+ */
97
+ onShellReady?: () => void;
98
+ /**
99
+ * Called once all pending boundaries resolve and the stream completes.
100
+ */
101
+ onAllReady?: () => void;
102
+ /**
103
+ * Called when an error occurs during streaming.
104
+ */
105
+ onError?: (err: unknown) => void;
106
+ /**
107
+ * Abort signal to cancel the stream.
108
+ */
109
+ signal?: AbortSignal;
110
+ /**
111
+ * How to load the streaming patch runtime.
112
+ * Defaults to 'inline'. Use 'external' with streamRuntimeSrc for strict CSP.
113
+ */
114
+ streamRuntime?: 'inline' | 'external';
115
+ /**
116
+ * External streaming patch runtime URL when streamRuntime is 'external'.
117
+ */
118
+ streamRuntimeSrc?: string;
119
+ /**
120
+ * How resolved Suspense patch chunks are applied.
121
+ * Defaults to 'inline' for inline runtimes and 'observer' for external runtimes.
122
+ */
123
+ streamPatchMode?: 'inline' | 'observer';
124
+ }
125
+ interface PipeableStream {
126
+ pipe: (writable: NodeJS.WritableStream) => void;
127
+ abort: (reason?: unknown) => void;
128
+ shellReady: Promise<void>;
129
+ allReady: Promise<void>;
130
+ }
131
+ interface PartialPrerenderResult {
132
+ /**
133
+ * Complete shell HTML (fallbacks + markers + initial snapshot scripts).
134
+ *
135
+ * @experimental Preview API for v1.0; the access pattern may change before
136
+ * this becomes stable.
137
+ */
138
+ shell: string;
139
+ /**
140
+ * Stream of deferred patch chunks and incremental snapshots.
141
+ */
142
+ stream: ReadableStream<Uint8Array>;
143
+ shellReady: Promise<void>;
144
+ allReady: Promise<void>;
145
+ abort: (reason?: unknown) => void;
146
+ }
147
+ interface RenderToDocumentResult extends SSRDom {
148
+ html: string;
149
+ container: HTMLElement;
150
+ dispose: () => void;
151
+ }
152
+ declare function createSSRDocument(html?: string): SSRDom;
153
+ declare function renderToDocument(view: () => FictNode, options?: RenderToStringOptions): RenderToDocumentResult;
154
+ declare function renderToString(view: () => FictNode, options?: RenderToStringOptions): string;
155
+ declare function renderToStringAsync(view: () => FictNode, options?: RenderToStringOptions): Promise<string>;
156
+ declare function renderToStream(view: () => FictNode, options?: RenderToStreamOptions): ReadableStream<Uint8Array>;
157
+ declare function renderToPipeableStream(view: () => FictNode, options?: RenderToStreamOptions): PipeableStream;
158
+ /**
159
+ * @experimental Preview API for v1.0; the return shape may change before this
160
+ * becomes stable.
161
+ */
162
+ declare function renderToPartial(view: () => FictNode, options?: RenderToStreamOptions): PartialPrerenderResult;
163
+
164
+ export { type PipeableStream as P, type PartialPrerenderResult, type RenderToStringOptions as R, type SSRDom as S, renderToString as a, renderToStringAsync as b, createSSRDocument as c, renderToStream as d, renderToPipeableStream as e, type RenderToStreamOptions as f, type RenderToDocumentResult as g, renderToDocument as r, renderToPartial };
@@ -0,0 +1,164 @@
1
+ import { FictNode } from '@fictjs/runtime';
2
+
3
+ interface SSRDom {
4
+ window: Window;
5
+ document: Document;
6
+ }
7
+ interface RenderToStringOptions {
8
+ /**
9
+ * Provide a pre-created DOM (document + window). If omitted, a new DOM is
10
+ * created per render using `html`.
11
+ */
12
+ dom?: SSRDom;
13
+ /**
14
+ * Provide a document directly. If `window` is omitted, `document.defaultView`
15
+ * will be used when available.
16
+ */
17
+ document?: Document;
18
+ /**
19
+ * Provide a window directly. If `document` is omitted, `window.document` is used.
20
+ */
21
+ window?: Window;
22
+ /**
23
+ * HTML template used when creating a new DOM.
24
+ */
25
+ html?: string;
26
+ /**
27
+ * Provide a container element to render into.
28
+ */
29
+ container?: HTMLElement;
30
+ /**
31
+ * Tag name for the auto-created container.
32
+ */
33
+ containerTag?: string;
34
+ /**
35
+ * id applied to the auto-created container.
36
+ */
37
+ containerId?: string;
38
+ /**
39
+ * Additional attributes applied to the auto-created container.
40
+ */
41
+ containerAttributes?: Record<string, string | number | boolean | null | undefined>;
42
+ /**
43
+ * Return the container element including its outer tag.
44
+ */
45
+ includeContainer?: boolean;
46
+ /**
47
+ * Return a full HTML document string (doctype + documentElement.outerHTML).
48
+ */
49
+ fullDocument?: boolean;
50
+ /**
51
+ * Override doctype when `fullDocument` is true. Use `null` to omit.
52
+ */
53
+ doctype?: string | null;
54
+ /**
55
+ * Expose DOM globals (window/document/Node/Element/etc) during render.
56
+ * Defaults to false. Set to true only for compatibility with components
57
+ * that still read process-global DOM objects during server rendering.
58
+ */
59
+ exposeGlobals?: boolean;
60
+ /**
61
+ * Manifest mapping module URLs to built client chunk URLs.
62
+ * Can be an object or a path to a JSON file.
63
+ * File path mode requires Deno sync filesystem access or a CommonJS
64
+ * environment where `require('node:fs')` is available. Pass an object when
65
+ * rendering from Node ESM or edge runtimes.
66
+ */
67
+ manifest?: Record<string, string> | string;
68
+ /**
69
+ * Include the SSR snapshot script for resumability.
70
+ * Defaults to true.
71
+ */
72
+ includeSnapshot?: boolean;
73
+ /**
74
+ * Script element id for the snapshot.
75
+ */
76
+ snapshotScriptId?: string;
77
+ /**
78
+ * Where to append the snapshot script when not returning full document.
79
+ * Defaults to 'container'.
80
+ */
81
+ snapshotTarget?: 'container' | 'body' | 'head';
82
+ /**
83
+ * Nonce applied to generated <script> tags for CSP compatibility.
84
+ */
85
+ scriptNonce?: string;
86
+ }
87
+ interface RenderToStreamOptions extends RenderToStringOptions {
88
+ /**
89
+ * Streaming mode:
90
+ * - 'shell': send fallback shell first, then patch resolved boundaries
91
+ * - 'all': wait for all suspense boundaries, then send full HTML
92
+ */
93
+ mode?: 'shell' | 'all';
94
+ /**
95
+ * Called once the initial shell has been written.
96
+ */
97
+ onShellReady?: () => void;
98
+ /**
99
+ * Called once all pending boundaries resolve and the stream completes.
100
+ */
101
+ onAllReady?: () => void;
102
+ /**
103
+ * Called when an error occurs during streaming.
104
+ */
105
+ onError?: (err: unknown) => void;
106
+ /**
107
+ * Abort signal to cancel the stream.
108
+ */
109
+ signal?: AbortSignal;
110
+ /**
111
+ * How to load the streaming patch runtime.
112
+ * Defaults to 'inline'. Use 'external' with streamRuntimeSrc for strict CSP.
113
+ */
114
+ streamRuntime?: 'inline' | 'external';
115
+ /**
116
+ * External streaming patch runtime URL when streamRuntime is 'external'.
117
+ */
118
+ streamRuntimeSrc?: string;
119
+ /**
120
+ * How resolved Suspense patch chunks are applied.
121
+ * Defaults to 'inline' for inline runtimes and 'observer' for external runtimes.
122
+ */
123
+ streamPatchMode?: 'inline' | 'observer';
124
+ }
125
+ interface PipeableStream {
126
+ pipe: (writable: NodeJS.WritableStream) => void;
127
+ abort: (reason?: unknown) => void;
128
+ shellReady: Promise<void>;
129
+ allReady: Promise<void>;
130
+ }
131
+ interface PartialPrerenderResult {
132
+ /**
133
+ * Complete shell HTML (fallbacks + markers + initial snapshot scripts).
134
+ *
135
+ * @experimental Preview API for v1.0; the access pattern may change before
136
+ * this becomes stable.
137
+ */
138
+ shell: string;
139
+ /**
140
+ * Stream of deferred patch chunks and incremental snapshots.
141
+ */
142
+ stream: ReadableStream<Uint8Array>;
143
+ shellReady: Promise<void>;
144
+ allReady: Promise<void>;
145
+ abort: (reason?: unknown) => void;
146
+ }
147
+ interface RenderToDocumentResult extends SSRDom {
148
+ html: string;
149
+ container: HTMLElement;
150
+ dispose: () => void;
151
+ }
152
+ declare function createSSRDocument(html?: string): SSRDom;
153
+ declare function renderToDocument(view: () => FictNode, options?: RenderToStringOptions): RenderToDocumentResult;
154
+ declare function renderToString(view: () => FictNode, options?: RenderToStringOptions): string;
155
+ declare function renderToStringAsync(view: () => FictNode, options?: RenderToStringOptions): Promise<string>;
156
+ declare function renderToStream(view: () => FictNode, options?: RenderToStreamOptions): ReadableStream<Uint8Array>;
157
+ declare function renderToPipeableStream(view: () => FictNode, options?: RenderToStreamOptions): PipeableStream;
158
+ /**
159
+ * @experimental Preview API for v1.0; the return shape may change before this
160
+ * becomes stable.
161
+ */
162
+ declare function renderToPartial(view: () => FictNode, options?: RenderToStreamOptions): PartialPrerenderResult;
163
+
164
+ export { type PipeableStream as P, type PartialPrerenderResult, type RenderToStringOptions as R, type SSRDom as S, renderToString as a, renderToStringAsync as b, createSSRDocument as c, renderToStream as d, renderToPipeableStream as e, type RenderToStreamOptions as f, type RenderToDocumentResult as g, renderToDocument as r, renderToPartial };
@@ -0,0 +1,7 @@
1
+ import {
2
+ renderToPartial
3
+ } from "./chunk-LW7ALN63.js";
4
+ import "./chunk-DI4PFT6R.js";
5
+ export {
6
+ renderToPartial
7
+ };
package/dist/index.cjs CHANGED
@@ -22,13 +22,14 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  createSSRDocument: () => createSSRDocument,
24
24
  renderToDocument: () => renderToDocument,
25
- renderToPartial: () => renderToPartial,
26
25
  renderToPipeableStream: () => renderToPipeableStream,
27
26
  renderToStream: () => renderToStream,
28
27
  renderToString: () => renderToString,
29
28
  renderToStringAsync: () => renderToStringAsync
30
29
  });
31
30
  module.exports = __toCommonJS(index_exports);
31
+
32
+ // src/render-core.ts
32
33
  var import_runtime = require("@fictjs/runtime");
33
34
  var import_internal2 = require("@fictjs/runtime/internal");
34
35
  var import_linkedom = require("linkedom");
@@ -138,7 +139,7 @@ function readTextFileFromPath(path) {
138
139
  return fs.readFileSync(path, "utf8");
139
140
  }
140
141
  throw new Error(
141
- "[fict/ssr] `manifest` as file path is only supported in Node.js or Deno. Pass a manifest object in edge runtimes."
142
+ "[fict/ssr] `manifest` as file path is only supported when Deno.readTextFileSync or CommonJS require is available. Pass a manifest object in Node ESM or edge runtimes."
142
143
  );
143
144
  }
144
145
  function getNodeRequire() {
@@ -155,84 +156,6 @@ function getNodeRequire() {
155
156
  }
156
157
 
157
158
  // src/stream-bridge.ts
158
- function createQueuedTextStream(options = {}) {
159
- const encoder = new TextEncoder();
160
- const queue = [];
161
- let controller = null;
162
- let closed = false;
163
- let aborted;
164
- const readyResolvers = [];
165
- const resolveReady = () => {
166
- if (!controller || (controller.desiredSize ?? 1) <= 0) return;
167
- while (readyResolvers.length > 0) {
168
- readyResolvers.shift()?.();
169
- }
170
- };
171
- const drainReady = () => {
172
- while (readyResolvers.length > 0) {
173
- readyResolvers.shift()?.();
174
- }
175
- };
176
- const abortQueue = (reason, notifyController = true) => {
177
- if (closed || aborted !== void 0) return;
178
- aborted = reason ?? new Error("Stream aborted");
179
- queue.length = 0;
180
- drainReady();
181
- if (notifyController) {
182
- controller?.error(aborted);
183
- }
184
- };
185
- const stream = new ReadableStream({
186
- start(ctrl) {
187
- controller = ctrl;
188
- for (const chunk of queue) {
189
- ctrl.enqueue(chunk);
190
- }
191
- queue.length = 0;
192
- if (aborted !== void 0) {
193
- ctrl.error(aborted);
194
- return;
195
- }
196
- if (closed) {
197
- ctrl.close();
198
- }
199
- },
200
- pull() {
201
- resolveReady();
202
- },
203
- cancel(reason) {
204
- abortQueue(reason, false);
205
- options.onCancel?.(reason);
206
- }
207
- });
208
- const writer = {
209
- write(chunk) {
210
- if (closed || aborted !== void 0) return;
211
- const data = encoder.encode(chunk);
212
- if (controller) {
213
- controller.enqueue(data);
214
- if ((controller.desiredSize ?? 1) <= 0) {
215
- return new Promise((resolve) => {
216
- readyResolvers.push(resolve);
217
- });
218
- }
219
- } else {
220
- queue.push(data);
221
- }
222
- return void 0;
223
- },
224
- close() {
225
- if (closed || aborted !== void 0) return;
226
- closed = true;
227
- drainReady();
228
- controller?.close();
229
- },
230
- abort(reason) {
231
- abortQueue(reason);
232
- }
233
- };
234
- return { stream, writer };
235
- }
236
159
  function createPipeBridge() {
237
160
  const nodeBridge = createNodePipeBridge();
238
161
  if (nodeBridge) return nodeBridge;
@@ -240,6 +163,7 @@ function createPipeBridge() {
240
163
  const buffer = [];
241
164
  let state = "open";
242
165
  let abortReason = null;
166
+ let sinkErrorHandler;
243
167
  const safeWrite = (target, chunk) => {
244
168
  try {
245
169
  const ready = target.write(chunk);
@@ -253,7 +177,8 @@ function createPipeBridge() {
253
177
  }
254
178
  });
255
179
  }
256
- } catch {
180
+ } catch (error) {
181
+ sinkErrorHandler?.(error);
257
182
  }
258
183
  return void 0;
259
184
  };
@@ -275,8 +200,14 @@ function createPipeBridge() {
275
200
  safeEnd(target);
276
201
  };
277
202
  return {
278
- pipe(writable) {
203
+ pipe(writable, options) {
279
204
  targets.add(writable);
205
+ if (options?.onError) {
206
+ sinkErrorHandler = options.onError;
207
+ if (typeof writable.on === "function") {
208
+ writable.on("error", options.onError);
209
+ }
210
+ }
280
211
  if (buffer.length > 0) {
281
212
  for (const chunk of buffer) {
282
213
  safeWrite(writable, chunk);
@@ -334,14 +265,24 @@ function createNodePipeBridge() {
334
265
  let piped = false;
335
266
  let state = "open";
336
267
  let abortReason = null;
268
+ const pendingDrains = /* @__PURE__ */ new Set();
269
+ const flushDrains = () => {
270
+ for (const resolve of pendingDrains) resolve();
271
+ pendingDrains.clear();
272
+ };
337
273
  const writeToPassThrough = (chunk) => {
338
274
  if (passThrough.write(chunk) === false) {
339
275
  return new Promise((resolve) => {
276
+ const settle = () => {
277
+ pendingDrains.delete(settle);
278
+ resolve();
279
+ };
280
+ pendingDrains.add(settle);
340
281
  const withOnce = passThrough;
341
282
  if (typeof withOnce.once === "function") {
342
- withOnce.once("drain", resolve);
283
+ withOnce.once("drain", settle);
343
284
  } else {
344
- resolve();
285
+ settle();
345
286
  }
346
287
  });
347
288
  }
@@ -365,9 +306,16 @@ function createNodePipeBridge() {
365
306
  }
366
307
  };
367
308
  return {
368
- pipe(writable) {
309
+ pipe(writable, options) {
369
310
  piped = true;
370
311
  passThrough.pipe(writable);
312
+ const onError = options?.onError;
313
+ if (onError && typeof writable.on === "function") {
314
+ writable.on("error", (err) => {
315
+ flushDrains();
316
+ onError(err);
317
+ });
318
+ }
371
319
  if (state === "aborted") {
372
320
  destroyPassThrough(abortReason ?? new Error("Stream aborted"));
373
321
  return;
@@ -400,6 +348,7 @@ function createNodePipeBridge() {
400
348
  state = "aborted";
401
349
  abortReason = reason instanceof Error ? reason : new Error("Stream aborted");
402
350
  buffer.length = 0;
351
+ flushDrains();
403
352
  if (piped) {
404
353
  destroyPassThrough(abortReason);
405
354
  }
@@ -429,7 +378,7 @@ function createStreamRuntimeCode(options = {}) {
429
378
  }
430
379
  var FICT_STREAM_RUNTIME_CODE = createStreamRuntimeCode({ observerMode: true });
431
380
 
432
- // src/index.ts
381
+ // src/render-core.ts
433
382
  var DEFAULT_HTML = "<!doctype html><html><head></head><body></body></html>";
434
383
  function createSSRDocument(html = DEFAULT_HTML) {
435
384
  const window = (0, import_linkedom.parseHTML)(html);
@@ -580,61 +529,13 @@ function renderToPipeableStream(view, options = {}) {
580
529
  });
581
530
  return {
582
531
  pipe(writable) {
583
- bridge.pipe(writable);
532
+ bridge.pipe(writable, { onError: abort });
584
533
  },
585
534
  abort,
586
535
  shellReady,
587
536
  allReady
588
537
  };
589
538
  }
590
- function renderToPartial(view, options = {}) {
591
- const partialOptions = {
592
- ...options,
593
- mode: "shell",
594
- fullDocument: options.fullDocument ?? true
595
- };
596
- let shell = "";
597
- let shellPhase = true;
598
- let abortPartial = null;
599
- const queued = createQueuedTextStream({
600
- onCancel(reason) {
601
- abortPartial?.(reason ?? new Error("Stream canceled"));
602
- }
603
- });
604
- const { shellReady, allReady, abort } = startStreamingRender(
605
- view,
606
- partialOptions,
607
- {
608
- write(chunk) {
609
- if (shellPhase) {
610
- shell += chunk;
611
- return;
612
- }
613
- return queued.writer.write(chunk);
614
- },
615
- close() {
616
- queued.writer.close();
617
- },
618
- abort(reason) {
619
- queued.writer.abort(reason);
620
- }
621
- },
622
- {
623
- includeTailInShell: true,
624
- onShellFlushed() {
625
- shellPhase = false;
626
- }
627
- }
628
- );
629
- abortPartial = abort;
630
- return {
631
- shell,
632
- stream: queued.stream,
633
- shellReady,
634
- allReady,
635
- abort
636
- };
637
- }
638
539
  function resolveDom(options) {
639
540
  if (options.dom) {
640
541
  return options.dom;
@@ -901,12 +802,13 @@ function startStreamingRenderInSession(session, view, options, writer, control =
901
802
  }
902
803
  };
903
804
  const abort = (reason) => {
904
- if (closed) return;
905
- closed = true;
906
- writeFailed = true;
907
- writer.abort(reason);
908
- cleanup();
909
805
  const abortReason = reason ?? new Error("Stream aborted");
806
+ if (!closed) {
807
+ closed = true;
808
+ writeFailed = true;
809
+ writer.abort(reason);
810
+ cleanup();
811
+ }
910
812
  rejectShell(abortReason);
911
813
  rejectAll(abortReason);
912
814
  };
@@ -1117,7 +1019,6 @@ function serializeDoctype(document, override) {
1117
1019
  0 && (module.exports = {
1118
1020
  createSSRDocument,
1119
1021
  renderToDocument,
1120
- renderToPartial,
1121
1022
  renderToPipeableStream,
1122
1023
  renderToStream,
1123
1024
  renderToString,