@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.cjs CHANGED
@@ -146,12 +146,33 @@ function getNodeRequire() {
146
146
  }
147
147
 
148
148
  // src/stream-bridge.ts
149
- function createQueuedTextStream() {
149
+ function createQueuedTextStream(options = {}) {
150
150
  const encoder = new TextEncoder();
151
151
  const queue = [];
152
152
  let controller = null;
153
153
  let closed = false;
154
154
  let aborted;
155
+ const readyResolvers = [];
156
+ const resolveReady = () => {
157
+ if (!controller || (controller.desiredSize ?? 1) <= 0) return;
158
+ while (readyResolvers.length > 0) {
159
+ readyResolvers.shift()?.();
160
+ }
161
+ };
162
+ const drainReady = () => {
163
+ while (readyResolvers.length > 0) {
164
+ readyResolvers.shift()?.();
165
+ }
166
+ };
167
+ const abortQueue = (reason, notifyController = true) => {
168
+ if (closed || aborted !== void 0) return;
169
+ aborted = reason ?? new Error("Stream aborted");
170
+ queue.length = 0;
171
+ drainReady();
172
+ if (notifyController) {
173
+ controller?.error(aborted);
174
+ }
175
+ };
155
176
  const stream = new ReadableStream({
156
177
  start(ctrl) {
157
178
  controller = ctrl;
@@ -166,6 +187,13 @@ function createQueuedTextStream() {
166
187
  if (closed) {
167
188
  ctrl.close();
168
189
  }
190
+ },
191
+ pull() {
192
+ resolveReady();
193
+ },
194
+ cancel(reason) {
195
+ abortQueue(reason, false);
196
+ options.onCancel?.(reason);
169
197
  }
170
198
  });
171
199
  const writer = {
@@ -174,19 +202,24 @@ function createQueuedTextStream() {
174
202
  const data = encoder.encode(chunk);
175
203
  if (controller) {
176
204
  controller.enqueue(data);
205
+ if ((controller.desiredSize ?? 1) <= 0) {
206
+ return new Promise((resolve) => {
207
+ readyResolvers.push(resolve);
208
+ });
209
+ }
177
210
  } else {
178
211
  queue.push(data);
179
212
  }
213
+ return void 0;
180
214
  },
181
215
  close() {
182
216
  if (closed || aborted !== void 0) return;
183
217
  closed = true;
218
+ drainReady();
184
219
  controller?.close();
185
220
  },
186
221
  abort(reason) {
187
- if (closed || aborted !== void 0) return;
188
- aborted = reason ?? new Error("Stream aborted");
189
- controller?.error(aborted);
222
+ abortQueue(reason);
190
223
  }
191
224
  };
192
225
  return { stream, writer };
@@ -200,9 +233,20 @@ function createPipeBridge() {
200
233
  let abortReason = null;
201
234
  const safeWrite = (target, chunk) => {
202
235
  try {
203
- target.write(chunk);
236
+ const ready = target.write(chunk);
237
+ if (ready === false) {
238
+ return new Promise((resolve) => {
239
+ const withOnce = target;
240
+ if (typeof withOnce.once === "function") {
241
+ withOnce.once("drain", resolve);
242
+ } else {
243
+ resolve();
244
+ }
245
+ });
246
+ }
204
247
  } catch {
205
248
  }
249
+ return void 0;
206
250
  };
207
251
  const safeEnd = (target) => {
208
252
  try {
@@ -242,9 +286,12 @@ function createPipeBridge() {
242
286
  buffer.push(chunk);
243
287
  return;
244
288
  }
289
+ const pending = [];
245
290
  for (const target of targets) {
246
- safeWrite(target, chunk);
291
+ const result = safeWrite(target, chunk);
292
+ if (result) pending.push(result);
247
293
  }
294
+ return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
248
295
  },
249
296
  close() {
250
297
  if (state !== "open") return;
@@ -274,22 +321,78 @@ function createNodePipeBridge() {
274
321
  const streamModule = nodeRequire("node:stream");
275
322
  if (!streamModule.PassThrough) return null;
276
323
  const passThrough = new streamModule.PassThrough();
324
+ const buffer = [];
325
+ let piped = false;
326
+ let state = "open";
327
+ let abortReason = null;
328
+ const writeToPassThrough = (chunk) => {
329
+ if (passThrough.write(chunk) === false) {
330
+ return new Promise((resolve) => {
331
+ const withOnce = passThrough;
332
+ if (typeof withOnce.once === "function") {
333
+ withOnce.once("drain", resolve);
334
+ } else {
335
+ resolve();
336
+ }
337
+ });
338
+ }
339
+ return void 0;
340
+ };
341
+ const flushBuffer = () => {
342
+ if (buffer.length === 0) return void 0;
343
+ const pending = [];
344
+ for (const chunk of buffer) {
345
+ const result = writeToPassThrough(chunk);
346
+ if (result) pending.push(result);
347
+ }
348
+ buffer.length = 0;
349
+ return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
350
+ };
351
+ const destroyPassThrough = (error) => {
352
+ if (typeof passThrough.destroy === "function") {
353
+ passThrough.destroy(error);
354
+ } else {
355
+ passThrough.end();
356
+ }
357
+ };
277
358
  return {
278
359
  pipe(writable) {
360
+ piped = true;
279
361
  passThrough.pipe(writable);
362
+ if (state === "aborted") {
363
+ destroyPassThrough(abortReason ?? new Error("Stream aborted"));
364
+ return;
365
+ }
366
+ const flushed = flushBuffer();
367
+ if (state === "closed") {
368
+ if (flushed) {
369
+ void flushed.then(() => passThrough.end());
370
+ } else {
371
+ passThrough.end();
372
+ }
373
+ }
280
374
  },
281
375
  write(chunk) {
282
- passThrough.write(chunk);
376
+ if (state !== "open") return;
377
+ if (!piped) {
378
+ buffer.push(chunk);
379
+ return void 0;
380
+ }
381
+ return writeToPassThrough(chunk);
283
382
  },
284
383
  close() {
384
+ if (state !== "open") return;
385
+ state = "closed";
386
+ if (!piped) return;
285
387
  passThrough.end();
286
388
  },
287
389
  abort(reason) {
288
- const error = reason instanceof Error ? reason : new Error("Stream aborted");
289
- if (typeof passThrough.destroy === "function") {
290
- passThrough.destroy(error);
291
- } else {
292
- passThrough.end();
390
+ if (state !== "open") return;
391
+ state = "aborted";
392
+ abortReason = reason instanceof Error ? reason : new Error("Stream aborted");
393
+ buffer.length = 0;
394
+ if (piped) {
395
+ destroyPassThrough(abortReason);
293
396
  }
294
397
  }
295
398
  };
@@ -310,6 +413,13 @@ function getNodeRequire2() {
310
413
  }
311
414
  }
312
415
 
416
+ // src/stream-runtime.ts
417
+ function createStreamRuntimeCode(options = {}) {
418
+ const observerMode = options.observerMode ?? true;
419
+ return `(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};` + (observerMode ? 'function scan(root){var list=(root&&root.querySelectorAll?root:document).querySelectorAll("template[data-fict-suspense]");for(var i=0;i<list.length;i++){apply(list[i].getAttribute("data-fict-suspense"));}}if(typeof MutationObserver==="function"){new MutationObserver(function(muts){for(var i=0;i<muts.length;i++){for(var j=0;j<muts[i].addedNodes.length;j++){var n=muts[i].addedNodes[j];if(n.nodeType===1){if(n.matches&&n.matches("template[data-fict-suspense]"))apply(n.getAttribute("data-fict-suspense"));scan(n);}}}}).observe(document.documentElement||document,{childList:true,subtree:true});}if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",function(){scan(document);},{once:true});}else{scan(document);}' : "") + "})();";
420
+ }
421
+ var FICT_STREAM_RUNTIME_CODE = createStreamRuntimeCode({ observerMode: true });
422
+
313
423
  // src/index.ts
314
424
  var DEFAULT_HTML = "<!doctype html><html><head></head><body></body></html>";
315
425
  function createSSRDocument(html = DEFAULT_HTML) {
@@ -321,6 +431,10 @@ function createSSRDocument(html = DEFAULT_HTML) {
321
431
  return { window, document };
322
432
  }
323
433
  function renderToDocument(view, options = {}) {
434
+ const session = (0, import_internal.__fictCreateSSRSession)();
435
+ return (0, import_internal.__fictRunWithSSRSession)(session, () => renderToDocumentInSession(view, options));
436
+ }
437
+ function renderToDocumentInSession(view, options) {
324
438
  const includeSnapshot = options.includeSnapshot !== false;
325
439
  (0, import_internal.__fictEnableSSR)();
326
440
  let dom;
@@ -374,6 +488,37 @@ async function renderToStringAsync(view, options = {}) {
374
488
  function renderToStream(view, options = {}) {
375
489
  const encoder = new TextEncoder();
376
490
  let controller = null;
491
+ let abortRender = null;
492
+ const readyResolvers = [];
493
+ const drainBackpressure = () => {
494
+ while (readyResolvers.length > 0) {
495
+ readyResolvers.shift()?.();
496
+ }
497
+ };
498
+ const resolveBackpressure = () => {
499
+ if (!controller || (controller.desiredSize ?? 1) <= 0) return;
500
+ drainBackpressure();
501
+ };
502
+ const closeController = () => {
503
+ abortRender = null;
504
+ drainBackpressure();
505
+ if (!controller) return;
506
+ try {
507
+ controller.close();
508
+ } finally {
509
+ controller = null;
510
+ }
511
+ };
512
+ const errorController = (reason) => {
513
+ abortRender = null;
514
+ drainBackpressure();
515
+ if (!controller) return;
516
+ try {
517
+ controller.error(reason);
518
+ } finally {
519
+ controller = null;
520
+ }
521
+ };
377
522
  const stream = new ReadableStream({
378
523
  start(ctrl) {
379
524
  controller = ctrl;
@@ -381,15 +526,32 @@ function renderToStream(view, options = {}) {
381
526
  write(chunk) {
382
527
  if (!controller) return;
383
528
  controller.enqueue(encoder.encode(chunk));
529
+ if ((controller.desiredSize ?? 1) <= 0) {
530
+ return new Promise((resolve) => {
531
+ readyResolvers.push(resolve);
532
+ });
533
+ }
534
+ return void 0;
384
535
  },
385
536
  close() {
386
- controller?.close();
537
+ closeController();
387
538
  },
388
539
  abort(reason) {
389
- controller?.error(reason);
540
+ errorController(reason);
390
541
  }
391
542
  });
543
+ abortRender = started.abort;
544
+ started.shellReady.catch(() => void 0);
392
545
  started.allReady.catch(() => void 0);
546
+ },
547
+ pull() {
548
+ resolveBackpressure();
549
+ },
550
+ cancel(reason) {
551
+ const abort = abortRender;
552
+ controller = null;
553
+ drainBackpressure();
554
+ abort?.(reason ?? new Error("Stream canceled"));
393
555
  }
394
556
  });
395
557
  return stream;
@@ -398,7 +560,7 @@ function renderToPipeableStream(view, options = {}) {
398
560
  const bridge = createPipeBridge();
399
561
  const { shellReady, allReady, abort } = startStreamingRender(view, options, {
400
562
  write(chunk) {
401
- bridge.write(chunk);
563
+ return bridge.write(chunk);
402
564
  },
403
565
  close() {
404
566
  bridge.close();
@@ -424,7 +586,12 @@ function renderToPartial(view, options = {}) {
424
586
  };
425
587
  let shell = "";
426
588
  let shellPhase = true;
427
- const queued = createQueuedTextStream();
589
+ let abortPartial = null;
590
+ const queued = createQueuedTextStream({
591
+ onCancel(reason) {
592
+ abortPartial?.(reason ?? new Error("Stream canceled"));
593
+ }
594
+ });
428
595
  const { shellReady, allReady, abort } = startStreamingRender(
429
596
  view,
430
597
  partialOptions,
@@ -434,7 +601,7 @@ function renderToPartial(view, options = {}) {
434
601
  shell += chunk;
435
602
  return;
436
603
  }
437
- queued.writer.write(chunk);
604
+ return queued.writer.write(chunk);
438
605
  },
439
606
  close() {
440
607
  queued.writer.close();
@@ -450,6 +617,7 @@ function renderToPartial(view, options = {}) {
450
617
  }
451
618
  }
452
619
  );
620
+ abortPartial = abort;
453
621
  return {
454
622
  shell,
455
623
  stream: queued.stream,
@@ -479,17 +647,39 @@ function resolveDom(options) {
479
647
  }
480
648
  return createSSRDocument(options.html);
481
649
  }
650
+ function isPromiseLike(value) {
651
+ return typeof value === "object" && value !== null && typeof value.then === "function";
652
+ }
482
653
  function startStreamingRender(view, options, writer, control = {}) {
654
+ const session = (0, import_internal.__fictCreateSSRSession)();
655
+ return (0, import_internal.__fictRunWithSSRSession)(
656
+ session,
657
+ () => startStreamingRenderInSession(session, view, options, writer, control)
658
+ );
659
+ }
660
+ function startStreamingRenderInSession(session, view, options, writer, control = {}) {
661
+ const runInSession = (fn) => (0, import_internal.__fictRunWithSSRSession)(session, fn);
483
662
  const resolvedOptions = {
484
663
  ...options,
485
664
  // Streaming requires a real document; default to fullDocument when unspecified.
486
665
  fullDocument: options.fullDocument ?? true
487
666
  };
488
667
  let resolveShell;
668
+ let rejectShell;
489
669
  let resolveAll;
490
670
  let rejectAll;
491
- const shellReady = new Promise((res) => {
492
- resolveShell = res;
671
+ let shellSettled = false;
672
+ const shellReady = new Promise((res, rej) => {
673
+ resolveShell = () => {
674
+ if (shellSettled) return;
675
+ shellSettled = true;
676
+ res();
677
+ };
678
+ rejectShell = (err) => {
679
+ if (shellSettled) return;
680
+ shellSettled = true;
681
+ rej(err);
682
+ };
493
683
  });
494
684
  const allReady = new Promise((res, rej) => {
495
685
  resolveAll = res;
@@ -507,39 +697,123 @@ function startStreamingRender(view, options, writer, control = {}) {
507
697
  let tailHtml = "";
508
698
  let wroteShell = false;
509
699
  let shellCarriesTail = false;
700
+ let writeChain = null;
701
+ let writeFailed = false;
702
+ let removeAbortListener = () => {
703
+ };
510
704
  const mode = options.mode ?? "shell";
511
705
  const includeSnapshot = options.includeSnapshot !== false;
512
706
  const sentScopes = /* @__PURE__ */ new Set();
513
707
  const boundaryMap = /* @__PURE__ */ new Map();
514
708
  let boundaryId = 0;
515
709
  let pendingCount = 0;
516
- const writeSnapshotForScopes = (scopeIds) => {
517
- if (!includeSnapshot || scopeIds.length === 0) return;
518
- const registry = (0, import_internal.__fictGetScopeRegistry)();
519
- const pending = scopeIds.filter((id) => registry.has(id) && !sentScopes.has(id));
520
- if (pending.length === 0) return;
521
- const snapshot = (0, import_internal.__fictSerializeSSRStateForScopes)(pending);
522
- const ids = Object.keys(snapshot.scopes);
523
- if (ids.length === 0) return;
524
- const chunk = buildIncrementalSnapshotChunk(snapshot, resolvedOptions);
525
- if (chunk) {
526
- writer.write(chunk);
710
+ const handleWriteError = (error) => {
711
+ if (writeFailed) return;
712
+ writeFailed = true;
713
+ options.onError?.(error);
714
+ writer.abort(error);
715
+ cleanup();
716
+ rejectShell(error);
717
+ rejectAll(error);
718
+ };
719
+ const enqueueWrite = (chunk) => {
720
+ if (writeFailed) return;
721
+ const trackWrite = (promise) => {
722
+ const tracked = promise.then(
723
+ () => {
724
+ if (writeChain === tracked) {
725
+ writeChain = null;
726
+ }
727
+ },
728
+ (error) => {
729
+ if (writeChain === tracked) {
730
+ writeChain = null;
731
+ }
732
+ handleWriteError(error);
733
+ }
734
+ );
735
+ writeChain = tracked;
736
+ };
737
+ const writeAsync = () => Promise.resolve(writer.write(chunk)).then(() => void 0);
738
+ if (writeChain) {
739
+ trackWrite(writeChain.then(writeAsync));
740
+ return;
741
+ }
742
+ try {
743
+ const result = writer.write(chunk);
744
+ if (isPromiseLike(result)) {
745
+ trackWrite(
746
+ result.then(
747
+ () => void 0,
748
+ (error) => {
749
+ throw error;
750
+ }
751
+ )
752
+ );
753
+ }
754
+ } catch (error) {
755
+ handleWriteError(error);
527
756
  }
528
- for (const id of ids) {
529
- sentScopes.add(id);
757
+ };
758
+ const afterWrites = (fn) => {
759
+ const pending = writeChain;
760
+ if (!pending) {
761
+ if (!writeFailed) {
762
+ fn();
763
+ }
764
+ return;
530
765
  }
766
+ void pending.then(() => {
767
+ if (!writeFailed) {
768
+ fn();
769
+ }
770
+ });
771
+ };
772
+ const markShellReady = () => {
773
+ afterWrites(() => {
774
+ control.onShellFlushed?.();
775
+ resolveShell();
776
+ options.onShellReady?.();
777
+ });
778
+ };
779
+ const writeSnapshotForScopes = (scopeIds) => {
780
+ runInSession(() => {
781
+ if (!includeSnapshot || scopeIds.length === 0) return;
782
+ const registry = (0, import_internal.__fictGetScopeRegistry)();
783
+ const pending = scopeIds.filter((id) => registry.has(id) && !sentScopes.has(id));
784
+ if (pending.length === 0) return;
785
+ const snapshot = (0, import_internal.__fictSerializeSSRStateForScopes)(pending);
786
+ const ids = Object.keys(snapshot.scopes);
787
+ if (ids.length === 0) return;
788
+ const chunk = buildIncrementalSnapshotChunk(snapshot, resolvedOptions);
789
+ if (chunk) {
790
+ enqueueWrite(chunk);
791
+ }
792
+ for (const id of ids) {
793
+ sentScopes.add(id);
794
+ }
795
+ });
531
796
  };
532
797
  const writeSnapshotForBoundary = (boundary) => {
533
- const scopes = (0, import_internal.__fictGetScopesForBoundary)(boundary);
534
- writeSnapshotForScopes(scopes);
798
+ runInSession(() => {
799
+ const scopes = (0, import_internal.__fictGetScopesForBoundary)(boundary);
800
+ writeSnapshotForScopes(scopes);
801
+ });
535
802
  };
536
803
  const writeRemainingSnapshots = () => {
537
- const scopes = Array.from((0, import_internal.__fictGetScopeRegistry)().keys());
538
- writeSnapshotForScopes(scopes);
804
+ runInSession(() => {
805
+ const scopes = Array.from((0, import_internal.__fictGetScopeRegistry)().keys());
806
+ writeSnapshotForScopes(scopes);
807
+ });
539
808
  };
540
809
  const cleanup = () => {
541
- (0, import_internal.__fictSetSSRStreamHooks)(null);
542
- (0, import_internal.__fictDisableSSR)();
810
+ removeAbortListener();
811
+ removeAbortListener = () => {
812
+ };
813
+ runInSession(() => {
814
+ (0, import_internal.__fictSetSSRStreamHooks)(null);
815
+ (0, import_internal.__fictDisableSSR)();
816
+ });
543
817
  restoreGlobals2();
544
818
  restoreManifest();
545
819
  try {
@@ -556,23 +830,27 @@ function startStreamingRender(view, options, writer, control = {}) {
556
830
  injectSnapshot(dom.document, container, snapshot, resolvedOptions);
557
831
  }
558
832
  const fullHtml = serializeOutput(dom.document, container, resolvedOptions);
559
- writer.write(fullHtml);
560
- writer.close();
561
- cleanup();
562
- resolveShell();
563
- resolveAll();
564
- options.onShellReady?.();
565
- options.onAllReady?.();
833
+ enqueueWrite(fullHtml);
834
+ afterWrites(() => {
835
+ writer.close();
836
+ cleanup();
837
+ resolveShell();
838
+ resolveAll();
839
+ options.onShellReady?.();
840
+ options.onAllReady?.();
841
+ });
566
842
  return;
567
843
  }
568
844
  writeRemainingSnapshots();
569
845
  if (tailHtml) {
570
- writer.write(tailHtml);
846
+ enqueueWrite(tailHtml);
571
847
  }
572
- writer.close();
573
- cleanup();
574
- resolveAll();
575
- options.onAllReady?.();
848
+ afterWrites(() => {
849
+ writer.close();
850
+ cleanup();
851
+ resolveAll();
852
+ options.onAllReady?.();
853
+ });
576
854
  };
577
855
  const maybeFinalize = () => {
578
856
  if (pendingCount === 0) {
@@ -602,7 +880,7 @@ function startStreamingRender(view, options, writer, control = {}) {
602
880
  writeSnapshotForBoundary(id);
603
881
  if (dom) {
604
882
  const html = serializeBetween(dom.document, entry.start, entry.end);
605
- writer.write(buildPatchChunk(id, html));
883
+ enqueueWrite(buildPatchChunk(id, html, resolvedOptions));
606
884
  }
607
885
  }
608
886
  maybeFinalize();
@@ -615,15 +893,21 @@ function startStreamingRender(view, options, writer, control = {}) {
615
893
  const abort = (reason) => {
616
894
  if (closed) return;
617
895
  closed = true;
896
+ writeFailed = true;
618
897
  writer.abort(reason);
619
898
  cleanup();
620
- rejectAll(reason ?? new Error("Stream aborted"));
899
+ const abortReason = reason ?? new Error("Stream aborted");
900
+ rejectShell(abortReason);
901
+ rejectAll(abortReason);
621
902
  };
622
903
  if (options.signal) {
623
904
  if (options.signal.aborted) {
624
905
  abort(options.signal.reason);
906
+ return { shellReady, allReady, abort };
625
907
  } else {
626
- options.signal.addEventListener("abort", () => abort(options.signal?.reason), { once: true });
908
+ const onAbort = () => abort(options.signal?.reason);
909
+ options.signal.addEventListener("abort", onAbort, { once: true });
910
+ removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
627
911
  }
628
912
  }
629
913
  try {
@@ -642,33 +926,31 @@ function startStreamingRender(view, options, writer, control = {}) {
642
926
  return { shellReady, allReady, abort };
643
927
  }
644
928
  const shellHtml = serializeOutput(dom.document, container, resolvedOptions);
645
- const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript() : "";
929
+ const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript(resolvedOptions) : "";
646
930
  if (resolvedOptions.fullDocument) {
647
931
  const split = splitDocumentHtml(shellHtml);
648
932
  if (!split) {
649
933
  throw new Error("[fict/ssr] Failed to locate </body> for streaming output.");
650
934
  }
651
935
  if (control.includeTailInShell) {
652
- writer.write(split.head + streamRuntime);
936
+ enqueueWrite(split.head + streamRuntime);
653
937
  tailHtml = split.tail;
654
938
  shellCarriesTail = true;
655
939
  } else {
656
- writer.write(split.head + streamRuntime);
940
+ enqueueWrite(split.head + streamRuntime);
657
941
  tailHtml = split.tail;
658
942
  }
659
943
  } else {
660
- writer.write(shellHtml + streamRuntime);
944
+ enqueueWrite(shellHtml + streamRuntime);
661
945
  }
662
946
  wroteShell = true;
663
947
  writeSnapshotForScopes(Array.from((0, import_internal.__fictGetScopeRegistry)().keys()));
664
948
  if (shellCarriesTail && tailHtml) {
665
- writer.write(tailHtml);
949
+ enqueueWrite(tailHtml);
666
950
  tailHtml = "";
667
951
  shellCarriesTail = false;
668
952
  }
669
- control.onShellFlushed?.();
670
- resolveShell();
671
- options.onShellReady?.();
953
+ markShellReady();
672
954
  maybeFinalize();
673
955
  } catch (err) {
674
956
  options.onError?.(err);
@@ -699,11 +981,28 @@ function resolveContainer(document, options) {
699
981
  }
700
982
  return container;
701
983
  }
702
- function buildStreamRuntimeScript() {
703
- 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>`;
984
+ function buildStreamRuntimeScript(options) {
985
+ const nonce = renderNonceAttribute(options);
986
+ if (options.streamRuntime === "external") {
987
+ if (!options.streamRuntimeSrc) {
988
+ throw new Error('[fict/ssr] streamRuntimeSrc is required when streamRuntime is "external".');
989
+ }
990
+ return `<script${nonce} src="${escapeAttribute(options.streamRuntimeSrc)}" data-fict-stream-runtime data-fict-stream-observer></script>`;
991
+ }
992
+ return `<script${nonce}>${createStreamRuntimeCode({
993
+ observerMode: resolveStreamPatchMode(options) === "observer"
994
+ })}</script>`;
995
+ }
996
+ function buildPatchChunk(id, html, options) {
997
+ const template = `<template data-fict-suspense="${escapeAttribute(id)}">${html}</template>`;
998
+ if (resolveStreamPatchMode(options) === "observer") {
999
+ return template;
1000
+ }
1001
+ return `${template}<script${renderNonceAttribute(options)}>__FICT_STREAM.apply("${escapeScriptString(id)}")</script>`;
704
1002
  }
705
- function buildPatchChunk(id, html) {
706
- return `<template data-fict-suspense="${id}">` + html + `</template><script>__FICT_STREAM.apply("${id}")</script>`;
1003
+ function resolveStreamPatchMode(options) {
1004
+ if (options.streamPatchMode) return options.streamPatchMode;
1005
+ return options.streamRuntime === "external" ? "observer" : "inline";
707
1006
  }
708
1007
  function serializeBetween(document, start, end) {
709
1008
  const wrapper = document.createElement("div");
@@ -722,11 +1021,13 @@ function splitDocumentHtml(html) {
722
1021
  }
723
1022
  function buildIncrementalSnapshotChunk(state, options) {
724
1023
  const json = serializeSnapshotForScript(state);
1024
+ const nonce = renderNonceAttribute(options);
725
1025
  if (options.snapshotTarget === "head") {
726
1026
  const jsonLiteral = JSON.stringify(json);
727
- 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>`;
1027
+ const setNonce = options.scriptNonce !== void 0 ? `s.setAttribute('nonce',${serializeScriptStringLiteral(options.scriptNonce)});` : "";
1028
+ 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>`;
728
1029
  }
729
- return `<script type="application/json" data-fict-snapshot>${json}</script>`;
1030
+ return `<script${nonce} type="application/json" data-fict-snapshot>${json}</script>`;
730
1031
  }
731
1032
  function serializeOutput(document, container, options) {
732
1033
  if (options.fullDocument) {
@@ -743,6 +1044,9 @@ function injectSnapshot(document, container, state, options) {
743
1044
  const script = document.createElement("script");
744
1045
  script.type = "application/json";
745
1046
  script.id = options.snapshotScriptId ?? "__FICT_SNAPSHOT__";
1047
+ if (options.scriptNonce !== void 0) {
1048
+ script.setAttribute("nonce", options.scriptNonce);
1049
+ }
746
1050
  script.textContent = serializeSnapshotForScript(state);
747
1051
  if (options.fullDocument) {
748
1052
  if (options.snapshotTarget === "head" && document.head) {
@@ -768,6 +1072,18 @@ function injectSnapshot(document, container, state, options) {
768
1072
  function serializeSnapshotForScript(state) {
769
1073
  return JSON.stringify(state).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
770
1074
  }
1075
+ function serializeScriptStringLiteral(value) {
1076
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1077
+ }
1078
+ function renderNonceAttribute(options) {
1079
+ return options.scriptNonce === void 0 ? "" : ` nonce="${escapeAttribute(options.scriptNonce)}"`;
1080
+ }
1081
+ function escapeAttribute(value) {
1082
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1083
+ }
1084
+ function escapeScriptString(value) {
1085
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1086
+ }
771
1087
  function serializeDoctype(document, override) {
772
1088
  if (override === null) return "";
773
1089
  if (override !== void 0) return override;