@fictjs/ssr 0.5.0 → 0.5.2

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
@@ -10,9 +10,12 @@ Fict's Server-Side Rendering (SSR) package, providing high-performance server-si
10
10
  - [Core Concepts](#core-concepts)
11
11
  - [API Reference](#api-reference)
12
12
  - [Architecture Design](#architecture-design)
13
+ - [Partial Prerendering](#partial-prerendering)
14
+ - [Edge Runtime](#edge-runtime)
13
15
  - [Integration with Vite](#integration-with-vite)
14
16
  - [Advanced Usage](#advanced-usage)
15
17
  - [Performance Optimization](#performance-optimization)
18
+ - [Production Guides](#production-guides)
16
19
  - [Troubleshooting](#troubleshooting)
17
20
 
18
21
  ## Overview
@@ -268,6 +271,54 @@ interface RenderToDocumentResult extends SSRDom {
268
271
 
269
272
  Returns a DOM object for further manipulation or streaming rendering.
270
273
 
274
+ ### renderToStream
275
+
276
+ Stream HTML to a Web `ReadableStream`. In `shell` mode, the fallback shell is
277
+ sent immediately and Suspense boundaries patch in as they resolve.
278
+
279
+ > Note: In `shell` mode, resumable snapshots are emitted incrementally as
280
+ > `data-fict-snapshot` scripts (shell + each resolved boundary). When
281
+ > `snapshotTarget: 'head'`, each chunk injects into `<head>` via a small script.
282
+
283
+ ```typescript
284
+ import { renderToStream } from '@fictjs/ssr'
285
+
286
+ const stream = renderToStream(() => <App />, { mode: 'shell' })
287
+ ```
288
+
289
+ ### renderToPipeableStream
290
+
291
+ Node.js-style stream variant (compatible with `pipe()`).
292
+
293
+ ```typescript
294
+ import { renderToPipeableStream } from '@fictjs/ssr'
295
+
296
+ const { pipe, shellReady, allReady } = renderToPipeableStream(() => <App />, { mode: 'shell' })
297
+ pipe(res)
298
+ await shellReady
299
+ await allReady
300
+ ```
301
+
302
+ > Use `renderToStream()` in Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy).
303
+
304
+ ### renderToPartial
305
+
306
+ Generate a complete shell HTML plus a deferred patch stream for Partial Prerendering workflows.
307
+ This is an advanced API and currently considered **Preview** in v1.0.
308
+
309
+ ```typescript
310
+ import { renderToPartial } from '@fictjs/ssr'
311
+
312
+ const { shell, stream, shellReady, allReady } = renderToPartial(() => <App />, {
313
+ mode: 'shell',
314
+ fullDocument: true,
315
+ })
316
+ ```
317
+
318
+ - `shell`: complete HTML document (fallbacks + boundary markers + initial snapshots)
319
+ - `stream`: patch chunks (`data-fict-suspense` + incremental snapshots) for deferred delivery
320
+ - `shellReady` / `allReady`: readiness signals for orchestration
321
+
271
322
  ### createSSRDocument
272
323
 
273
324
  ```typescript
@@ -382,6 +433,25 @@ Generated detailed `fict.manifest.json` during production build, mapping virtual
382
433
 
383
434
  ## Integration with Vite
384
435
 
436
+ ## Partial Prerendering
437
+
438
+ `renderToPartial()` enables a PPR-style split:
439
+
440
+ 1. **Shell phase**: serve/cache `shell` as static-first HTML.
441
+ 2. **Deferred phase**: deliver `stream` patches for resolved Suspense boundaries.
442
+
443
+ This keeps shell TTFB low while still allowing server-resolved dynamic islands.
444
+
445
+ ## Edge Runtime
446
+
447
+ `@fictjs/ssr` can run in Edge environments via `renderToStream()` and `renderToString()`.
448
+
449
+ Notes:
450
+
451
+ - `manifest` as an object works in all runtimes.
452
+ - `manifest` as a file path string requires Node.js or Deno sync file access.
453
+ - Prefer `renderToStream()` over `renderToPipeableStream()` for Edge.
454
+
385
455
  ### vite.config.ts
386
456
 
387
457
  ```typescript
@@ -463,23 +533,21 @@ app.get('*', async (req, res) => {
463
533
  })
464
534
  ```
465
535
 
466
- ### Streaming Rendering (Experimental)
536
+ ### Streaming Rendering
467
537
 
468
538
  ```typescript
469
- import { renderToDocument } from '@fictjs/ssr'
539
+ import { renderToPipeableStream } from '@fictjs/ssr'
470
540
 
471
541
  app.get('*', async (req, res) => {
472
- const result = renderToDocument(() => <App />, {
473
- includeSnapshot: true,
542
+ const { pipe, shellReady, allReady } = renderToPipeableStream(() => <App />, {
543
+ mode: 'shell',
474
544
  })
475
545
 
476
- // Can send in chunks
477
- res.write('<!DOCTYPE html><html><head>...</head><body>')
478
- res.write(result.html)
479
- res.write('</body></html>')
480
- res.end()
546
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
547
+ pipe(res)
481
548
 
482
- result.dispose()
549
+ await shellReady
550
+ await allReady
483
551
  })
484
552
  ```
485
553
 
@@ -546,6 +614,12 @@ let largeData = $state(hugeArray)
546
614
  let dataId = $state(id) // Store ID only, fetch on client
547
615
  ```
548
616
 
617
+ ## Production Guides
618
+
619
+ - [SSR SEO Guide](../../docs/ssr-seo.md)
620
+ - [SSR Performance Tuning](../../docs/ssr-performance.md)
621
+ - [SSR Deployment Guide](../../docs/ssr-deployment.md)
622
+
549
623
  ## Troubleshooting
550
624
 
551
625
  ### Common Issues
@@ -604,8 +678,15 @@ onMount(async () => {
604
678
  console.log(globalThis.__FICT_MANIFEST__)
605
679
 
606
680
  // Check snapshot content
607
- const snapshot = document.getElementById('__FICT_SNAPSHOT__')
608
- console.log(JSON.parse(snapshot.textContent))
681
+ const fullSnapshot = document.getElementById('__FICT_SNAPSHOT__')
682
+ if (fullSnapshot?.textContent) {
683
+ console.log(JSON.parse(fullSnapshot.textContent))
684
+ }
685
+ // In streaming shell mode, snapshots are chunked:
686
+ const snapshots = document.querySelectorAll('script[data-fict-snapshot]')
687
+ for (const script of snapshots) {
688
+ console.log(JSON.parse(script.textContent || '{}'))
689
+ }
609
690
  ```
610
691
 
611
692
  ## Related Packages
package/dist/index.cjs CHANGED
@@ -22,11 +22,13 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  createSSRDocument: () => createSSRDocument,
24
24
  renderToDocument: () => renderToDocument,
25
+ renderToPartial: () => renderToPartial,
26
+ renderToPipeableStream: () => renderToPipeableStream,
27
+ renderToStream: () => renderToStream,
25
28
  renderToString: () => renderToString,
26
29
  renderToStringAsync: () => renderToStringAsync
27
30
  });
28
31
  module.exports = __toCommonJS(index_exports);
29
- var import_node_fs = require("fs");
30
32
  var import_runtime = require("@fictjs/runtime");
31
33
  var import_internal = require("@fictjs/runtime/internal");
32
34
  var import_linkedom = require("linkedom");
@@ -90,6 +92,93 @@ function renderToString(view, options = {}) {
90
92
  async function renderToStringAsync(view, options = {}) {
91
93
  return renderToString(view, options);
92
94
  }
95
+ function renderToStream(view, options = {}) {
96
+ const encoder = new TextEncoder();
97
+ let controller = null;
98
+ const stream = new ReadableStream({
99
+ start(ctrl) {
100
+ controller = ctrl;
101
+ const started = startStreamingRender(view, options, {
102
+ write(chunk) {
103
+ if (!controller) return;
104
+ controller.enqueue(encoder.encode(chunk));
105
+ },
106
+ close() {
107
+ controller?.close();
108
+ },
109
+ abort(reason) {
110
+ controller?.error(reason);
111
+ }
112
+ });
113
+ started.allReady.catch(() => void 0);
114
+ }
115
+ });
116
+ return stream;
117
+ }
118
+ function renderToPipeableStream(view, options = {}) {
119
+ const bridge = createPipeBridge();
120
+ const { shellReady, allReady, abort } = startStreamingRender(view, options, {
121
+ write(chunk) {
122
+ bridge.write(chunk);
123
+ },
124
+ close() {
125
+ bridge.close();
126
+ },
127
+ abort(reason) {
128
+ bridge.abort(reason);
129
+ }
130
+ });
131
+ return {
132
+ pipe(writable) {
133
+ bridge.pipe(writable);
134
+ },
135
+ abort,
136
+ shellReady,
137
+ allReady
138
+ };
139
+ }
140
+ function renderToPartial(view, options = {}) {
141
+ const partialOptions = {
142
+ ...options,
143
+ mode: "shell",
144
+ fullDocument: options.fullDocument ?? true
145
+ };
146
+ let shell = "";
147
+ let shellPhase = true;
148
+ const queued = createQueuedTextStream();
149
+ const { shellReady, allReady, abort } = startStreamingRender(
150
+ view,
151
+ partialOptions,
152
+ {
153
+ write(chunk) {
154
+ if (shellPhase) {
155
+ shell += chunk;
156
+ return;
157
+ }
158
+ queued.writer.write(chunk);
159
+ },
160
+ close() {
161
+ queued.writer.close();
162
+ },
163
+ abort(reason) {
164
+ queued.writer.abort(reason);
165
+ }
166
+ },
167
+ {
168
+ includeTailInShell: true,
169
+ onShellFlushed() {
170
+ shellPhase = false;
171
+ }
172
+ }
173
+ );
174
+ return {
175
+ shell,
176
+ stream: queued.stream,
177
+ shellReady,
178
+ allReady,
179
+ abort
180
+ };
181
+ }
93
182
  function resolveDom(options) {
94
183
  if (options.dom) {
95
184
  return options.dom;
@@ -111,6 +200,354 @@ function resolveDom(options) {
111
200
  }
112
201
  return createSSRDocument(options.html);
113
202
  }
203
+ function createQueuedTextStream() {
204
+ const encoder = new TextEncoder();
205
+ const queue = [];
206
+ let controller = null;
207
+ let closed = false;
208
+ let aborted;
209
+ const stream = new ReadableStream({
210
+ start(ctrl) {
211
+ controller = ctrl;
212
+ for (const chunk of queue) {
213
+ ctrl.enqueue(chunk);
214
+ }
215
+ queue.length = 0;
216
+ if (aborted !== void 0) {
217
+ ctrl.error(aborted);
218
+ return;
219
+ }
220
+ if (closed) {
221
+ ctrl.close();
222
+ }
223
+ }
224
+ });
225
+ const writer = {
226
+ write(chunk) {
227
+ if (closed || aborted !== void 0) return;
228
+ const data = encoder.encode(chunk);
229
+ if (controller) {
230
+ controller.enqueue(data);
231
+ } else {
232
+ queue.push(data);
233
+ }
234
+ },
235
+ close() {
236
+ if (closed || aborted !== void 0) return;
237
+ closed = true;
238
+ controller?.close();
239
+ },
240
+ abort(reason) {
241
+ if (closed || aborted !== void 0) return;
242
+ aborted = reason ?? new Error("Stream aborted");
243
+ controller?.error(aborted);
244
+ }
245
+ };
246
+ return { stream, writer };
247
+ }
248
+ function createPipeBridge() {
249
+ const nodeBridge = createNodePipeBridge();
250
+ if (nodeBridge) return nodeBridge;
251
+ const targets = /* @__PURE__ */ new Set();
252
+ const buffer = [];
253
+ let state = "open";
254
+ let abortReason = null;
255
+ const safeWrite = (target, chunk) => {
256
+ try {
257
+ target.write(chunk);
258
+ } catch {
259
+ }
260
+ };
261
+ const safeEnd = (target) => {
262
+ try {
263
+ target.end();
264
+ } catch {
265
+ }
266
+ };
267
+ const safeDestroy = (target, reason) => {
268
+ const withDestroy = target;
269
+ if (typeof withDestroy.destroy === "function") {
270
+ try {
271
+ withDestroy.destroy(reason);
272
+ } catch {
273
+ }
274
+ return;
275
+ }
276
+ safeEnd(target);
277
+ };
278
+ return {
279
+ pipe(writable) {
280
+ targets.add(writable);
281
+ if (buffer.length > 0) {
282
+ for (const chunk of buffer) {
283
+ safeWrite(writable, chunk);
284
+ }
285
+ buffer.length = 0;
286
+ }
287
+ if (state === "closed") {
288
+ safeEnd(writable);
289
+ } else if (state === "aborted") {
290
+ safeDestroy(writable, abortReason ?? new Error("Stream aborted"));
291
+ }
292
+ },
293
+ write(chunk) {
294
+ if (state !== "open") return;
295
+ if (targets.size === 0) {
296
+ buffer.push(chunk);
297
+ return;
298
+ }
299
+ for (const target of targets) {
300
+ safeWrite(target, chunk);
301
+ }
302
+ },
303
+ close() {
304
+ if (state !== "open") return;
305
+ state = "closed";
306
+ for (const target of targets) {
307
+ safeEnd(target);
308
+ }
309
+ if (targets.size > 0) {
310
+ buffer.length = 0;
311
+ }
312
+ },
313
+ abort(reason) {
314
+ if (state !== "open") return;
315
+ state = "aborted";
316
+ abortReason = reason instanceof Error ? reason : new Error("Stream aborted");
317
+ for (const target of targets) {
318
+ safeDestroy(target, abortReason);
319
+ }
320
+ buffer.length = 0;
321
+ }
322
+ };
323
+ }
324
+ function createNodePipeBridge() {
325
+ const nodeRequire = getNodeRequire();
326
+ if (!nodeRequire) return null;
327
+ try {
328
+ const streamModule = nodeRequire("node:stream");
329
+ if (!streamModule.PassThrough) return null;
330
+ const passThrough = new streamModule.PassThrough();
331
+ return {
332
+ pipe(writable) {
333
+ passThrough.pipe(writable);
334
+ },
335
+ write(chunk) {
336
+ passThrough.write(chunk);
337
+ },
338
+ close() {
339
+ passThrough.end();
340
+ },
341
+ abort(reason) {
342
+ const error = reason instanceof Error ? reason : new Error("Stream aborted");
343
+ if (typeof passThrough.destroy === "function") {
344
+ passThrough.destroy(error);
345
+ } else {
346
+ passThrough.end();
347
+ }
348
+ }
349
+ };
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+ function startStreamingRender(view, options, writer, control = {}) {
355
+ const resolvedOptions = {
356
+ ...options,
357
+ // Streaming requires a real document; default to fullDocument when unspecified.
358
+ fullDocument: options.fullDocument ?? true
359
+ };
360
+ let resolveShell;
361
+ let resolveAll;
362
+ let rejectAll;
363
+ const shellReady = new Promise((res) => {
364
+ resolveShell = res;
365
+ });
366
+ const allReady = new Promise((res, rej) => {
367
+ resolveAll = res;
368
+ rejectAll = rej;
369
+ });
370
+ let dom = null;
371
+ let restoreGlobals2 = () => {
372
+ };
373
+ let restoreManifest = () => {
374
+ };
375
+ let teardown = () => {
376
+ };
377
+ let container = null;
378
+ let closed = false;
379
+ let tailHtml = "";
380
+ let wroteShell = false;
381
+ let shellCarriesTail = false;
382
+ const mode = options.mode ?? "shell";
383
+ const includeSnapshot = options.includeSnapshot !== false;
384
+ const sentScopes = /* @__PURE__ */ new Set();
385
+ const boundaryMap = /* @__PURE__ */ new Map();
386
+ let boundaryId = 0;
387
+ let pendingCount = 0;
388
+ const writeSnapshotForScopes = (scopeIds) => {
389
+ if (!includeSnapshot || scopeIds.length === 0) return;
390
+ const registry = (0, import_internal.__fictGetScopeRegistry)();
391
+ const pending = scopeIds.filter((id) => registry.has(id) && !sentScopes.has(id));
392
+ if (pending.length === 0) return;
393
+ const snapshot = (0, import_internal.__fictSerializeSSRStateForScopes)(pending);
394
+ const ids = Object.keys(snapshot.scopes);
395
+ if (ids.length === 0) return;
396
+ const chunk = buildIncrementalSnapshotChunk(snapshot, resolvedOptions);
397
+ if (chunk) {
398
+ writer.write(chunk);
399
+ }
400
+ for (const id of ids) {
401
+ sentScopes.add(id);
402
+ }
403
+ };
404
+ const writeSnapshotForBoundary = (boundary) => {
405
+ const scopes = (0, import_internal.__fictGetScopesForBoundary)(boundary);
406
+ writeSnapshotForScopes(scopes);
407
+ };
408
+ const writeRemainingSnapshots = () => {
409
+ const scopes = Array.from((0, import_internal.__fictGetScopeRegistry)().keys());
410
+ writeSnapshotForScopes(scopes);
411
+ };
412
+ const cleanup = () => {
413
+ (0, import_internal.__fictSetSSRStreamHooks)(null);
414
+ (0, import_internal.__fictDisableSSR)();
415
+ restoreGlobals2();
416
+ restoreManifest();
417
+ try {
418
+ teardown();
419
+ } catch {
420
+ }
421
+ };
422
+ const finalize = () => {
423
+ if (closed) return;
424
+ closed = true;
425
+ if (mode === "all" && dom && container && !wroteShell) {
426
+ if (includeSnapshot) {
427
+ const snapshot = (0, import_internal.__fictSerializeSSRState)();
428
+ injectSnapshot(dom.document, container, snapshot, resolvedOptions);
429
+ }
430
+ const fullHtml = serializeOutput(dom.document, container, resolvedOptions);
431
+ writer.write(fullHtml);
432
+ writer.close();
433
+ cleanup();
434
+ resolveShell();
435
+ resolveAll();
436
+ options.onShellReady?.();
437
+ options.onAllReady?.();
438
+ return;
439
+ }
440
+ writeRemainingSnapshots();
441
+ if (tailHtml) {
442
+ writer.write(tailHtml);
443
+ }
444
+ writer.close();
445
+ cleanup();
446
+ resolveAll();
447
+ options.onAllReady?.();
448
+ };
449
+ const maybeFinalize = () => {
450
+ if (pendingCount === 0) {
451
+ finalize();
452
+ }
453
+ };
454
+ const hooks = {
455
+ registerBoundary(start, end) {
456
+ const id = `s${++boundaryId}`;
457
+ boundaryMap.set(id, { start, end, pending: false });
458
+ return id;
459
+ },
460
+ boundaryPending(id) {
461
+ const entry = boundaryMap.get(id);
462
+ if (!entry || entry.pending) return;
463
+ entry.pending = true;
464
+ pendingCount++;
465
+ },
466
+ boundaryResolved(id) {
467
+ const entry = boundaryMap.get(id);
468
+ if (!entry) return;
469
+ if (entry.pending) {
470
+ entry.pending = false;
471
+ pendingCount = Math.max(0, pendingCount - 1);
472
+ }
473
+ if (mode === "shell") {
474
+ writeSnapshotForBoundary(id);
475
+ if (dom) {
476
+ const html = serializeBetween(dom.document, entry.start, entry.end);
477
+ writer.write(buildPatchChunk(id, html));
478
+ }
479
+ }
480
+ maybeFinalize();
481
+ },
482
+ onError(err) {
483
+ options.onError?.(err);
484
+ abort(err);
485
+ }
486
+ };
487
+ const abort = (reason) => {
488
+ if (closed) return;
489
+ closed = true;
490
+ writer.abort(reason);
491
+ cleanup();
492
+ rejectAll(reason ?? new Error("Stream aborted"));
493
+ };
494
+ if (options.signal) {
495
+ if (options.signal.aborted) {
496
+ abort(options.signal.reason);
497
+ } else {
498
+ options.signal.addEventListener("abort", () => abort(options.signal?.reason), { once: true });
499
+ }
500
+ }
501
+ try {
502
+ (0, import_internal.__fictEnableSSR)();
503
+ (0, import_internal.__fictSetSSRStreamHooks)(hooks);
504
+ dom = resolveDom(resolvedOptions);
505
+ restoreGlobals2 = resolvedOptions.exposeGlobals !== false ? installGlobals(dom.window, dom.document) : () => {
506
+ };
507
+ restoreManifest = installManifest(resolvedOptions.manifest);
508
+ container = resolveContainer(dom.document, resolvedOptions);
509
+ teardown = (0, import_runtime.render)(view, container);
510
+ if (mode === "all") {
511
+ if (pendingCount === 0) {
512
+ finalize();
513
+ }
514
+ return { shellReady, allReady, abort };
515
+ }
516
+ const shellHtml = serializeOutput(dom.document, container, resolvedOptions);
517
+ const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript() : "";
518
+ if (resolvedOptions.fullDocument) {
519
+ const split = splitDocumentHtml(shellHtml);
520
+ if (!split) {
521
+ throw new Error("[fict/ssr] Failed to locate </body> for streaming output.");
522
+ }
523
+ if (control.includeTailInShell) {
524
+ writer.write(split.head + streamRuntime);
525
+ tailHtml = split.tail;
526
+ shellCarriesTail = true;
527
+ } else {
528
+ writer.write(split.head + streamRuntime);
529
+ tailHtml = split.tail;
530
+ }
531
+ } else {
532
+ writer.write(shellHtml + streamRuntime);
533
+ }
534
+ wroteShell = true;
535
+ writeSnapshotForScopes(Array.from((0, import_internal.__fictGetScopeRegistry)().keys()));
536
+ if (shellCarriesTail && tailHtml) {
537
+ writer.write(tailHtml);
538
+ tailHtml = "";
539
+ shellCarriesTail = false;
540
+ }
541
+ control.onShellFlushed?.();
542
+ resolveShell();
543
+ options.onShellReady?.();
544
+ maybeFinalize();
545
+ } catch (err) {
546
+ options.onError?.(err);
547
+ abort(err);
548
+ }
549
+ return { shellReady, allReady, abort };
550
+ }
114
551
  function resolveContainer(document, options) {
115
552
  if (options.container) {
116
553
  if (options.container.ownerDocument && options.container.ownerDocument !== document) {
@@ -134,6 +571,35 @@ function resolveContainer(document, options) {
134
571
  }
135
572
  return container;
136
573
  }
574
+ function buildStreamRuntimeScript() {
575
+ return `<script>(function(){if(window.__FICT_STREAM)return;var cache=new Map();function find(id){var hit=cache.get(id);if(hit)return hit;var start=null,end=null;var w=document.createTreeWalker(document,NodeFilter.SHOW_COMMENT);while(w.nextNode()){var n=w.currentNode;var d=n.data;if(d==="fict:suspense-start:"+id)start=n;else if(d==="fict:suspense-end:"+id)end=n;if(start&&end)break;}if(start&&end){hit={start:start,end:end};cache.set(id,hit);}return hit;}function apply(id){var tpl=document.querySelector('template[data-fict-suspense="' + id + '"]');if(!tpl)return;var b=find(id);if(!b)return;var node=b.start.nextSibling;while(node&&node!==b.end){var next=node.nextSibling;node.parentNode&&node.parentNode.removeChild(node);node=next;}b.end.parentNode&&b.end.parentNode.insertBefore(tpl.content,b.end);tpl.parentNode&&tpl.parentNode.removeChild(tpl);}window.__FICT_STREAM={apply:apply};})();</script>`;
576
+ }
577
+ function buildPatchChunk(id, html) {
578
+ return `<template data-fict-suspense="${id}">` + html + `</template><script>__FICT_STREAM.apply("${id}")</script>`;
579
+ }
580
+ function serializeBetween(document, start, end) {
581
+ const wrapper = document.createElement("div");
582
+ let node = start.nextSibling;
583
+ while (node && node !== end) {
584
+ wrapper.appendChild(node.cloneNode(true));
585
+ node = node.nextSibling;
586
+ }
587
+ return wrapper.innerHTML;
588
+ }
589
+ function splitDocumentHtml(html) {
590
+ const lower = html.toLowerCase();
591
+ const idx = lower.lastIndexOf("</body>");
592
+ if (idx === -1) return null;
593
+ return { head: html.slice(0, idx), tail: html.slice(idx) };
594
+ }
595
+ function buildIncrementalSnapshotChunk(state, options) {
596
+ const json = JSON.stringify(state);
597
+ if (options.snapshotTarget === "head") {
598
+ const jsonLiteral = JSON.stringify(json);
599
+ return `<script>(function(){var s=document.createElement('script');s.type='application/json';s.setAttribute('data-fict-snapshot','');s.textContent=${jsonLiteral};(document.head||document.documentElement).appendChild(s);}())</script>`;
600
+ }
601
+ return `<script type="application/json" data-fict-snapshot>${json}</script>`;
602
+ }
137
603
  function serializeOutput(document, container, options) {
138
604
  if (options.fullDocument) {
139
605
  const doctype = serializeDoctype(document, options.doctype);
@@ -248,12 +714,39 @@ function restoreGlobals(snapshot) {
248
714
  }
249
715
  }
250
716
  }
717
+ function readTextFileFromPath(path) {
718
+ const g = globalThis;
719
+ const deno = g.Deno;
720
+ if (deno && typeof deno.readTextFileSync === "function") {
721
+ return deno.readTextFileSync(path);
722
+ }
723
+ const nodeRequire = getNodeRequire();
724
+ if (nodeRequire) {
725
+ const fs = nodeRequire("node:fs");
726
+ return fs.readFileSync(path, "utf8");
727
+ }
728
+ throw new Error(
729
+ "[fict/ssr] `manifest` as file path is only supported in Node.js or Deno. Pass a manifest object in edge runtimes."
730
+ );
731
+ }
732
+ function getNodeRequire() {
733
+ const g = globalThis;
734
+ const direct = g.require;
735
+ if (typeof direct === "function") {
736
+ return direct;
737
+ }
738
+ try {
739
+ return Function('return typeof require === "function" ? require : null')();
740
+ } catch {
741
+ return null;
742
+ }
743
+ }
251
744
  function installManifest(manifest) {
252
745
  if (!manifest) return () => {
253
746
  };
254
747
  let resolved;
255
748
  if (typeof manifest === "string") {
256
- const raw = (0, import_node_fs.readFileSync)(manifest, "utf8");
749
+ const raw = readTextFileFromPath(manifest);
257
750
  resolved = JSON.parse(raw);
258
751
  } else {
259
752
  resolved = manifest;
@@ -277,6 +770,9 @@ function installManifest(manifest) {
277
770
  0 && (module.exports = {
278
771
  createSSRDocument,
279
772
  renderToDocument,
773
+ renderToPartial,
774
+ renderToPipeableStream,
775
+ renderToStream,
280
776
  renderToString,
281
777
  renderToStringAsync
282
778
  });
package/dist/index.d.cts CHANGED
@@ -59,6 +59,7 @@ interface RenderToStringOptions {
59
59
  /**
60
60
  * Manifest mapping module URLs to built client chunk URLs.
61
61
  * Can be an object or a path to a JSON file.
62
+ * File path mode requires Node.js or Deno filesystem access.
62
63
  */
63
64
  manifest?: Record<string, string> | string;
64
65
  /**
@@ -76,6 +77,49 @@ interface RenderToStringOptions {
76
77
  */
77
78
  snapshotTarget?: 'container' | 'body' | 'head';
78
79
  }
80
+ interface RenderToStreamOptions extends RenderToStringOptions {
81
+ /**
82
+ * Streaming mode:
83
+ * - 'shell': send fallback shell first, then patch resolved boundaries
84
+ * - 'all': wait for all suspense boundaries, then send full HTML
85
+ */
86
+ mode?: 'shell' | 'all';
87
+ /**
88
+ * Called once the initial shell has been written.
89
+ */
90
+ onShellReady?: () => void;
91
+ /**
92
+ * Called once all pending boundaries resolve and the stream completes.
93
+ */
94
+ onAllReady?: () => void;
95
+ /**
96
+ * Called when an error occurs during streaming.
97
+ */
98
+ onError?: (err: unknown) => void;
99
+ /**
100
+ * Abort signal to cancel the stream.
101
+ */
102
+ signal?: AbortSignal;
103
+ }
104
+ interface PipeableStream {
105
+ pipe: (writable: NodeJS.WritableStream) => void;
106
+ abort: (reason?: unknown) => void;
107
+ shellReady: Promise<void>;
108
+ allReady: Promise<void>;
109
+ }
110
+ interface PartialPrerenderResult {
111
+ /**
112
+ * Complete shell HTML (fallbacks + markers + initial snapshot scripts).
113
+ */
114
+ shell: string;
115
+ /**
116
+ * Stream of deferred patch chunks and incremental snapshots.
117
+ */
118
+ stream: ReadableStream<Uint8Array>;
119
+ shellReady: Promise<void>;
120
+ allReady: Promise<void>;
121
+ abort: (reason?: unknown) => void;
122
+ }
79
123
  interface RenderToDocumentResult extends SSRDom {
80
124
  html: string;
81
125
  container: HTMLElement;
@@ -85,5 +129,8 @@ declare function createSSRDocument(html?: string): SSRDom;
85
129
  declare function renderToDocument(view: () => FictNode, options?: RenderToStringOptions): RenderToDocumentResult;
86
130
  declare function renderToString(view: () => FictNode, options?: RenderToStringOptions): string;
87
131
  declare function renderToStringAsync(view: () => FictNode, options?: RenderToStringOptions): Promise<string>;
132
+ declare function renderToStream(view: () => FictNode, options?: RenderToStreamOptions): ReadableStream<Uint8Array>;
133
+ declare function renderToPipeableStream(view: () => FictNode, options?: RenderToStreamOptions): PipeableStream;
134
+ declare function renderToPartial(view: () => FictNode, options?: RenderToStreamOptions): PartialPrerenderResult;
88
135
 
89
- export { type RenderToDocumentResult, type RenderToStringOptions, type SSRDom, createSSRDocument, renderToDocument, renderToString, renderToStringAsync };
136
+ export { type PartialPrerenderResult, type PipeableStream, type RenderToDocumentResult, type RenderToStreamOptions, type RenderToStringOptions, type SSRDom, createSSRDocument, renderToDocument, renderToPartial, renderToPipeableStream, renderToStream, renderToString, renderToStringAsync };
package/dist/index.d.ts CHANGED
@@ -59,6 +59,7 @@ interface RenderToStringOptions {
59
59
  /**
60
60
  * Manifest mapping module URLs to built client chunk URLs.
61
61
  * Can be an object or a path to a JSON file.
62
+ * File path mode requires Node.js or Deno filesystem access.
62
63
  */
63
64
  manifest?: Record<string, string> | string;
64
65
  /**
@@ -76,6 +77,49 @@ interface RenderToStringOptions {
76
77
  */
77
78
  snapshotTarget?: 'container' | 'body' | 'head';
78
79
  }
80
+ interface RenderToStreamOptions extends RenderToStringOptions {
81
+ /**
82
+ * Streaming mode:
83
+ * - 'shell': send fallback shell first, then patch resolved boundaries
84
+ * - 'all': wait for all suspense boundaries, then send full HTML
85
+ */
86
+ mode?: 'shell' | 'all';
87
+ /**
88
+ * Called once the initial shell has been written.
89
+ */
90
+ onShellReady?: () => void;
91
+ /**
92
+ * Called once all pending boundaries resolve and the stream completes.
93
+ */
94
+ onAllReady?: () => void;
95
+ /**
96
+ * Called when an error occurs during streaming.
97
+ */
98
+ onError?: (err: unknown) => void;
99
+ /**
100
+ * Abort signal to cancel the stream.
101
+ */
102
+ signal?: AbortSignal;
103
+ }
104
+ interface PipeableStream {
105
+ pipe: (writable: NodeJS.WritableStream) => void;
106
+ abort: (reason?: unknown) => void;
107
+ shellReady: Promise<void>;
108
+ allReady: Promise<void>;
109
+ }
110
+ interface PartialPrerenderResult {
111
+ /**
112
+ * Complete shell HTML (fallbacks + markers + initial snapshot scripts).
113
+ */
114
+ shell: string;
115
+ /**
116
+ * Stream of deferred patch chunks and incremental snapshots.
117
+ */
118
+ stream: ReadableStream<Uint8Array>;
119
+ shellReady: Promise<void>;
120
+ allReady: Promise<void>;
121
+ abort: (reason?: unknown) => void;
122
+ }
79
123
  interface RenderToDocumentResult extends SSRDom {
80
124
  html: string;
81
125
  container: HTMLElement;
@@ -85,5 +129,8 @@ declare function createSSRDocument(html?: string): SSRDom;
85
129
  declare function renderToDocument(view: () => FictNode, options?: RenderToStringOptions): RenderToDocumentResult;
86
130
  declare function renderToString(view: () => FictNode, options?: RenderToStringOptions): string;
87
131
  declare function renderToStringAsync(view: () => FictNode, options?: RenderToStringOptions): Promise<string>;
132
+ declare function renderToStream(view: () => FictNode, options?: RenderToStreamOptions): ReadableStream<Uint8Array>;
133
+ declare function renderToPipeableStream(view: () => FictNode, options?: RenderToStreamOptions): PipeableStream;
134
+ declare function renderToPartial(view: () => FictNode, options?: RenderToStreamOptions): PartialPrerenderResult;
88
135
 
89
- export { type RenderToDocumentResult, type RenderToStringOptions, type SSRDom, createSSRDocument, renderToDocument, renderToString, renderToStringAsync };
136
+ export { type PartialPrerenderResult, type PipeableStream, type RenderToDocumentResult, type RenderToStreamOptions, type RenderToStringOptions, type SSRDom, createSSRDocument, renderToDocument, renderToPartial, renderToPipeableStream, renderToStream, renderToString, renderToStringAsync };
package/dist/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  // src/index.ts
2
- import { readFileSync } from "fs";
3
2
  import { render } from "@fictjs/runtime";
4
3
  import {
5
4
  __fictDisableSSR,
6
5
  __fictEnableSSR,
7
- __fictSerializeSSRState
6
+ __fictGetScopeRegistry,
7
+ __fictGetScopesForBoundary,
8
+ __fictSerializeSSRState,
9
+ __fictSerializeSSRStateForScopes,
10
+ __fictSetSSRStreamHooks
8
11
  } from "@fictjs/runtime/internal";
9
12
  import { parseHTML } from "linkedom";
10
13
  var DEFAULT_HTML = "<!doctype html><html><head></head><body></body></html>";
@@ -67,6 +70,93 @@ function renderToString(view, options = {}) {
67
70
  async function renderToStringAsync(view, options = {}) {
68
71
  return renderToString(view, options);
69
72
  }
73
+ function renderToStream(view, options = {}) {
74
+ const encoder = new TextEncoder();
75
+ let controller = null;
76
+ const stream = new ReadableStream({
77
+ start(ctrl) {
78
+ controller = ctrl;
79
+ const started = startStreamingRender(view, options, {
80
+ write(chunk) {
81
+ if (!controller) return;
82
+ controller.enqueue(encoder.encode(chunk));
83
+ },
84
+ close() {
85
+ controller?.close();
86
+ },
87
+ abort(reason) {
88
+ controller?.error(reason);
89
+ }
90
+ });
91
+ started.allReady.catch(() => void 0);
92
+ }
93
+ });
94
+ return stream;
95
+ }
96
+ function renderToPipeableStream(view, options = {}) {
97
+ const bridge = createPipeBridge();
98
+ const { shellReady, allReady, abort } = startStreamingRender(view, options, {
99
+ write(chunk) {
100
+ bridge.write(chunk);
101
+ },
102
+ close() {
103
+ bridge.close();
104
+ },
105
+ abort(reason) {
106
+ bridge.abort(reason);
107
+ }
108
+ });
109
+ return {
110
+ pipe(writable) {
111
+ bridge.pipe(writable);
112
+ },
113
+ abort,
114
+ shellReady,
115
+ allReady
116
+ };
117
+ }
118
+ function renderToPartial(view, options = {}) {
119
+ const partialOptions = {
120
+ ...options,
121
+ mode: "shell",
122
+ fullDocument: options.fullDocument ?? true
123
+ };
124
+ let shell = "";
125
+ let shellPhase = true;
126
+ const queued = createQueuedTextStream();
127
+ const { shellReady, allReady, abort } = startStreamingRender(
128
+ view,
129
+ partialOptions,
130
+ {
131
+ write(chunk) {
132
+ if (shellPhase) {
133
+ shell += chunk;
134
+ return;
135
+ }
136
+ queued.writer.write(chunk);
137
+ },
138
+ close() {
139
+ queued.writer.close();
140
+ },
141
+ abort(reason) {
142
+ queued.writer.abort(reason);
143
+ }
144
+ },
145
+ {
146
+ includeTailInShell: true,
147
+ onShellFlushed() {
148
+ shellPhase = false;
149
+ }
150
+ }
151
+ );
152
+ return {
153
+ shell,
154
+ stream: queued.stream,
155
+ shellReady,
156
+ allReady,
157
+ abort
158
+ };
159
+ }
70
160
  function resolveDom(options) {
71
161
  if (options.dom) {
72
162
  return options.dom;
@@ -88,6 +178,354 @@ function resolveDom(options) {
88
178
  }
89
179
  return createSSRDocument(options.html);
90
180
  }
181
+ function createQueuedTextStream() {
182
+ const encoder = new TextEncoder();
183
+ const queue = [];
184
+ let controller = null;
185
+ let closed = false;
186
+ let aborted;
187
+ const stream = new ReadableStream({
188
+ start(ctrl) {
189
+ controller = ctrl;
190
+ for (const chunk of queue) {
191
+ ctrl.enqueue(chunk);
192
+ }
193
+ queue.length = 0;
194
+ if (aborted !== void 0) {
195
+ ctrl.error(aborted);
196
+ return;
197
+ }
198
+ if (closed) {
199
+ ctrl.close();
200
+ }
201
+ }
202
+ });
203
+ const writer = {
204
+ write(chunk) {
205
+ if (closed || aborted !== void 0) return;
206
+ const data = encoder.encode(chunk);
207
+ if (controller) {
208
+ controller.enqueue(data);
209
+ } else {
210
+ queue.push(data);
211
+ }
212
+ },
213
+ close() {
214
+ if (closed || aborted !== void 0) return;
215
+ closed = true;
216
+ controller?.close();
217
+ },
218
+ abort(reason) {
219
+ if (closed || aborted !== void 0) return;
220
+ aborted = reason ?? new Error("Stream aborted");
221
+ controller?.error(aborted);
222
+ }
223
+ };
224
+ return { stream, writer };
225
+ }
226
+ function createPipeBridge() {
227
+ const nodeBridge = createNodePipeBridge();
228
+ if (nodeBridge) return nodeBridge;
229
+ const targets = /* @__PURE__ */ new Set();
230
+ const buffer = [];
231
+ let state = "open";
232
+ let abortReason = null;
233
+ const safeWrite = (target, chunk) => {
234
+ try {
235
+ target.write(chunk);
236
+ } catch {
237
+ }
238
+ };
239
+ const safeEnd = (target) => {
240
+ try {
241
+ target.end();
242
+ } catch {
243
+ }
244
+ };
245
+ const safeDestroy = (target, reason) => {
246
+ const withDestroy = target;
247
+ if (typeof withDestroy.destroy === "function") {
248
+ try {
249
+ withDestroy.destroy(reason);
250
+ } catch {
251
+ }
252
+ return;
253
+ }
254
+ safeEnd(target);
255
+ };
256
+ return {
257
+ pipe(writable) {
258
+ targets.add(writable);
259
+ if (buffer.length > 0) {
260
+ for (const chunk of buffer) {
261
+ safeWrite(writable, chunk);
262
+ }
263
+ buffer.length = 0;
264
+ }
265
+ if (state === "closed") {
266
+ safeEnd(writable);
267
+ } else if (state === "aborted") {
268
+ safeDestroy(writable, abortReason ?? new Error("Stream aborted"));
269
+ }
270
+ },
271
+ write(chunk) {
272
+ if (state !== "open") return;
273
+ if (targets.size === 0) {
274
+ buffer.push(chunk);
275
+ return;
276
+ }
277
+ for (const target of targets) {
278
+ safeWrite(target, chunk);
279
+ }
280
+ },
281
+ close() {
282
+ if (state !== "open") return;
283
+ state = "closed";
284
+ for (const target of targets) {
285
+ safeEnd(target);
286
+ }
287
+ if (targets.size > 0) {
288
+ buffer.length = 0;
289
+ }
290
+ },
291
+ abort(reason) {
292
+ if (state !== "open") return;
293
+ state = "aborted";
294
+ abortReason = reason instanceof Error ? reason : new Error("Stream aborted");
295
+ for (const target of targets) {
296
+ safeDestroy(target, abortReason);
297
+ }
298
+ buffer.length = 0;
299
+ }
300
+ };
301
+ }
302
+ function createNodePipeBridge() {
303
+ const nodeRequire = getNodeRequire();
304
+ if (!nodeRequire) return null;
305
+ try {
306
+ const streamModule = nodeRequire("node:stream");
307
+ if (!streamModule.PassThrough) return null;
308
+ const passThrough = new streamModule.PassThrough();
309
+ return {
310
+ pipe(writable) {
311
+ passThrough.pipe(writable);
312
+ },
313
+ write(chunk) {
314
+ passThrough.write(chunk);
315
+ },
316
+ close() {
317
+ passThrough.end();
318
+ },
319
+ abort(reason) {
320
+ const error = reason instanceof Error ? reason : new Error("Stream aborted");
321
+ if (typeof passThrough.destroy === "function") {
322
+ passThrough.destroy(error);
323
+ } else {
324
+ passThrough.end();
325
+ }
326
+ }
327
+ };
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+ function startStreamingRender(view, options, writer, control = {}) {
333
+ const resolvedOptions = {
334
+ ...options,
335
+ // Streaming requires a real document; default to fullDocument when unspecified.
336
+ fullDocument: options.fullDocument ?? true
337
+ };
338
+ let resolveShell;
339
+ let resolveAll;
340
+ let rejectAll;
341
+ const shellReady = new Promise((res) => {
342
+ resolveShell = res;
343
+ });
344
+ const allReady = new Promise((res, rej) => {
345
+ resolveAll = res;
346
+ rejectAll = rej;
347
+ });
348
+ let dom = null;
349
+ let restoreGlobals2 = () => {
350
+ };
351
+ let restoreManifest = () => {
352
+ };
353
+ let teardown = () => {
354
+ };
355
+ let container = null;
356
+ let closed = false;
357
+ let tailHtml = "";
358
+ let wroteShell = false;
359
+ let shellCarriesTail = false;
360
+ const mode = options.mode ?? "shell";
361
+ const includeSnapshot = options.includeSnapshot !== false;
362
+ const sentScopes = /* @__PURE__ */ new Set();
363
+ const boundaryMap = /* @__PURE__ */ new Map();
364
+ let boundaryId = 0;
365
+ let pendingCount = 0;
366
+ const writeSnapshotForScopes = (scopeIds) => {
367
+ if (!includeSnapshot || scopeIds.length === 0) return;
368
+ const registry = __fictGetScopeRegistry();
369
+ const pending = scopeIds.filter((id) => registry.has(id) && !sentScopes.has(id));
370
+ if (pending.length === 0) return;
371
+ const snapshot = __fictSerializeSSRStateForScopes(pending);
372
+ const ids = Object.keys(snapshot.scopes);
373
+ if (ids.length === 0) return;
374
+ const chunk = buildIncrementalSnapshotChunk(snapshot, resolvedOptions);
375
+ if (chunk) {
376
+ writer.write(chunk);
377
+ }
378
+ for (const id of ids) {
379
+ sentScopes.add(id);
380
+ }
381
+ };
382
+ const writeSnapshotForBoundary = (boundary) => {
383
+ const scopes = __fictGetScopesForBoundary(boundary);
384
+ writeSnapshotForScopes(scopes);
385
+ };
386
+ const writeRemainingSnapshots = () => {
387
+ const scopes = Array.from(__fictGetScopeRegistry().keys());
388
+ writeSnapshotForScopes(scopes);
389
+ };
390
+ const cleanup = () => {
391
+ __fictSetSSRStreamHooks(null);
392
+ __fictDisableSSR();
393
+ restoreGlobals2();
394
+ restoreManifest();
395
+ try {
396
+ teardown();
397
+ } catch {
398
+ }
399
+ };
400
+ const finalize = () => {
401
+ if (closed) return;
402
+ closed = true;
403
+ if (mode === "all" && dom && container && !wroteShell) {
404
+ if (includeSnapshot) {
405
+ const snapshot = __fictSerializeSSRState();
406
+ injectSnapshot(dom.document, container, snapshot, resolvedOptions);
407
+ }
408
+ const fullHtml = serializeOutput(dom.document, container, resolvedOptions);
409
+ writer.write(fullHtml);
410
+ writer.close();
411
+ cleanup();
412
+ resolveShell();
413
+ resolveAll();
414
+ options.onShellReady?.();
415
+ options.onAllReady?.();
416
+ return;
417
+ }
418
+ writeRemainingSnapshots();
419
+ if (tailHtml) {
420
+ writer.write(tailHtml);
421
+ }
422
+ writer.close();
423
+ cleanup();
424
+ resolveAll();
425
+ options.onAllReady?.();
426
+ };
427
+ const maybeFinalize = () => {
428
+ if (pendingCount === 0) {
429
+ finalize();
430
+ }
431
+ };
432
+ const hooks = {
433
+ registerBoundary(start, end) {
434
+ const id = `s${++boundaryId}`;
435
+ boundaryMap.set(id, { start, end, pending: false });
436
+ return id;
437
+ },
438
+ boundaryPending(id) {
439
+ const entry = boundaryMap.get(id);
440
+ if (!entry || entry.pending) return;
441
+ entry.pending = true;
442
+ pendingCount++;
443
+ },
444
+ boundaryResolved(id) {
445
+ const entry = boundaryMap.get(id);
446
+ if (!entry) return;
447
+ if (entry.pending) {
448
+ entry.pending = false;
449
+ pendingCount = Math.max(0, pendingCount - 1);
450
+ }
451
+ if (mode === "shell") {
452
+ writeSnapshotForBoundary(id);
453
+ if (dom) {
454
+ const html = serializeBetween(dom.document, entry.start, entry.end);
455
+ writer.write(buildPatchChunk(id, html));
456
+ }
457
+ }
458
+ maybeFinalize();
459
+ },
460
+ onError(err) {
461
+ options.onError?.(err);
462
+ abort(err);
463
+ }
464
+ };
465
+ const abort = (reason) => {
466
+ if (closed) return;
467
+ closed = true;
468
+ writer.abort(reason);
469
+ cleanup();
470
+ rejectAll(reason ?? new Error("Stream aborted"));
471
+ };
472
+ if (options.signal) {
473
+ if (options.signal.aborted) {
474
+ abort(options.signal.reason);
475
+ } else {
476
+ options.signal.addEventListener("abort", () => abort(options.signal?.reason), { once: true });
477
+ }
478
+ }
479
+ try {
480
+ __fictEnableSSR();
481
+ __fictSetSSRStreamHooks(hooks);
482
+ dom = resolveDom(resolvedOptions);
483
+ restoreGlobals2 = resolvedOptions.exposeGlobals !== false ? installGlobals(dom.window, dom.document) : () => {
484
+ };
485
+ restoreManifest = installManifest(resolvedOptions.manifest);
486
+ container = resolveContainer(dom.document, resolvedOptions);
487
+ teardown = render(view, container);
488
+ if (mode === "all") {
489
+ if (pendingCount === 0) {
490
+ finalize();
491
+ }
492
+ return { shellReady, allReady, abort };
493
+ }
494
+ const shellHtml = serializeOutput(dom.document, container, resolvedOptions);
495
+ const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript() : "";
496
+ if (resolvedOptions.fullDocument) {
497
+ const split = splitDocumentHtml(shellHtml);
498
+ if (!split) {
499
+ throw new Error("[fict/ssr] Failed to locate </body> for streaming output.");
500
+ }
501
+ if (control.includeTailInShell) {
502
+ writer.write(split.head + streamRuntime);
503
+ tailHtml = split.tail;
504
+ shellCarriesTail = true;
505
+ } else {
506
+ writer.write(split.head + streamRuntime);
507
+ tailHtml = split.tail;
508
+ }
509
+ } else {
510
+ writer.write(shellHtml + streamRuntime);
511
+ }
512
+ wroteShell = true;
513
+ writeSnapshotForScopes(Array.from(__fictGetScopeRegistry().keys()));
514
+ if (shellCarriesTail && tailHtml) {
515
+ writer.write(tailHtml);
516
+ tailHtml = "";
517
+ shellCarriesTail = false;
518
+ }
519
+ control.onShellFlushed?.();
520
+ resolveShell();
521
+ options.onShellReady?.();
522
+ maybeFinalize();
523
+ } catch (err) {
524
+ options.onError?.(err);
525
+ abort(err);
526
+ }
527
+ return { shellReady, allReady, abort };
528
+ }
91
529
  function resolveContainer(document, options) {
92
530
  if (options.container) {
93
531
  if (options.container.ownerDocument && options.container.ownerDocument !== document) {
@@ -111,6 +549,35 @@ function resolveContainer(document, options) {
111
549
  }
112
550
  return container;
113
551
  }
552
+ function buildStreamRuntimeScript() {
553
+ return `<script>(function(){if(window.__FICT_STREAM)return;var cache=new Map();function find(id){var hit=cache.get(id);if(hit)return hit;var start=null,end=null;var w=document.createTreeWalker(document,NodeFilter.SHOW_COMMENT);while(w.nextNode()){var n=w.currentNode;var d=n.data;if(d==="fict:suspense-start:"+id)start=n;else if(d==="fict:suspense-end:"+id)end=n;if(start&&end)break;}if(start&&end){hit={start:start,end:end};cache.set(id,hit);}return hit;}function apply(id){var tpl=document.querySelector('template[data-fict-suspense="' + id + '"]');if(!tpl)return;var b=find(id);if(!b)return;var node=b.start.nextSibling;while(node&&node!==b.end){var next=node.nextSibling;node.parentNode&&node.parentNode.removeChild(node);node=next;}b.end.parentNode&&b.end.parentNode.insertBefore(tpl.content,b.end);tpl.parentNode&&tpl.parentNode.removeChild(tpl);}window.__FICT_STREAM={apply:apply};})();</script>`;
554
+ }
555
+ function buildPatchChunk(id, html) {
556
+ return `<template data-fict-suspense="${id}">` + html + `</template><script>__FICT_STREAM.apply("${id}")</script>`;
557
+ }
558
+ function serializeBetween(document, start, end) {
559
+ const wrapper = document.createElement("div");
560
+ let node = start.nextSibling;
561
+ while (node && node !== end) {
562
+ wrapper.appendChild(node.cloneNode(true));
563
+ node = node.nextSibling;
564
+ }
565
+ return wrapper.innerHTML;
566
+ }
567
+ function splitDocumentHtml(html) {
568
+ const lower = html.toLowerCase();
569
+ const idx = lower.lastIndexOf("</body>");
570
+ if (idx === -1) return null;
571
+ return { head: html.slice(0, idx), tail: html.slice(idx) };
572
+ }
573
+ function buildIncrementalSnapshotChunk(state, options) {
574
+ const json = JSON.stringify(state);
575
+ if (options.snapshotTarget === "head") {
576
+ const jsonLiteral = JSON.stringify(json);
577
+ return `<script>(function(){var s=document.createElement('script');s.type='application/json';s.setAttribute('data-fict-snapshot','');s.textContent=${jsonLiteral};(document.head||document.documentElement).appendChild(s);}())</script>`;
578
+ }
579
+ return `<script type="application/json" data-fict-snapshot>${json}</script>`;
580
+ }
114
581
  function serializeOutput(document, container, options) {
115
582
  if (options.fullDocument) {
116
583
  const doctype = serializeDoctype(document, options.doctype);
@@ -225,12 +692,39 @@ function restoreGlobals(snapshot) {
225
692
  }
226
693
  }
227
694
  }
695
+ function readTextFileFromPath(path) {
696
+ const g = globalThis;
697
+ const deno = g.Deno;
698
+ if (deno && typeof deno.readTextFileSync === "function") {
699
+ return deno.readTextFileSync(path);
700
+ }
701
+ const nodeRequire = getNodeRequire();
702
+ if (nodeRequire) {
703
+ const fs = nodeRequire("node:fs");
704
+ return fs.readFileSync(path, "utf8");
705
+ }
706
+ throw new Error(
707
+ "[fict/ssr] `manifest` as file path is only supported in Node.js or Deno. Pass a manifest object in edge runtimes."
708
+ );
709
+ }
710
+ function getNodeRequire() {
711
+ const g = globalThis;
712
+ const direct = g.require;
713
+ if (typeof direct === "function") {
714
+ return direct;
715
+ }
716
+ try {
717
+ return Function('return typeof require === "function" ? require : null')();
718
+ } catch {
719
+ return null;
720
+ }
721
+ }
228
722
  function installManifest(manifest) {
229
723
  if (!manifest) return () => {
230
724
  };
231
725
  let resolved;
232
726
  if (typeof manifest === "string") {
233
- const raw = readFileSync(manifest, "utf8");
727
+ const raw = readTextFileFromPath(manifest);
234
728
  resolved = JSON.parse(raw);
235
729
  } else {
236
730
  resolved = manifest;
@@ -253,6 +747,9 @@ function installManifest(manifest) {
253
747
  export {
254
748
  createSSRDocument,
255
749
  renderToDocument,
750
+ renderToPartial,
751
+ renderToPipeableStream,
752
+ renderToStream,
256
753
  renderToString,
257
754
  renderToStringAsync
258
755
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/ssr",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Fict server-side rendering",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "linkedom": "^0.18.12",
30
- "@fictjs/runtime": "0.5.0"
30
+ "@fictjs/runtime": "0.5.2"
31
31
  },
32
32
  "devDependencies": {
33
33
  "tsup": "^8.5.1"
@@ -45,6 +45,7 @@
45
45
  "build": "tsup src/index.ts --format cjs,esm --dts",
46
46
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
47
47
  "test": "vitest run",
48
+ "test:edge": "node test/edge-runtime.smoke.mjs",
48
49
  "test:coverage": "vitest run --coverage",
49
50
  "lint": "eslint src",
50
51
  "typecheck": "tsc --noEmit",