@fictjs/ssr 0.19.0 → 0.21.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
@@ -12,6 +18,7 @@ import {
12
18
  import { parseHTML } from "linkedom";
13
19
 
14
20
  // src/globals.ts
21
+ import { __fictGetCurrentSSRSession } from "@fictjs/runtime/internal";
15
22
  function installGlobals(window, document) {
16
23
  const win = window;
17
24
  const required = {
@@ -61,6 +68,14 @@ function installManifest(manifest) {
61
68
  } else {
62
69
  resolved = manifest;
63
70
  }
71
+ const session = __fictGetCurrentSSRSession();
72
+ if (session) {
73
+ const previous = session.manifest;
74
+ session.manifest = resolved;
75
+ return () => {
76
+ session.manifest = previous;
77
+ };
78
+ }
64
79
  const key = "__FICT_MANIFEST__";
65
80
  const snapshot = {
66
81
  exists: Object.prototype.hasOwnProperty.call(globalThis, key),
@@ -124,12 +139,33 @@ function getNodeRequire() {
124
139
  }
125
140
 
126
141
  // src/stream-bridge.ts
127
- function createQueuedTextStream() {
142
+ function createQueuedTextStream(options = {}) {
128
143
  const encoder = new TextEncoder();
129
144
  const queue = [];
130
145
  let controller = null;
131
146
  let closed = false;
132
147
  let aborted;
148
+ const readyResolvers = [];
149
+ const resolveReady = () => {
150
+ if (!controller || (controller.desiredSize ?? 1) <= 0) return;
151
+ while (readyResolvers.length > 0) {
152
+ readyResolvers.shift()?.();
153
+ }
154
+ };
155
+ const drainReady = () => {
156
+ while (readyResolvers.length > 0) {
157
+ readyResolvers.shift()?.();
158
+ }
159
+ };
160
+ const abortQueue = (reason, notifyController = true) => {
161
+ if (closed || aborted !== void 0) return;
162
+ aborted = reason ?? new Error("Stream aborted");
163
+ queue.length = 0;
164
+ drainReady();
165
+ if (notifyController) {
166
+ controller?.error(aborted);
167
+ }
168
+ };
133
169
  const stream = new ReadableStream({
134
170
  start(ctrl) {
135
171
  controller = ctrl;
@@ -144,6 +180,13 @@ function createQueuedTextStream() {
144
180
  if (closed) {
145
181
  ctrl.close();
146
182
  }
183
+ },
184
+ pull() {
185
+ resolveReady();
186
+ },
187
+ cancel(reason) {
188
+ abortQueue(reason, false);
189
+ options.onCancel?.(reason);
147
190
  }
148
191
  });
149
192
  const writer = {
@@ -152,19 +195,24 @@ function createQueuedTextStream() {
152
195
  const data = encoder.encode(chunk);
153
196
  if (controller) {
154
197
  controller.enqueue(data);
198
+ if ((controller.desiredSize ?? 1) <= 0) {
199
+ return new Promise((resolve) => {
200
+ readyResolvers.push(resolve);
201
+ });
202
+ }
155
203
  } else {
156
204
  queue.push(data);
157
205
  }
206
+ return void 0;
158
207
  },
159
208
  close() {
160
209
  if (closed || aborted !== void 0) return;
161
210
  closed = true;
211
+ drainReady();
162
212
  controller?.close();
163
213
  },
164
214
  abort(reason) {
165
- if (closed || aborted !== void 0) return;
166
- aborted = reason ?? new Error("Stream aborted");
167
- controller?.error(aborted);
215
+ abortQueue(reason);
168
216
  }
169
217
  };
170
218
  return { stream, writer };
@@ -178,9 +226,20 @@ function createPipeBridge() {
178
226
  let abortReason = null;
179
227
  const safeWrite = (target, chunk) => {
180
228
  try {
181
- target.write(chunk);
229
+ const ready = target.write(chunk);
230
+ if (ready === false) {
231
+ return new Promise((resolve) => {
232
+ const withOnce = target;
233
+ if (typeof withOnce.once === "function") {
234
+ withOnce.once("drain", resolve);
235
+ } else {
236
+ resolve();
237
+ }
238
+ });
239
+ }
182
240
  } catch {
183
241
  }
242
+ return void 0;
184
243
  };
185
244
  const safeEnd = (target) => {
186
245
  try {
@@ -220,9 +279,12 @@ function createPipeBridge() {
220
279
  buffer.push(chunk);
221
280
  return;
222
281
  }
282
+ const pending = [];
223
283
  for (const target of targets) {
224
- safeWrite(target, chunk);
284
+ const result = safeWrite(target, chunk);
285
+ if (result) pending.push(result);
225
286
  }
287
+ return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
226
288
  },
227
289
  close() {
228
290
  if (state !== "open") return;
@@ -252,22 +314,78 @@ function createNodePipeBridge() {
252
314
  const streamModule = nodeRequire("node:stream");
253
315
  if (!streamModule.PassThrough) return null;
254
316
  const passThrough = new streamModule.PassThrough();
317
+ const buffer = [];
318
+ let piped = false;
319
+ let state = "open";
320
+ let abortReason = null;
321
+ const writeToPassThrough = (chunk) => {
322
+ if (passThrough.write(chunk) === false) {
323
+ return new Promise((resolve) => {
324
+ const withOnce = passThrough;
325
+ if (typeof withOnce.once === "function") {
326
+ withOnce.once("drain", resolve);
327
+ } else {
328
+ resolve();
329
+ }
330
+ });
331
+ }
332
+ return void 0;
333
+ };
334
+ const flushBuffer = () => {
335
+ if (buffer.length === 0) return void 0;
336
+ const pending = [];
337
+ for (const chunk of buffer) {
338
+ const result = writeToPassThrough(chunk);
339
+ if (result) pending.push(result);
340
+ }
341
+ buffer.length = 0;
342
+ return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
343
+ };
344
+ const destroyPassThrough = (error) => {
345
+ if (typeof passThrough.destroy === "function") {
346
+ passThrough.destroy(error);
347
+ } else {
348
+ passThrough.end();
349
+ }
350
+ };
255
351
  return {
256
352
  pipe(writable) {
353
+ piped = true;
257
354
  passThrough.pipe(writable);
355
+ if (state === "aborted") {
356
+ destroyPassThrough(abortReason ?? new Error("Stream aborted"));
357
+ return;
358
+ }
359
+ const flushed = flushBuffer();
360
+ if (state === "closed") {
361
+ if (flushed) {
362
+ void flushed.then(() => passThrough.end());
363
+ } else {
364
+ passThrough.end();
365
+ }
366
+ }
258
367
  },
259
368
  write(chunk) {
260
- passThrough.write(chunk);
369
+ if (state !== "open") return;
370
+ if (!piped) {
371
+ buffer.push(chunk);
372
+ return void 0;
373
+ }
374
+ return writeToPassThrough(chunk);
261
375
  },
262
376
  close() {
377
+ if (state !== "open") return;
378
+ state = "closed";
379
+ if (!piped) return;
263
380
  passThrough.end();
264
381
  },
265
382
  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();
383
+ if (state !== "open") return;
384
+ state = "aborted";
385
+ abortReason = reason instanceof Error ? reason : new Error("Stream aborted");
386
+ buffer.length = 0;
387
+ if (piped) {
388
+ destroyPassThrough(abortReason);
271
389
  }
272
390
  }
273
391
  };
@@ -299,6 +417,10 @@ function createSSRDocument(html = DEFAULT_HTML) {
299
417
  return { window, document };
300
418
  }
301
419
  function renderToDocument(view, options = {}) {
420
+ const session = __fictCreateSSRSession();
421
+ return __fictRunWithSSRSession(session, () => renderToDocumentInSession(view, options));
422
+ }
423
+ function renderToDocumentInSession(view, options) {
302
424
  const includeSnapshot = options.includeSnapshot !== false;
303
425
  __fictEnableSSR();
304
426
  let dom;
@@ -312,7 +434,7 @@ function renderToDocument(view, options = {}) {
312
434
  try {
313
435
  dom = resolveDom(options);
314
436
  const { document, window } = dom;
315
- const shouldExpose = options.exposeGlobals !== false;
437
+ const shouldExpose = options.exposeGlobals === true;
316
438
  restoreGlobals2 = shouldExpose ? installGlobals(window, document) : () => {
317
439
  };
318
440
  restoreManifest = installManifest(options.manifest);
@@ -352,6 +474,37 @@ async function renderToStringAsync(view, options = {}) {
352
474
  function renderToStream(view, options = {}) {
353
475
  const encoder = new TextEncoder();
354
476
  let controller = null;
477
+ let abortRender = null;
478
+ const readyResolvers = [];
479
+ const drainBackpressure = () => {
480
+ while (readyResolvers.length > 0) {
481
+ readyResolvers.shift()?.();
482
+ }
483
+ };
484
+ const resolveBackpressure = () => {
485
+ if (!controller || (controller.desiredSize ?? 1) <= 0) return;
486
+ drainBackpressure();
487
+ };
488
+ const closeController = () => {
489
+ abortRender = null;
490
+ drainBackpressure();
491
+ if (!controller) return;
492
+ try {
493
+ controller.close();
494
+ } finally {
495
+ controller = null;
496
+ }
497
+ };
498
+ const errorController = (reason) => {
499
+ abortRender = null;
500
+ drainBackpressure();
501
+ if (!controller) return;
502
+ try {
503
+ controller.error(reason);
504
+ } finally {
505
+ controller = null;
506
+ }
507
+ };
355
508
  const stream = new ReadableStream({
356
509
  start(ctrl) {
357
510
  controller = ctrl;
@@ -359,15 +512,32 @@ function renderToStream(view, options = {}) {
359
512
  write(chunk) {
360
513
  if (!controller) return;
361
514
  controller.enqueue(encoder.encode(chunk));
515
+ if ((controller.desiredSize ?? 1) <= 0) {
516
+ return new Promise((resolve) => {
517
+ readyResolvers.push(resolve);
518
+ });
519
+ }
520
+ return void 0;
362
521
  },
363
522
  close() {
364
- controller?.close();
523
+ closeController();
365
524
  },
366
525
  abort(reason) {
367
- controller?.error(reason);
526
+ errorController(reason);
368
527
  }
369
528
  });
529
+ abortRender = started.abort;
530
+ started.shellReady.catch(() => void 0);
370
531
  started.allReady.catch(() => void 0);
532
+ },
533
+ pull() {
534
+ resolveBackpressure();
535
+ },
536
+ cancel(reason) {
537
+ const abort = abortRender;
538
+ controller = null;
539
+ drainBackpressure();
540
+ abort?.(reason ?? new Error("Stream canceled"));
371
541
  }
372
542
  });
373
543
  return stream;
@@ -376,7 +546,7 @@ function renderToPipeableStream(view, options = {}) {
376
546
  const bridge = createPipeBridge();
377
547
  const { shellReady, allReady, abort } = startStreamingRender(view, options, {
378
548
  write(chunk) {
379
- bridge.write(chunk);
549
+ return bridge.write(chunk);
380
550
  },
381
551
  close() {
382
552
  bridge.close();
@@ -402,7 +572,12 @@ function renderToPartial(view, options = {}) {
402
572
  };
403
573
  let shell = "";
404
574
  let shellPhase = true;
405
- const queued = createQueuedTextStream();
575
+ let abortPartial = null;
576
+ const queued = createQueuedTextStream({
577
+ onCancel(reason) {
578
+ abortPartial?.(reason ?? new Error("Stream canceled"));
579
+ }
580
+ });
406
581
  const { shellReady, allReady, abort } = startStreamingRender(
407
582
  view,
408
583
  partialOptions,
@@ -412,7 +587,7 @@ function renderToPartial(view, options = {}) {
412
587
  shell += chunk;
413
588
  return;
414
589
  }
415
- queued.writer.write(chunk);
590
+ return queued.writer.write(chunk);
416
591
  },
417
592
  close() {
418
593
  queued.writer.close();
@@ -428,6 +603,7 @@ function renderToPartial(view, options = {}) {
428
603
  }
429
604
  }
430
605
  );
606
+ abortPartial = abort;
431
607
  return {
432
608
  shell,
433
609
  stream: queued.stream,
@@ -457,17 +633,39 @@ function resolveDom(options) {
457
633
  }
458
634
  return createSSRDocument(options.html);
459
635
  }
636
+ function isPromiseLike(value) {
637
+ return typeof value === "object" && value !== null && typeof value.then === "function";
638
+ }
460
639
  function startStreamingRender(view, options, writer, control = {}) {
640
+ const session = __fictCreateSSRSession();
641
+ return __fictRunWithSSRSession(
642
+ session,
643
+ () => startStreamingRenderInSession(session, view, options, writer, control)
644
+ );
645
+ }
646
+ function startStreamingRenderInSession(session, view, options, writer, control = {}) {
647
+ const runInSession = (fn) => __fictRunWithSSRSession(session, fn);
461
648
  const resolvedOptions = {
462
649
  ...options,
463
650
  // Streaming requires a real document; default to fullDocument when unspecified.
464
651
  fullDocument: options.fullDocument ?? true
465
652
  };
466
653
  let resolveShell;
654
+ let rejectShell;
467
655
  let resolveAll;
468
656
  let rejectAll;
469
- const shellReady = new Promise((res) => {
470
- resolveShell = res;
657
+ let shellSettled = false;
658
+ const shellReady = new Promise((res, rej) => {
659
+ resolveShell = () => {
660
+ if (shellSettled) return;
661
+ shellSettled = true;
662
+ res();
663
+ };
664
+ rejectShell = (err) => {
665
+ if (shellSettled) return;
666
+ shellSettled = true;
667
+ rej(err);
668
+ };
471
669
  });
472
670
  const allReady = new Promise((res, rej) => {
473
671
  resolveAll = res;
@@ -485,44 +683,129 @@ function startStreamingRender(view, options, writer, control = {}) {
485
683
  let tailHtml = "";
486
684
  let wroteShell = false;
487
685
  let shellCarriesTail = false;
686
+ let writeChain = null;
687
+ let writeFailed = false;
688
+ let removeAbortListener = () => {
689
+ };
488
690
  const mode = options.mode ?? "shell";
489
691
  const includeSnapshot = options.includeSnapshot !== false;
490
692
  const sentScopes = /* @__PURE__ */ new Set();
491
693
  const boundaryMap = /* @__PURE__ */ new Map();
492
694
  let boundaryId = 0;
493
695
  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);
696
+ const handleWriteError = (error) => {
697
+ if (writeFailed) return;
698
+ writeFailed = true;
699
+ options.onError?.(error);
700
+ writer.abort(error);
701
+ cleanup();
702
+ rejectShell(error);
703
+ rejectAll(error);
704
+ };
705
+ const enqueueWrite = (chunk) => {
706
+ if (writeFailed) return;
707
+ const trackWrite = (promise) => {
708
+ const tracked = promise.then(
709
+ () => {
710
+ if (writeChain === tracked) {
711
+ writeChain = null;
712
+ }
713
+ },
714
+ (error) => {
715
+ if (writeChain === tracked) {
716
+ writeChain = null;
717
+ }
718
+ handleWriteError(error);
719
+ }
720
+ );
721
+ writeChain = tracked;
722
+ };
723
+ const writeAsync = () => Promise.resolve(writer.write(chunk)).then(() => void 0);
724
+ if (writeChain) {
725
+ trackWrite(writeChain.then(writeAsync));
726
+ return;
727
+ }
728
+ try {
729
+ const result = writer.write(chunk);
730
+ if (isPromiseLike(result)) {
731
+ trackWrite(
732
+ result.then(
733
+ () => void 0,
734
+ (error) => {
735
+ throw error;
736
+ }
737
+ )
738
+ );
739
+ }
740
+ } catch (error) {
741
+ handleWriteError(error);
505
742
  }
506
- for (const id of ids) {
507
- sentScopes.add(id);
743
+ };
744
+ const afterWrites = (fn) => {
745
+ const pending = writeChain;
746
+ if (!pending) {
747
+ if (!writeFailed) {
748
+ fn();
749
+ }
750
+ return;
508
751
  }
752
+ void pending.then(() => {
753
+ if (!writeFailed) {
754
+ fn();
755
+ }
756
+ });
757
+ };
758
+ const markShellReady = () => {
759
+ afterWrites(() => {
760
+ control.onShellFlushed?.();
761
+ resolveShell();
762
+ options.onShellReady?.();
763
+ });
764
+ };
765
+ const writeSnapshotForScopes = (scopeIds) => {
766
+ runInSession(() => {
767
+ if (!includeSnapshot || scopeIds.length === 0) return;
768
+ const registry = __fictGetScopeRegistry();
769
+ const pending = scopeIds.filter((id) => registry.has(id) && !sentScopes.has(id));
770
+ if (pending.length === 0) return;
771
+ const snapshot = __fictSerializeSSRStateForScopes(pending);
772
+ const ids = Object.keys(snapshot.scopes);
773
+ if (ids.length === 0) return;
774
+ const chunk = buildIncrementalSnapshotChunk(snapshot, resolvedOptions);
775
+ if (chunk) {
776
+ enqueueWrite(chunk);
777
+ }
778
+ for (const id of ids) {
779
+ sentScopes.add(id);
780
+ }
781
+ });
509
782
  };
510
783
  const writeSnapshotForBoundary = (boundary) => {
511
- const scopes = __fictGetScopesForBoundary(boundary);
512
- writeSnapshotForScopes(scopes);
784
+ runInSession(() => {
785
+ const scopes = __fictGetScopesForBoundary(boundary);
786
+ writeSnapshotForScopes(scopes);
787
+ });
513
788
  };
514
789
  const writeRemainingSnapshots = () => {
515
- const scopes = Array.from(__fictGetScopeRegistry().keys());
516
- writeSnapshotForScopes(scopes);
790
+ runInSession(() => {
791
+ const scopes = Array.from(__fictGetScopeRegistry().keys());
792
+ writeSnapshotForScopes(scopes);
793
+ });
517
794
  };
518
795
  const cleanup = () => {
519
- __fictSetSSRStreamHooks(null);
520
- __fictDisableSSR();
521
- restoreGlobals2();
522
- restoreManifest();
796
+ removeAbortListener();
797
+ removeAbortListener = () => {
798
+ };
799
+ runInSession(() => {
800
+ __fictSetSSRStreamHooks(null);
801
+ __fictDisableSSR();
802
+ });
523
803
  try {
524
804
  teardown();
525
805
  } catch {
806
+ } finally {
807
+ restoreGlobals2();
808
+ restoreManifest();
526
809
  }
527
810
  };
528
811
  const finalize = () => {
@@ -534,23 +817,27 @@ function startStreamingRender(view, options, writer, control = {}) {
534
817
  injectSnapshot(dom.document, container, snapshot, resolvedOptions);
535
818
  }
536
819
  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?.();
820
+ enqueueWrite(fullHtml);
821
+ afterWrites(() => {
822
+ writer.close();
823
+ cleanup();
824
+ resolveShell();
825
+ resolveAll();
826
+ options.onShellReady?.();
827
+ options.onAllReady?.();
828
+ });
544
829
  return;
545
830
  }
546
831
  writeRemainingSnapshots();
547
832
  if (tailHtml) {
548
- writer.write(tailHtml);
833
+ enqueueWrite(tailHtml);
549
834
  }
550
- writer.close();
551
- cleanup();
552
- resolveAll();
553
- options.onAllReady?.();
835
+ afterWrites(() => {
836
+ writer.close();
837
+ cleanup();
838
+ resolveAll();
839
+ options.onAllReady?.();
840
+ });
554
841
  };
555
842
  const maybeFinalize = () => {
556
843
  if (pendingCount === 0) {
@@ -580,7 +867,7 @@ function startStreamingRender(view, options, writer, control = {}) {
580
867
  writeSnapshotForBoundary(id);
581
868
  if (dom) {
582
869
  const html = serializeBetween(dom.document, entry.start, entry.end);
583
- writer.write(buildPatchChunk(id, html));
870
+ enqueueWrite(buildPatchChunk(id, html, resolvedOptions));
584
871
  }
585
872
  }
586
873
  maybeFinalize();
@@ -593,22 +880,28 @@ function startStreamingRender(view, options, writer, control = {}) {
593
880
  const abort = (reason) => {
594
881
  if (closed) return;
595
882
  closed = true;
883
+ writeFailed = true;
596
884
  writer.abort(reason);
597
885
  cleanup();
598
- rejectAll(reason ?? new Error("Stream aborted"));
886
+ const abortReason = reason ?? new Error("Stream aborted");
887
+ rejectShell(abortReason);
888
+ rejectAll(abortReason);
599
889
  };
600
890
  if (options.signal) {
601
891
  if (options.signal.aborted) {
602
892
  abort(options.signal.reason);
893
+ return { shellReady, allReady, abort };
603
894
  } else {
604
- options.signal.addEventListener("abort", () => abort(options.signal?.reason), { once: true });
895
+ const onAbort = () => abort(options.signal?.reason);
896
+ options.signal.addEventListener("abort", onAbort, { once: true });
897
+ removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
605
898
  }
606
899
  }
607
900
  try {
608
901
  __fictEnableSSR();
609
902
  __fictSetSSRStreamHooks(hooks);
610
903
  dom = resolveDom(resolvedOptions);
611
- restoreGlobals2 = resolvedOptions.exposeGlobals !== false ? installGlobals(dom.window, dom.document) : () => {
904
+ restoreGlobals2 = resolvedOptions.exposeGlobals === true ? installGlobals(dom.window, dom.document) : () => {
612
905
  };
613
906
  restoreManifest = installManifest(resolvedOptions.manifest);
614
907
  container = resolveContainer(dom.document, resolvedOptions);
@@ -620,33 +913,31 @@ function startStreamingRender(view, options, writer, control = {}) {
620
913
  return { shellReady, allReady, abort };
621
914
  }
622
915
  const shellHtml = serializeOutput(dom.document, container, resolvedOptions);
623
- const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript() : "";
916
+ const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript(resolvedOptions) : "";
624
917
  if (resolvedOptions.fullDocument) {
625
918
  const split = splitDocumentHtml(shellHtml);
626
919
  if (!split) {
627
920
  throw new Error("[fict/ssr] Failed to locate </body> for streaming output.");
628
921
  }
629
922
  if (control.includeTailInShell) {
630
- writer.write(split.head + streamRuntime);
923
+ enqueueWrite(split.head + streamRuntime);
631
924
  tailHtml = split.tail;
632
925
  shellCarriesTail = true;
633
926
  } else {
634
- writer.write(split.head + streamRuntime);
927
+ enqueueWrite(split.head + streamRuntime);
635
928
  tailHtml = split.tail;
636
929
  }
637
930
  } else {
638
- writer.write(shellHtml + streamRuntime);
931
+ enqueueWrite(shellHtml + streamRuntime);
639
932
  }
640
933
  wroteShell = true;
641
934
  writeSnapshotForScopes(Array.from(__fictGetScopeRegistry().keys()));
642
935
  if (shellCarriesTail && tailHtml) {
643
- writer.write(tailHtml);
936
+ enqueueWrite(tailHtml);
644
937
  tailHtml = "";
645
938
  shellCarriesTail = false;
646
939
  }
647
- control.onShellFlushed?.();
648
- resolveShell();
649
- options.onShellReady?.();
940
+ markShellReady();
650
941
  maybeFinalize();
651
942
  } catch (err) {
652
943
  options.onError?.(err);
@@ -677,11 +968,28 @@ function resolveContainer(document, options) {
677
968
  }
678
969
  return container;
679
970
  }
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>`;
971
+ function buildStreamRuntimeScript(options) {
972
+ const nonce = renderNonceAttribute(options);
973
+ if (options.streamRuntime === "external") {
974
+ if (!options.streamRuntimeSrc) {
975
+ throw new Error('[fict/ssr] streamRuntimeSrc is required when streamRuntime is "external".');
976
+ }
977
+ return `<script${nonce} src="${escapeAttribute(options.streamRuntimeSrc)}" data-fict-stream-runtime data-fict-stream-observer></script>`;
978
+ }
979
+ return `<script${nonce}>${createStreamRuntimeCode({
980
+ observerMode: resolveStreamPatchMode(options) === "observer"
981
+ })}</script>`;
682
982
  }
683
- function buildPatchChunk(id, html) {
684
- return `<template data-fict-suspense="${id}">` + html + `</template><script>__FICT_STREAM.apply("${id}")</script>`;
983
+ function buildPatchChunk(id, html, options) {
984
+ const template = `<template data-fict-suspense="${escapeAttribute(id)}">${html}</template>`;
985
+ if (resolveStreamPatchMode(options) === "observer") {
986
+ return template;
987
+ }
988
+ return `${template}<script${renderNonceAttribute(options)}>__FICT_STREAM.apply("${escapeScriptString(id)}")</script>`;
989
+ }
990
+ function resolveStreamPatchMode(options) {
991
+ if (options.streamPatchMode) return options.streamPatchMode;
992
+ return options.streamRuntime === "external" ? "observer" : "inline";
685
993
  }
686
994
  function serializeBetween(document, start, end) {
687
995
  const wrapper = document.createElement("div");
@@ -700,11 +1008,13 @@ function splitDocumentHtml(html) {
700
1008
  }
701
1009
  function buildIncrementalSnapshotChunk(state, options) {
702
1010
  const json = serializeSnapshotForScript(state);
1011
+ const nonce = renderNonceAttribute(options);
703
1012
  if (options.snapshotTarget === "head") {
704
1013
  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>`;
1014
+ const setNonce = options.scriptNonce !== void 0 ? `s.setAttribute('nonce',${serializeScriptStringLiteral(options.scriptNonce)});` : "";
1015
+ 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
1016
  }
707
- return `<script type="application/json" data-fict-snapshot>${json}</script>`;
1017
+ return `<script${nonce} type="application/json" data-fict-snapshot>${json}</script>`;
708
1018
  }
709
1019
  function serializeOutput(document, container, options) {
710
1020
  if (options.fullDocument) {
@@ -721,6 +1031,9 @@ function injectSnapshot(document, container, state, options) {
721
1031
  const script = document.createElement("script");
722
1032
  script.type = "application/json";
723
1033
  script.id = options.snapshotScriptId ?? "__FICT_SNAPSHOT__";
1034
+ if (options.scriptNonce !== void 0) {
1035
+ script.setAttribute("nonce", options.scriptNonce);
1036
+ }
724
1037
  script.textContent = serializeSnapshotForScript(state);
725
1038
  if (options.fullDocument) {
726
1039
  if (options.snapshotTarget === "head" && document.head) {
@@ -746,6 +1059,18 @@ function injectSnapshot(document, container, state, options) {
746
1059
  function serializeSnapshotForScript(state) {
747
1060
  return JSON.stringify(state).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
748
1061
  }
1062
+ function serializeScriptStringLiteral(value) {
1063
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1064
+ }
1065
+ function renderNonceAttribute(options) {
1066
+ return options.scriptNonce === void 0 ? "" : ` nonce="${escapeAttribute(options.scriptNonce)}"`;
1067
+ }
1068
+ function escapeAttribute(value) {
1069
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1070
+ }
1071
+ function escapeScriptString(value) {
1072
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1073
+ }
749
1074
  function serializeDoctype(document, override) {
750
1075
  if (override === null) return "";
751
1076
  if (override !== void 0) return override;