@fictjs/ssr 0.19.0 → 0.20.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.
package/dist/index.js CHANGED
@@ -1,10 +1,16 @@
1
+ import {
2
+ createStreamRuntimeCode
3
+ } from "./chunk-DI4PFT6R.js";
4
+
1
5
  // src/index.ts
2
6
  import { render } from "@fictjs/runtime";
3
7
  import {
4
8
  __fictDisableSSR,
5
9
  __fictEnableSSR,
10
+ __fictCreateSSRSession,
6
11
  __fictGetScopeRegistry,
7
12
  __fictGetScopesForBoundary,
13
+ __fictRunWithSSRSession,
8
14
  __fictSerializeSSRState,
9
15
  __fictSerializeSSRStateForScopes,
10
16
  __fictSetSSRStreamHooks
@@ -124,12 +130,33 @@ function getNodeRequire() {
124
130
  }
125
131
 
126
132
  // src/stream-bridge.ts
127
- function createQueuedTextStream() {
133
+ function createQueuedTextStream(options = {}) {
128
134
  const encoder = new TextEncoder();
129
135
  const queue = [];
130
136
  let controller = null;
131
137
  let closed = false;
132
138
  let aborted;
139
+ const readyResolvers = [];
140
+ const resolveReady = () => {
141
+ if (!controller || (controller.desiredSize ?? 1) <= 0) return;
142
+ while (readyResolvers.length > 0) {
143
+ readyResolvers.shift()?.();
144
+ }
145
+ };
146
+ const drainReady = () => {
147
+ while (readyResolvers.length > 0) {
148
+ readyResolvers.shift()?.();
149
+ }
150
+ };
151
+ const abortQueue = (reason, notifyController = true) => {
152
+ if (closed || aborted !== void 0) return;
153
+ aborted = reason ?? new Error("Stream aborted");
154
+ queue.length = 0;
155
+ drainReady();
156
+ if (notifyController) {
157
+ controller?.error(aborted);
158
+ }
159
+ };
133
160
  const stream = new ReadableStream({
134
161
  start(ctrl) {
135
162
  controller = ctrl;
@@ -144,6 +171,13 @@ function createQueuedTextStream() {
144
171
  if (closed) {
145
172
  ctrl.close();
146
173
  }
174
+ },
175
+ pull() {
176
+ resolveReady();
177
+ },
178
+ cancel(reason) {
179
+ abortQueue(reason, false);
180
+ options.onCancel?.(reason);
147
181
  }
148
182
  });
149
183
  const writer = {
@@ -152,19 +186,24 @@ function createQueuedTextStream() {
152
186
  const data = encoder.encode(chunk);
153
187
  if (controller) {
154
188
  controller.enqueue(data);
189
+ if ((controller.desiredSize ?? 1) <= 0) {
190
+ return new Promise((resolve) => {
191
+ readyResolvers.push(resolve);
192
+ });
193
+ }
155
194
  } else {
156
195
  queue.push(data);
157
196
  }
197
+ return void 0;
158
198
  },
159
199
  close() {
160
200
  if (closed || aborted !== void 0) return;
161
201
  closed = true;
202
+ drainReady();
162
203
  controller?.close();
163
204
  },
164
205
  abort(reason) {
165
- if (closed || aborted !== void 0) return;
166
- aborted = reason ?? new Error("Stream aborted");
167
- controller?.error(aborted);
206
+ abortQueue(reason);
168
207
  }
169
208
  };
170
209
  return { stream, writer };
@@ -178,9 +217,20 @@ function createPipeBridge() {
178
217
  let abortReason = null;
179
218
  const safeWrite = (target, chunk) => {
180
219
  try {
181
- target.write(chunk);
220
+ const ready = target.write(chunk);
221
+ if (ready === false) {
222
+ return new Promise((resolve) => {
223
+ const withOnce = target;
224
+ if (typeof withOnce.once === "function") {
225
+ withOnce.once("drain", resolve);
226
+ } else {
227
+ resolve();
228
+ }
229
+ });
230
+ }
182
231
  } catch {
183
232
  }
233
+ return void 0;
184
234
  };
185
235
  const safeEnd = (target) => {
186
236
  try {
@@ -220,9 +270,12 @@ function createPipeBridge() {
220
270
  buffer.push(chunk);
221
271
  return;
222
272
  }
273
+ const pending = [];
223
274
  for (const target of targets) {
224
- safeWrite(target, chunk);
275
+ const result = safeWrite(target, chunk);
276
+ if (result) pending.push(result);
225
277
  }
278
+ return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
226
279
  },
227
280
  close() {
228
281
  if (state !== "open") return;
@@ -252,22 +305,78 @@ function createNodePipeBridge() {
252
305
  const streamModule = nodeRequire("node:stream");
253
306
  if (!streamModule.PassThrough) return null;
254
307
  const passThrough = new streamModule.PassThrough();
308
+ const buffer = [];
309
+ let piped = false;
310
+ let state = "open";
311
+ let abortReason = null;
312
+ const writeToPassThrough = (chunk) => {
313
+ if (passThrough.write(chunk) === false) {
314
+ return new Promise((resolve) => {
315
+ const withOnce = passThrough;
316
+ if (typeof withOnce.once === "function") {
317
+ withOnce.once("drain", resolve);
318
+ } else {
319
+ resolve();
320
+ }
321
+ });
322
+ }
323
+ return void 0;
324
+ };
325
+ const flushBuffer = () => {
326
+ if (buffer.length === 0) return void 0;
327
+ const pending = [];
328
+ for (const chunk of buffer) {
329
+ const result = writeToPassThrough(chunk);
330
+ if (result) pending.push(result);
331
+ }
332
+ buffer.length = 0;
333
+ return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
334
+ };
335
+ const destroyPassThrough = (error) => {
336
+ if (typeof passThrough.destroy === "function") {
337
+ passThrough.destroy(error);
338
+ } else {
339
+ passThrough.end();
340
+ }
341
+ };
255
342
  return {
256
343
  pipe(writable) {
344
+ piped = true;
257
345
  passThrough.pipe(writable);
346
+ if (state === "aborted") {
347
+ destroyPassThrough(abortReason ?? new Error("Stream aborted"));
348
+ return;
349
+ }
350
+ const flushed = flushBuffer();
351
+ if (state === "closed") {
352
+ if (flushed) {
353
+ void flushed.then(() => passThrough.end());
354
+ } else {
355
+ passThrough.end();
356
+ }
357
+ }
258
358
  },
259
359
  write(chunk) {
260
- passThrough.write(chunk);
360
+ if (state !== "open") return;
361
+ if (!piped) {
362
+ buffer.push(chunk);
363
+ return void 0;
364
+ }
365
+ return writeToPassThrough(chunk);
261
366
  },
262
367
  close() {
368
+ if (state !== "open") return;
369
+ state = "closed";
370
+ if (!piped) return;
263
371
  passThrough.end();
264
372
  },
265
373
  abort(reason) {
266
- const error = reason instanceof Error ? reason : new Error("Stream aborted");
267
- if (typeof passThrough.destroy === "function") {
268
- passThrough.destroy(error);
269
- } else {
270
- passThrough.end();
374
+ if (state !== "open") return;
375
+ state = "aborted";
376
+ abortReason = reason instanceof Error ? reason : new Error("Stream aborted");
377
+ buffer.length = 0;
378
+ if (piped) {
379
+ destroyPassThrough(abortReason);
271
380
  }
272
381
  }
273
382
  };
@@ -299,6 +408,10 @@ function createSSRDocument(html = DEFAULT_HTML) {
299
408
  return { window, document };
300
409
  }
301
410
  function renderToDocument(view, options = {}) {
411
+ const session = __fictCreateSSRSession();
412
+ return __fictRunWithSSRSession(session, () => renderToDocumentInSession(view, options));
413
+ }
414
+ function renderToDocumentInSession(view, options) {
302
415
  const includeSnapshot = options.includeSnapshot !== false;
303
416
  __fictEnableSSR();
304
417
  let dom;
@@ -352,6 +465,37 @@ async function renderToStringAsync(view, options = {}) {
352
465
  function renderToStream(view, options = {}) {
353
466
  const encoder = new TextEncoder();
354
467
  let controller = null;
468
+ let abortRender = null;
469
+ const readyResolvers = [];
470
+ const drainBackpressure = () => {
471
+ while (readyResolvers.length > 0) {
472
+ readyResolvers.shift()?.();
473
+ }
474
+ };
475
+ const resolveBackpressure = () => {
476
+ if (!controller || (controller.desiredSize ?? 1) <= 0) return;
477
+ drainBackpressure();
478
+ };
479
+ const closeController = () => {
480
+ abortRender = null;
481
+ drainBackpressure();
482
+ if (!controller) return;
483
+ try {
484
+ controller.close();
485
+ } finally {
486
+ controller = null;
487
+ }
488
+ };
489
+ const errorController = (reason) => {
490
+ abortRender = null;
491
+ drainBackpressure();
492
+ if (!controller) return;
493
+ try {
494
+ controller.error(reason);
495
+ } finally {
496
+ controller = null;
497
+ }
498
+ };
355
499
  const stream = new ReadableStream({
356
500
  start(ctrl) {
357
501
  controller = ctrl;
@@ -359,15 +503,32 @@ function renderToStream(view, options = {}) {
359
503
  write(chunk) {
360
504
  if (!controller) return;
361
505
  controller.enqueue(encoder.encode(chunk));
506
+ if ((controller.desiredSize ?? 1) <= 0) {
507
+ return new Promise((resolve) => {
508
+ readyResolvers.push(resolve);
509
+ });
510
+ }
511
+ return void 0;
362
512
  },
363
513
  close() {
364
- controller?.close();
514
+ closeController();
365
515
  },
366
516
  abort(reason) {
367
- controller?.error(reason);
517
+ errorController(reason);
368
518
  }
369
519
  });
520
+ abortRender = started.abort;
521
+ started.shellReady.catch(() => void 0);
370
522
  started.allReady.catch(() => void 0);
523
+ },
524
+ pull() {
525
+ resolveBackpressure();
526
+ },
527
+ cancel(reason) {
528
+ const abort = abortRender;
529
+ controller = null;
530
+ drainBackpressure();
531
+ abort?.(reason ?? new Error("Stream canceled"));
371
532
  }
372
533
  });
373
534
  return stream;
@@ -376,7 +537,7 @@ function renderToPipeableStream(view, options = {}) {
376
537
  const bridge = createPipeBridge();
377
538
  const { shellReady, allReady, abort } = startStreamingRender(view, options, {
378
539
  write(chunk) {
379
- bridge.write(chunk);
540
+ return bridge.write(chunk);
380
541
  },
381
542
  close() {
382
543
  bridge.close();
@@ -402,7 +563,12 @@ function renderToPartial(view, options = {}) {
402
563
  };
403
564
  let shell = "";
404
565
  let shellPhase = true;
405
- const queued = createQueuedTextStream();
566
+ let abortPartial = null;
567
+ const queued = createQueuedTextStream({
568
+ onCancel(reason) {
569
+ abortPartial?.(reason ?? new Error("Stream canceled"));
570
+ }
571
+ });
406
572
  const { shellReady, allReady, abort } = startStreamingRender(
407
573
  view,
408
574
  partialOptions,
@@ -412,7 +578,7 @@ function renderToPartial(view, options = {}) {
412
578
  shell += chunk;
413
579
  return;
414
580
  }
415
- queued.writer.write(chunk);
581
+ return queued.writer.write(chunk);
416
582
  },
417
583
  close() {
418
584
  queued.writer.close();
@@ -428,6 +594,7 @@ function renderToPartial(view, options = {}) {
428
594
  }
429
595
  }
430
596
  );
597
+ abortPartial = abort;
431
598
  return {
432
599
  shell,
433
600
  stream: queued.stream,
@@ -457,17 +624,39 @@ function resolveDom(options) {
457
624
  }
458
625
  return createSSRDocument(options.html);
459
626
  }
627
+ function isPromiseLike(value) {
628
+ return typeof value === "object" && value !== null && typeof value.then === "function";
629
+ }
460
630
  function startStreamingRender(view, options, writer, control = {}) {
631
+ const session = __fictCreateSSRSession();
632
+ return __fictRunWithSSRSession(
633
+ session,
634
+ () => startStreamingRenderInSession(session, view, options, writer, control)
635
+ );
636
+ }
637
+ function startStreamingRenderInSession(session, view, options, writer, control = {}) {
638
+ const runInSession = (fn) => __fictRunWithSSRSession(session, fn);
461
639
  const resolvedOptions = {
462
640
  ...options,
463
641
  // Streaming requires a real document; default to fullDocument when unspecified.
464
642
  fullDocument: options.fullDocument ?? true
465
643
  };
466
644
  let resolveShell;
645
+ let rejectShell;
467
646
  let resolveAll;
468
647
  let rejectAll;
469
- const shellReady = new Promise((res) => {
470
- resolveShell = res;
648
+ let shellSettled = false;
649
+ const shellReady = new Promise((res, rej) => {
650
+ resolveShell = () => {
651
+ if (shellSettled) return;
652
+ shellSettled = true;
653
+ res();
654
+ };
655
+ rejectShell = (err) => {
656
+ if (shellSettled) return;
657
+ shellSettled = true;
658
+ rej(err);
659
+ };
471
660
  });
472
661
  const allReady = new Promise((res, rej) => {
473
662
  resolveAll = res;
@@ -485,39 +674,123 @@ function startStreamingRender(view, options, writer, control = {}) {
485
674
  let tailHtml = "";
486
675
  let wroteShell = false;
487
676
  let shellCarriesTail = false;
677
+ let writeChain = null;
678
+ let writeFailed = false;
679
+ let removeAbortListener = () => {
680
+ };
488
681
  const mode = options.mode ?? "shell";
489
682
  const includeSnapshot = options.includeSnapshot !== false;
490
683
  const sentScopes = /* @__PURE__ */ new Set();
491
684
  const boundaryMap = /* @__PURE__ */ new Map();
492
685
  let boundaryId = 0;
493
686
  let pendingCount = 0;
494
- const writeSnapshotForScopes = (scopeIds) => {
495
- if (!includeSnapshot || scopeIds.length === 0) return;
496
- const registry = __fictGetScopeRegistry();
497
- const pending = scopeIds.filter((id) => registry.has(id) && !sentScopes.has(id));
498
- if (pending.length === 0) return;
499
- const snapshot = __fictSerializeSSRStateForScopes(pending);
500
- const ids = Object.keys(snapshot.scopes);
501
- if (ids.length === 0) return;
502
- const chunk = buildIncrementalSnapshotChunk(snapshot, resolvedOptions);
503
- if (chunk) {
504
- writer.write(chunk);
687
+ const handleWriteError = (error) => {
688
+ if (writeFailed) return;
689
+ writeFailed = true;
690
+ options.onError?.(error);
691
+ writer.abort(error);
692
+ cleanup();
693
+ rejectShell(error);
694
+ rejectAll(error);
695
+ };
696
+ const enqueueWrite = (chunk) => {
697
+ if (writeFailed) return;
698
+ const trackWrite = (promise) => {
699
+ const tracked = promise.then(
700
+ () => {
701
+ if (writeChain === tracked) {
702
+ writeChain = null;
703
+ }
704
+ },
705
+ (error) => {
706
+ if (writeChain === tracked) {
707
+ writeChain = null;
708
+ }
709
+ handleWriteError(error);
710
+ }
711
+ );
712
+ writeChain = tracked;
713
+ };
714
+ const writeAsync = () => Promise.resolve(writer.write(chunk)).then(() => void 0);
715
+ if (writeChain) {
716
+ trackWrite(writeChain.then(writeAsync));
717
+ return;
718
+ }
719
+ try {
720
+ const result = writer.write(chunk);
721
+ if (isPromiseLike(result)) {
722
+ trackWrite(
723
+ result.then(
724
+ () => void 0,
725
+ (error) => {
726
+ throw error;
727
+ }
728
+ )
729
+ );
730
+ }
731
+ } catch (error) {
732
+ handleWriteError(error);
505
733
  }
506
- for (const id of ids) {
507
- sentScopes.add(id);
734
+ };
735
+ const afterWrites = (fn) => {
736
+ const pending = writeChain;
737
+ if (!pending) {
738
+ if (!writeFailed) {
739
+ fn();
740
+ }
741
+ return;
508
742
  }
743
+ void pending.then(() => {
744
+ if (!writeFailed) {
745
+ fn();
746
+ }
747
+ });
748
+ };
749
+ const markShellReady = () => {
750
+ afterWrites(() => {
751
+ control.onShellFlushed?.();
752
+ resolveShell();
753
+ options.onShellReady?.();
754
+ });
755
+ };
756
+ const writeSnapshotForScopes = (scopeIds) => {
757
+ runInSession(() => {
758
+ if (!includeSnapshot || scopeIds.length === 0) return;
759
+ const registry = __fictGetScopeRegistry();
760
+ const pending = scopeIds.filter((id) => registry.has(id) && !sentScopes.has(id));
761
+ if (pending.length === 0) return;
762
+ const snapshot = __fictSerializeSSRStateForScopes(pending);
763
+ const ids = Object.keys(snapshot.scopes);
764
+ if (ids.length === 0) return;
765
+ const chunk = buildIncrementalSnapshotChunk(snapshot, resolvedOptions);
766
+ if (chunk) {
767
+ enqueueWrite(chunk);
768
+ }
769
+ for (const id of ids) {
770
+ sentScopes.add(id);
771
+ }
772
+ });
509
773
  };
510
774
  const writeSnapshotForBoundary = (boundary) => {
511
- const scopes = __fictGetScopesForBoundary(boundary);
512
- writeSnapshotForScopes(scopes);
775
+ runInSession(() => {
776
+ const scopes = __fictGetScopesForBoundary(boundary);
777
+ writeSnapshotForScopes(scopes);
778
+ });
513
779
  };
514
780
  const writeRemainingSnapshots = () => {
515
- const scopes = Array.from(__fictGetScopeRegistry().keys());
516
- writeSnapshotForScopes(scopes);
781
+ runInSession(() => {
782
+ const scopes = Array.from(__fictGetScopeRegistry().keys());
783
+ writeSnapshotForScopes(scopes);
784
+ });
517
785
  };
518
786
  const cleanup = () => {
519
- __fictSetSSRStreamHooks(null);
520
- __fictDisableSSR();
787
+ removeAbortListener();
788
+ removeAbortListener = () => {
789
+ };
790
+ runInSession(() => {
791
+ __fictSetSSRStreamHooks(null);
792
+ __fictDisableSSR();
793
+ });
521
794
  restoreGlobals2();
522
795
  restoreManifest();
523
796
  try {
@@ -534,23 +807,27 @@ function startStreamingRender(view, options, writer, control = {}) {
534
807
  injectSnapshot(dom.document, container, snapshot, resolvedOptions);
535
808
  }
536
809
  const fullHtml = serializeOutput(dom.document, container, resolvedOptions);
537
- writer.write(fullHtml);
538
- writer.close();
539
- cleanup();
540
- resolveShell();
541
- resolveAll();
542
- options.onShellReady?.();
543
- options.onAllReady?.();
810
+ enqueueWrite(fullHtml);
811
+ afterWrites(() => {
812
+ writer.close();
813
+ cleanup();
814
+ resolveShell();
815
+ resolveAll();
816
+ options.onShellReady?.();
817
+ options.onAllReady?.();
818
+ });
544
819
  return;
545
820
  }
546
821
  writeRemainingSnapshots();
547
822
  if (tailHtml) {
548
- writer.write(tailHtml);
823
+ enqueueWrite(tailHtml);
549
824
  }
550
- writer.close();
551
- cleanup();
552
- resolveAll();
553
- options.onAllReady?.();
825
+ afterWrites(() => {
826
+ writer.close();
827
+ cleanup();
828
+ resolveAll();
829
+ options.onAllReady?.();
830
+ });
554
831
  };
555
832
  const maybeFinalize = () => {
556
833
  if (pendingCount === 0) {
@@ -580,7 +857,7 @@ function startStreamingRender(view, options, writer, control = {}) {
580
857
  writeSnapshotForBoundary(id);
581
858
  if (dom) {
582
859
  const html = serializeBetween(dom.document, entry.start, entry.end);
583
- writer.write(buildPatchChunk(id, html));
860
+ enqueueWrite(buildPatchChunk(id, html, resolvedOptions));
584
861
  }
585
862
  }
586
863
  maybeFinalize();
@@ -593,15 +870,21 @@ function startStreamingRender(view, options, writer, control = {}) {
593
870
  const abort = (reason) => {
594
871
  if (closed) return;
595
872
  closed = true;
873
+ writeFailed = true;
596
874
  writer.abort(reason);
597
875
  cleanup();
598
- rejectAll(reason ?? new Error("Stream aborted"));
876
+ const abortReason = reason ?? new Error("Stream aborted");
877
+ rejectShell(abortReason);
878
+ rejectAll(abortReason);
599
879
  };
600
880
  if (options.signal) {
601
881
  if (options.signal.aborted) {
602
882
  abort(options.signal.reason);
883
+ return { shellReady, allReady, abort };
603
884
  } else {
604
- options.signal.addEventListener("abort", () => abort(options.signal?.reason), { once: true });
885
+ const onAbort = () => abort(options.signal?.reason);
886
+ options.signal.addEventListener("abort", onAbort, { once: true });
887
+ removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
605
888
  }
606
889
  }
607
890
  try {
@@ -620,33 +903,31 @@ function startStreamingRender(view, options, writer, control = {}) {
620
903
  return { shellReady, allReady, abort };
621
904
  }
622
905
  const shellHtml = serializeOutput(dom.document, container, resolvedOptions);
623
- const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript() : "";
906
+ const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript(resolvedOptions) : "";
624
907
  if (resolvedOptions.fullDocument) {
625
908
  const split = splitDocumentHtml(shellHtml);
626
909
  if (!split) {
627
910
  throw new Error("[fict/ssr] Failed to locate </body> for streaming output.");
628
911
  }
629
912
  if (control.includeTailInShell) {
630
- writer.write(split.head + streamRuntime);
913
+ enqueueWrite(split.head + streamRuntime);
631
914
  tailHtml = split.tail;
632
915
  shellCarriesTail = true;
633
916
  } else {
634
- writer.write(split.head + streamRuntime);
917
+ enqueueWrite(split.head + streamRuntime);
635
918
  tailHtml = split.tail;
636
919
  }
637
920
  } else {
638
- writer.write(shellHtml + streamRuntime);
921
+ enqueueWrite(shellHtml + streamRuntime);
639
922
  }
640
923
  wroteShell = true;
641
924
  writeSnapshotForScopes(Array.from(__fictGetScopeRegistry().keys()));
642
925
  if (shellCarriesTail && tailHtml) {
643
- writer.write(tailHtml);
926
+ enqueueWrite(tailHtml);
644
927
  tailHtml = "";
645
928
  shellCarriesTail = false;
646
929
  }
647
- control.onShellFlushed?.();
648
- resolveShell();
649
- options.onShellReady?.();
930
+ markShellReady();
650
931
  maybeFinalize();
651
932
  } catch (err) {
652
933
  options.onError?.(err);
@@ -677,11 +958,28 @@ function resolveContainer(document, options) {
677
958
  }
678
959
  return container;
679
960
  }
680
- function buildStreamRuntimeScript() {
681
- 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>`;
961
+ function buildStreamRuntimeScript(options) {
962
+ const nonce = renderNonceAttribute(options);
963
+ if (options.streamRuntime === "external") {
964
+ if (!options.streamRuntimeSrc) {
965
+ throw new Error('[fict/ssr] streamRuntimeSrc is required when streamRuntime is "external".');
966
+ }
967
+ return `<script${nonce} src="${escapeAttribute(options.streamRuntimeSrc)}" data-fict-stream-runtime data-fict-stream-observer></script>`;
968
+ }
969
+ return `<script${nonce}>${createStreamRuntimeCode({
970
+ observerMode: resolveStreamPatchMode(options) === "observer"
971
+ })}</script>`;
972
+ }
973
+ function buildPatchChunk(id, html, options) {
974
+ const template = `<template data-fict-suspense="${escapeAttribute(id)}">${html}</template>`;
975
+ if (resolveStreamPatchMode(options) === "observer") {
976
+ return template;
977
+ }
978
+ return `${template}<script${renderNonceAttribute(options)}>__FICT_STREAM.apply("${escapeScriptString(id)}")</script>`;
682
979
  }
683
- function buildPatchChunk(id, html) {
684
- return `<template data-fict-suspense="${id}">` + html + `</template><script>__FICT_STREAM.apply("${id}")</script>`;
980
+ function resolveStreamPatchMode(options) {
981
+ if (options.streamPatchMode) return options.streamPatchMode;
982
+ return options.streamRuntime === "external" ? "observer" : "inline";
685
983
  }
686
984
  function serializeBetween(document, start, end) {
687
985
  const wrapper = document.createElement("div");
@@ -700,11 +998,13 @@ function splitDocumentHtml(html) {
700
998
  }
701
999
  function buildIncrementalSnapshotChunk(state, options) {
702
1000
  const json = serializeSnapshotForScript(state);
1001
+ const nonce = renderNonceAttribute(options);
703
1002
  if (options.snapshotTarget === "head") {
704
1003
  const jsonLiteral = JSON.stringify(json);
705
- 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>`;
1004
+ const setNonce = options.scriptNonce !== void 0 ? `s.setAttribute('nonce',${serializeScriptStringLiteral(options.scriptNonce)});` : "";
1005
+ return `<script${nonce}>(function(){var s=document.createElement('script');s.type='application/json';s.setAttribute('data-fict-snapshot','');${setNonce}s.textContent=${jsonLiteral};(document.head||document.documentElement).appendChild(s);}())</script>`;
706
1006
  }
707
- return `<script type="application/json" data-fict-snapshot>${json}</script>`;
1007
+ return `<script${nonce} type="application/json" data-fict-snapshot>${json}</script>`;
708
1008
  }
709
1009
  function serializeOutput(document, container, options) {
710
1010
  if (options.fullDocument) {
@@ -721,6 +1021,9 @@ function injectSnapshot(document, container, state, options) {
721
1021
  const script = document.createElement("script");
722
1022
  script.type = "application/json";
723
1023
  script.id = options.snapshotScriptId ?? "__FICT_SNAPSHOT__";
1024
+ if (options.scriptNonce !== void 0) {
1025
+ script.setAttribute("nonce", options.scriptNonce);
1026
+ }
724
1027
  script.textContent = serializeSnapshotForScript(state);
725
1028
  if (options.fullDocument) {
726
1029
  if (options.snapshotTarget === "head" && document.head) {
@@ -746,6 +1049,18 @@ function injectSnapshot(document, container, state, options) {
746
1049
  function serializeSnapshotForScript(state) {
747
1050
  return JSON.stringify(state).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
748
1051
  }
1052
+ function serializeScriptStringLiteral(value) {
1053
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1054
+ }
1055
+ function renderNonceAttribute(options) {
1056
+ return options.scriptNonce === void 0 ? "" : ` nonce="${escapeAttribute(options.scriptNonce)}"`;
1057
+ }
1058
+ function escapeAttribute(value) {
1059
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1060
+ }
1061
+ function escapeScriptString(value) {
1062
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1063
+ }
749
1064
  function serializeDoctype(document, override) {
750
1065
  if (override === null) return "";
751
1066
  if (override !== void 0) return override;