@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.cjs CHANGED
@@ -30,10 +30,11 @@ __export(index_exports, {
30
30
  });
31
31
  module.exports = __toCommonJS(index_exports);
32
32
  var import_runtime = require("@fictjs/runtime");
33
- var import_internal = require("@fictjs/runtime/internal");
33
+ var import_internal2 = require("@fictjs/runtime/internal");
34
34
  var import_linkedom = require("linkedom");
35
35
 
36
36
  // src/globals.ts
37
+ var import_internal = require("@fictjs/runtime/internal");
37
38
  function installGlobals(window, document) {
38
39
  const win = window;
39
40
  const required = {
@@ -83,6 +84,14 @@ function installManifest(manifest) {
83
84
  } else {
84
85
  resolved = manifest;
85
86
  }
87
+ const session = (0, import_internal.__fictGetCurrentSSRSession)();
88
+ if (session) {
89
+ const previous = session.manifest;
90
+ session.manifest = resolved;
91
+ return () => {
92
+ session.manifest = previous;
93
+ };
94
+ }
86
95
  const key = "__FICT_MANIFEST__";
87
96
  const snapshot = {
88
97
  exists: Object.prototype.hasOwnProperty.call(globalThis, key),
@@ -146,12 +155,33 @@ function getNodeRequire() {
146
155
  }
147
156
 
148
157
  // src/stream-bridge.ts
149
- function createQueuedTextStream() {
158
+ function createQueuedTextStream(options = {}) {
150
159
  const encoder = new TextEncoder();
151
160
  const queue = [];
152
161
  let controller = null;
153
162
  let closed = false;
154
163
  let aborted;
164
+ const readyResolvers = [];
165
+ const resolveReady = () => {
166
+ if (!controller || (controller.desiredSize ?? 1) <= 0) return;
167
+ while (readyResolvers.length > 0) {
168
+ readyResolvers.shift()?.();
169
+ }
170
+ };
171
+ const drainReady = () => {
172
+ while (readyResolvers.length > 0) {
173
+ readyResolvers.shift()?.();
174
+ }
175
+ };
176
+ const abortQueue = (reason, notifyController = true) => {
177
+ if (closed || aborted !== void 0) return;
178
+ aborted = reason ?? new Error("Stream aborted");
179
+ queue.length = 0;
180
+ drainReady();
181
+ if (notifyController) {
182
+ controller?.error(aborted);
183
+ }
184
+ };
155
185
  const stream = new ReadableStream({
156
186
  start(ctrl) {
157
187
  controller = ctrl;
@@ -166,6 +196,13 @@ function createQueuedTextStream() {
166
196
  if (closed) {
167
197
  ctrl.close();
168
198
  }
199
+ },
200
+ pull() {
201
+ resolveReady();
202
+ },
203
+ cancel(reason) {
204
+ abortQueue(reason, false);
205
+ options.onCancel?.(reason);
169
206
  }
170
207
  });
171
208
  const writer = {
@@ -174,19 +211,24 @@ function createQueuedTextStream() {
174
211
  const data = encoder.encode(chunk);
175
212
  if (controller) {
176
213
  controller.enqueue(data);
214
+ if ((controller.desiredSize ?? 1) <= 0) {
215
+ return new Promise((resolve) => {
216
+ readyResolvers.push(resolve);
217
+ });
218
+ }
177
219
  } else {
178
220
  queue.push(data);
179
221
  }
222
+ return void 0;
180
223
  },
181
224
  close() {
182
225
  if (closed || aborted !== void 0) return;
183
226
  closed = true;
227
+ drainReady();
184
228
  controller?.close();
185
229
  },
186
230
  abort(reason) {
187
- if (closed || aborted !== void 0) return;
188
- aborted = reason ?? new Error("Stream aborted");
189
- controller?.error(aborted);
231
+ abortQueue(reason);
190
232
  }
191
233
  };
192
234
  return { stream, writer };
@@ -200,9 +242,20 @@ function createPipeBridge() {
200
242
  let abortReason = null;
201
243
  const safeWrite = (target, chunk) => {
202
244
  try {
203
- target.write(chunk);
245
+ const ready = target.write(chunk);
246
+ if (ready === false) {
247
+ return new Promise((resolve) => {
248
+ const withOnce = target;
249
+ if (typeof withOnce.once === "function") {
250
+ withOnce.once("drain", resolve);
251
+ } else {
252
+ resolve();
253
+ }
254
+ });
255
+ }
204
256
  } catch {
205
257
  }
258
+ return void 0;
206
259
  };
207
260
  const safeEnd = (target) => {
208
261
  try {
@@ -242,9 +295,12 @@ function createPipeBridge() {
242
295
  buffer.push(chunk);
243
296
  return;
244
297
  }
298
+ const pending = [];
245
299
  for (const target of targets) {
246
- safeWrite(target, chunk);
300
+ const result = safeWrite(target, chunk);
301
+ if (result) pending.push(result);
247
302
  }
303
+ return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
248
304
  },
249
305
  close() {
250
306
  if (state !== "open") return;
@@ -274,22 +330,78 @@ function createNodePipeBridge() {
274
330
  const streamModule = nodeRequire("node:stream");
275
331
  if (!streamModule.PassThrough) return null;
276
332
  const passThrough = new streamModule.PassThrough();
333
+ const buffer = [];
334
+ let piped = false;
335
+ let state = "open";
336
+ let abortReason = null;
337
+ const writeToPassThrough = (chunk) => {
338
+ if (passThrough.write(chunk) === false) {
339
+ return new Promise((resolve) => {
340
+ const withOnce = passThrough;
341
+ if (typeof withOnce.once === "function") {
342
+ withOnce.once("drain", resolve);
343
+ } else {
344
+ resolve();
345
+ }
346
+ });
347
+ }
348
+ return void 0;
349
+ };
350
+ const flushBuffer = () => {
351
+ if (buffer.length === 0) return void 0;
352
+ const pending = [];
353
+ for (const chunk of buffer) {
354
+ const result = writeToPassThrough(chunk);
355
+ if (result) pending.push(result);
356
+ }
357
+ buffer.length = 0;
358
+ return pending.length > 0 ? Promise.all(pending).then(() => void 0) : void 0;
359
+ };
360
+ const destroyPassThrough = (error) => {
361
+ if (typeof passThrough.destroy === "function") {
362
+ passThrough.destroy(error);
363
+ } else {
364
+ passThrough.end();
365
+ }
366
+ };
277
367
  return {
278
368
  pipe(writable) {
369
+ piped = true;
279
370
  passThrough.pipe(writable);
371
+ if (state === "aborted") {
372
+ destroyPassThrough(abortReason ?? new Error("Stream aborted"));
373
+ return;
374
+ }
375
+ const flushed = flushBuffer();
376
+ if (state === "closed") {
377
+ if (flushed) {
378
+ void flushed.then(() => passThrough.end());
379
+ } else {
380
+ passThrough.end();
381
+ }
382
+ }
280
383
  },
281
384
  write(chunk) {
282
- passThrough.write(chunk);
385
+ if (state !== "open") return;
386
+ if (!piped) {
387
+ buffer.push(chunk);
388
+ return void 0;
389
+ }
390
+ return writeToPassThrough(chunk);
283
391
  },
284
392
  close() {
393
+ if (state !== "open") return;
394
+ state = "closed";
395
+ if (!piped) return;
285
396
  passThrough.end();
286
397
  },
287
398
  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();
399
+ if (state !== "open") return;
400
+ state = "aborted";
401
+ abortReason = reason instanceof Error ? reason : new Error("Stream aborted");
402
+ buffer.length = 0;
403
+ if (piped) {
404
+ destroyPassThrough(abortReason);
293
405
  }
294
406
  }
295
407
  };
@@ -310,6 +422,13 @@ function getNodeRequire2() {
310
422
  }
311
423
  }
312
424
 
425
+ // src/stream-runtime.ts
426
+ function createStreamRuntimeCode(options = {}) {
427
+ const observerMode = options.observerMode ?? true;
428
+ 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);}' : "") + "})();";
429
+ }
430
+ var FICT_STREAM_RUNTIME_CODE = createStreamRuntimeCode({ observerMode: true });
431
+
313
432
  // src/index.ts
314
433
  var DEFAULT_HTML = "<!doctype html><html><head></head><body></body></html>";
315
434
  function createSSRDocument(html = DEFAULT_HTML) {
@@ -321,8 +440,12 @@ function createSSRDocument(html = DEFAULT_HTML) {
321
440
  return { window, document };
322
441
  }
323
442
  function renderToDocument(view, options = {}) {
443
+ const session = (0, import_internal2.__fictCreateSSRSession)();
444
+ return (0, import_internal2.__fictRunWithSSRSession)(session, () => renderToDocumentInSession(view, options));
445
+ }
446
+ function renderToDocumentInSession(view, options) {
324
447
  const includeSnapshot = options.includeSnapshot !== false;
325
- (0, import_internal.__fictEnableSSR)();
448
+ (0, import_internal2.__fictEnableSSR)();
326
449
  let dom;
327
450
  let restoreGlobals2 = () => {
328
451
  };
@@ -334,23 +457,23 @@ function renderToDocument(view, options = {}) {
334
457
  try {
335
458
  dom = resolveDom(options);
336
459
  const { document, window } = dom;
337
- const shouldExpose = options.exposeGlobals !== false;
460
+ const shouldExpose = options.exposeGlobals === true;
338
461
  restoreGlobals2 = shouldExpose ? installGlobals(window, document) : () => {
339
462
  };
340
463
  restoreManifest = installManifest(options.manifest);
341
464
  container = resolveContainer(document, options);
342
465
  teardown = (0, import_runtime.render)(view, container);
343
466
  if (includeSnapshot) {
344
- const state = (0, import_internal.__fictSerializeSSRState)();
467
+ const state = (0, import_internal2.__fictSerializeSSRState)();
345
468
  injectSnapshot(document, container, state, options);
346
469
  }
347
470
  } catch (error) {
348
- (0, import_internal.__fictDisableSSR)();
471
+ (0, import_internal2.__fictDisableSSR)();
349
472
  restoreGlobals2();
350
473
  restoreManifest();
351
474
  throw error;
352
475
  }
353
- (0, import_internal.__fictDisableSSR)();
476
+ (0, import_internal2.__fictDisableSSR)();
354
477
  const html = serializeOutput(dom.document, container, options);
355
478
  const dispose = () => {
356
479
  try {
@@ -374,6 +497,37 @@ async function renderToStringAsync(view, options = {}) {
374
497
  function renderToStream(view, options = {}) {
375
498
  const encoder = new TextEncoder();
376
499
  let controller = null;
500
+ let abortRender = null;
501
+ const readyResolvers = [];
502
+ const drainBackpressure = () => {
503
+ while (readyResolvers.length > 0) {
504
+ readyResolvers.shift()?.();
505
+ }
506
+ };
507
+ const resolveBackpressure = () => {
508
+ if (!controller || (controller.desiredSize ?? 1) <= 0) return;
509
+ drainBackpressure();
510
+ };
511
+ const closeController = () => {
512
+ abortRender = null;
513
+ drainBackpressure();
514
+ if (!controller) return;
515
+ try {
516
+ controller.close();
517
+ } finally {
518
+ controller = null;
519
+ }
520
+ };
521
+ const errorController = (reason) => {
522
+ abortRender = null;
523
+ drainBackpressure();
524
+ if (!controller) return;
525
+ try {
526
+ controller.error(reason);
527
+ } finally {
528
+ controller = null;
529
+ }
530
+ };
377
531
  const stream = new ReadableStream({
378
532
  start(ctrl) {
379
533
  controller = ctrl;
@@ -381,15 +535,32 @@ function renderToStream(view, options = {}) {
381
535
  write(chunk) {
382
536
  if (!controller) return;
383
537
  controller.enqueue(encoder.encode(chunk));
538
+ if ((controller.desiredSize ?? 1) <= 0) {
539
+ return new Promise((resolve) => {
540
+ readyResolvers.push(resolve);
541
+ });
542
+ }
543
+ return void 0;
384
544
  },
385
545
  close() {
386
- controller?.close();
546
+ closeController();
387
547
  },
388
548
  abort(reason) {
389
- controller?.error(reason);
549
+ errorController(reason);
390
550
  }
391
551
  });
552
+ abortRender = started.abort;
553
+ started.shellReady.catch(() => void 0);
392
554
  started.allReady.catch(() => void 0);
555
+ },
556
+ pull() {
557
+ resolveBackpressure();
558
+ },
559
+ cancel(reason) {
560
+ const abort = abortRender;
561
+ controller = null;
562
+ drainBackpressure();
563
+ abort?.(reason ?? new Error("Stream canceled"));
393
564
  }
394
565
  });
395
566
  return stream;
@@ -398,7 +569,7 @@ function renderToPipeableStream(view, options = {}) {
398
569
  const bridge = createPipeBridge();
399
570
  const { shellReady, allReady, abort } = startStreamingRender(view, options, {
400
571
  write(chunk) {
401
- bridge.write(chunk);
572
+ return bridge.write(chunk);
402
573
  },
403
574
  close() {
404
575
  bridge.close();
@@ -424,7 +595,12 @@ function renderToPartial(view, options = {}) {
424
595
  };
425
596
  let shell = "";
426
597
  let shellPhase = true;
427
- const queued = createQueuedTextStream();
598
+ let abortPartial = null;
599
+ const queued = createQueuedTextStream({
600
+ onCancel(reason) {
601
+ abortPartial?.(reason ?? new Error("Stream canceled"));
602
+ }
603
+ });
428
604
  const { shellReady, allReady, abort } = startStreamingRender(
429
605
  view,
430
606
  partialOptions,
@@ -434,7 +610,7 @@ function renderToPartial(view, options = {}) {
434
610
  shell += chunk;
435
611
  return;
436
612
  }
437
- queued.writer.write(chunk);
613
+ return queued.writer.write(chunk);
438
614
  },
439
615
  close() {
440
616
  queued.writer.close();
@@ -450,6 +626,7 @@ function renderToPartial(view, options = {}) {
450
626
  }
451
627
  }
452
628
  );
629
+ abortPartial = abort;
453
630
  return {
454
631
  shell,
455
632
  stream: queued.stream,
@@ -479,17 +656,39 @@ function resolveDom(options) {
479
656
  }
480
657
  return createSSRDocument(options.html);
481
658
  }
659
+ function isPromiseLike(value) {
660
+ return typeof value === "object" && value !== null && typeof value.then === "function";
661
+ }
482
662
  function startStreamingRender(view, options, writer, control = {}) {
663
+ const session = (0, import_internal2.__fictCreateSSRSession)();
664
+ return (0, import_internal2.__fictRunWithSSRSession)(
665
+ session,
666
+ () => startStreamingRenderInSession(session, view, options, writer, control)
667
+ );
668
+ }
669
+ function startStreamingRenderInSession(session, view, options, writer, control = {}) {
670
+ const runInSession = (fn) => (0, import_internal2.__fictRunWithSSRSession)(session, fn);
483
671
  const resolvedOptions = {
484
672
  ...options,
485
673
  // Streaming requires a real document; default to fullDocument when unspecified.
486
674
  fullDocument: options.fullDocument ?? true
487
675
  };
488
676
  let resolveShell;
677
+ let rejectShell;
489
678
  let resolveAll;
490
679
  let rejectAll;
491
- const shellReady = new Promise((res) => {
492
- resolveShell = res;
680
+ let shellSettled = false;
681
+ const shellReady = new Promise((res, rej) => {
682
+ resolveShell = () => {
683
+ if (shellSettled) return;
684
+ shellSettled = true;
685
+ res();
686
+ };
687
+ rejectShell = (err) => {
688
+ if (shellSettled) return;
689
+ shellSettled = true;
690
+ rej(err);
691
+ };
493
692
  });
494
693
  const allReady = new Promise((res, rej) => {
495
694
  resolveAll = res;
@@ -507,44 +706,129 @@ function startStreamingRender(view, options, writer, control = {}) {
507
706
  let tailHtml = "";
508
707
  let wroteShell = false;
509
708
  let shellCarriesTail = false;
709
+ let writeChain = null;
710
+ let writeFailed = false;
711
+ let removeAbortListener = () => {
712
+ };
510
713
  const mode = options.mode ?? "shell";
511
714
  const includeSnapshot = options.includeSnapshot !== false;
512
715
  const sentScopes = /* @__PURE__ */ new Set();
513
716
  const boundaryMap = /* @__PURE__ */ new Map();
514
717
  let boundaryId = 0;
515
718
  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);
719
+ const handleWriteError = (error) => {
720
+ if (writeFailed) return;
721
+ writeFailed = true;
722
+ options.onError?.(error);
723
+ writer.abort(error);
724
+ cleanup();
725
+ rejectShell(error);
726
+ rejectAll(error);
727
+ };
728
+ const enqueueWrite = (chunk) => {
729
+ if (writeFailed) return;
730
+ const trackWrite = (promise) => {
731
+ const tracked = promise.then(
732
+ () => {
733
+ if (writeChain === tracked) {
734
+ writeChain = null;
735
+ }
736
+ },
737
+ (error) => {
738
+ if (writeChain === tracked) {
739
+ writeChain = null;
740
+ }
741
+ handleWriteError(error);
742
+ }
743
+ );
744
+ writeChain = tracked;
745
+ };
746
+ const writeAsync = () => Promise.resolve(writer.write(chunk)).then(() => void 0);
747
+ if (writeChain) {
748
+ trackWrite(writeChain.then(writeAsync));
749
+ return;
750
+ }
751
+ try {
752
+ const result = writer.write(chunk);
753
+ if (isPromiseLike(result)) {
754
+ trackWrite(
755
+ result.then(
756
+ () => void 0,
757
+ (error) => {
758
+ throw error;
759
+ }
760
+ )
761
+ );
762
+ }
763
+ } catch (error) {
764
+ handleWriteError(error);
527
765
  }
528
- for (const id of ids) {
529
- sentScopes.add(id);
766
+ };
767
+ const afterWrites = (fn) => {
768
+ const pending = writeChain;
769
+ if (!pending) {
770
+ if (!writeFailed) {
771
+ fn();
772
+ }
773
+ return;
530
774
  }
775
+ void pending.then(() => {
776
+ if (!writeFailed) {
777
+ fn();
778
+ }
779
+ });
780
+ };
781
+ const markShellReady = () => {
782
+ afterWrites(() => {
783
+ control.onShellFlushed?.();
784
+ resolveShell();
785
+ options.onShellReady?.();
786
+ });
787
+ };
788
+ const writeSnapshotForScopes = (scopeIds) => {
789
+ runInSession(() => {
790
+ if (!includeSnapshot || scopeIds.length === 0) return;
791
+ const registry = (0, import_internal2.__fictGetScopeRegistry)();
792
+ const pending = scopeIds.filter((id) => registry.has(id) && !sentScopes.has(id));
793
+ if (pending.length === 0) return;
794
+ const snapshot = (0, import_internal2.__fictSerializeSSRStateForScopes)(pending);
795
+ const ids = Object.keys(snapshot.scopes);
796
+ if (ids.length === 0) return;
797
+ const chunk = buildIncrementalSnapshotChunk(snapshot, resolvedOptions);
798
+ if (chunk) {
799
+ enqueueWrite(chunk);
800
+ }
801
+ for (const id of ids) {
802
+ sentScopes.add(id);
803
+ }
804
+ });
531
805
  };
532
806
  const writeSnapshotForBoundary = (boundary) => {
533
- const scopes = (0, import_internal.__fictGetScopesForBoundary)(boundary);
534
- writeSnapshotForScopes(scopes);
807
+ runInSession(() => {
808
+ const scopes = (0, import_internal2.__fictGetScopesForBoundary)(boundary);
809
+ writeSnapshotForScopes(scopes);
810
+ });
535
811
  };
536
812
  const writeRemainingSnapshots = () => {
537
- const scopes = Array.from((0, import_internal.__fictGetScopeRegistry)().keys());
538
- writeSnapshotForScopes(scopes);
813
+ runInSession(() => {
814
+ const scopes = Array.from((0, import_internal2.__fictGetScopeRegistry)().keys());
815
+ writeSnapshotForScopes(scopes);
816
+ });
539
817
  };
540
818
  const cleanup = () => {
541
- (0, import_internal.__fictSetSSRStreamHooks)(null);
542
- (0, import_internal.__fictDisableSSR)();
543
- restoreGlobals2();
544
- restoreManifest();
819
+ removeAbortListener();
820
+ removeAbortListener = () => {
821
+ };
822
+ runInSession(() => {
823
+ (0, import_internal2.__fictSetSSRStreamHooks)(null);
824
+ (0, import_internal2.__fictDisableSSR)();
825
+ });
545
826
  try {
546
827
  teardown();
547
828
  } catch {
829
+ } finally {
830
+ restoreGlobals2();
831
+ restoreManifest();
548
832
  }
549
833
  };
550
834
  const finalize = () => {
@@ -552,27 +836,31 @@ function startStreamingRender(view, options, writer, control = {}) {
552
836
  closed = true;
553
837
  if (mode === "all" && dom && container && !wroteShell) {
554
838
  if (includeSnapshot) {
555
- const snapshot = (0, import_internal.__fictSerializeSSRState)();
839
+ const snapshot = (0, import_internal2.__fictSerializeSSRState)();
556
840
  injectSnapshot(dom.document, container, snapshot, resolvedOptions);
557
841
  }
558
842
  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?.();
843
+ enqueueWrite(fullHtml);
844
+ afterWrites(() => {
845
+ writer.close();
846
+ cleanup();
847
+ resolveShell();
848
+ resolveAll();
849
+ options.onShellReady?.();
850
+ options.onAllReady?.();
851
+ });
566
852
  return;
567
853
  }
568
854
  writeRemainingSnapshots();
569
855
  if (tailHtml) {
570
- writer.write(tailHtml);
856
+ enqueueWrite(tailHtml);
571
857
  }
572
- writer.close();
573
- cleanup();
574
- resolveAll();
575
- options.onAllReady?.();
858
+ afterWrites(() => {
859
+ writer.close();
860
+ cleanup();
861
+ resolveAll();
862
+ options.onAllReady?.();
863
+ });
576
864
  };
577
865
  const maybeFinalize = () => {
578
866
  if (pendingCount === 0) {
@@ -602,7 +890,7 @@ function startStreamingRender(view, options, writer, control = {}) {
602
890
  writeSnapshotForBoundary(id);
603
891
  if (dom) {
604
892
  const html = serializeBetween(dom.document, entry.start, entry.end);
605
- writer.write(buildPatchChunk(id, html));
893
+ enqueueWrite(buildPatchChunk(id, html, resolvedOptions));
606
894
  }
607
895
  }
608
896
  maybeFinalize();
@@ -615,22 +903,28 @@ function startStreamingRender(view, options, writer, control = {}) {
615
903
  const abort = (reason) => {
616
904
  if (closed) return;
617
905
  closed = true;
906
+ writeFailed = true;
618
907
  writer.abort(reason);
619
908
  cleanup();
620
- rejectAll(reason ?? new Error("Stream aborted"));
909
+ const abortReason = reason ?? new Error("Stream aborted");
910
+ rejectShell(abortReason);
911
+ rejectAll(abortReason);
621
912
  };
622
913
  if (options.signal) {
623
914
  if (options.signal.aborted) {
624
915
  abort(options.signal.reason);
916
+ return { shellReady, allReady, abort };
625
917
  } else {
626
- options.signal.addEventListener("abort", () => abort(options.signal?.reason), { once: true });
918
+ const onAbort = () => abort(options.signal?.reason);
919
+ options.signal.addEventListener("abort", onAbort, { once: true });
920
+ removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
627
921
  }
628
922
  }
629
923
  try {
630
- (0, import_internal.__fictEnableSSR)();
631
- (0, import_internal.__fictSetSSRStreamHooks)(hooks);
924
+ (0, import_internal2.__fictEnableSSR)();
925
+ (0, import_internal2.__fictSetSSRStreamHooks)(hooks);
632
926
  dom = resolveDom(resolvedOptions);
633
- restoreGlobals2 = resolvedOptions.exposeGlobals !== false ? installGlobals(dom.window, dom.document) : () => {
927
+ restoreGlobals2 = resolvedOptions.exposeGlobals === true ? installGlobals(dom.window, dom.document) : () => {
634
928
  };
635
929
  restoreManifest = installManifest(resolvedOptions.manifest);
636
930
  container = resolveContainer(dom.document, resolvedOptions);
@@ -642,33 +936,31 @@ function startStreamingRender(view, options, writer, control = {}) {
642
936
  return { shellReady, allReady, abort };
643
937
  }
644
938
  const shellHtml = serializeOutput(dom.document, container, resolvedOptions);
645
- const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript() : "";
939
+ const streamRuntime = boundaryMap.size > 0 ? buildStreamRuntimeScript(resolvedOptions) : "";
646
940
  if (resolvedOptions.fullDocument) {
647
941
  const split = splitDocumentHtml(shellHtml);
648
942
  if (!split) {
649
943
  throw new Error("[fict/ssr] Failed to locate </body> for streaming output.");
650
944
  }
651
945
  if (control.includeTailInShell) {
652
- writer.write(split.head + streamRuntime);
946
+ enqueueWrite(split.head + streamRuntime);
653
947
  tailHtml = split.tail;
654
948
  shellCarriesTail = true;
655
949
  } else {
656
- writer.write(split.head + streamRuntime);
950
+ enqueueWrite(split.head + streamRuntime);
657
951
  tailHtml = split.tail;
658
952
  }
659
953
  } else {
660
- writer.write(shellHtml + streamRuntime);
954
+ enqueueWrite(shellHtml + streamRuntime);
661
955
  }
662
956
  wroteShell = true;
663
- writeSnapshotForScopes(Array.from((0, import_internal.__fictGetScopeRegistry)().keys()));
957
+ writeSnapshotForScopes(Array.from((0, import_internal2.__fictGetScopeRegistry)().keys()));
664
958
  if (shellCarriesTail && tailHtml) {
665
- writer.write(tailHtml);
959
+ enqueueWrite(tailHtml);
666
960
  tailHtml = "";
667
961
  shellCarriesTail = false;
668
962
  }
669
- control.onShellFlushed?.();
670
- resolveShell();
671
- options.onShellReady?.();
963
+ markShellReady();
672
964
  maybeFinalize();
673
965
  } catch (err) {
674
966
  options.onError?.(err);
@@ -699,11 +991,28 @@ function resolveContainer(document, options) {
699
991
  }
700
992
  return container;
701
993
  }
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>`;
994
+ function buildStreamRuntimeScript(options) {
995
+ const nonce = renderNonceAttribute(options);
996
+ if (options.streamRuntime === "external") {
997
+ if (!options.streamRuntimeSrc) {
998
+ throw new Error('[fict/ssr] streamRuntimeSrc is required when streamRuntime is "external".');
999
+ }
1000
+ return `<script${nonce} src="${escapeAttribute(options.streamRuntimeSrc)}" data-fict-stream-runtime data-fict-stream-observer></script>`;
1001
+ }
1002
+ return `<script${nonce}>${createStreamRuntimeCode({
1003
+ observerMode: resolveStreamPatchMode(options) === "observer"
1004
+ })}</script>`;
704
1005
  }
705
- function buildPatchChunk(id, html) {
706
- return `<template data-fict-suspense="${id}">` + html + `</template><script>__FICT_STREAM.apply("${id}")</script>`;
1006
+ function buildPatchChunk(id, html, options) {
1007
+ const template = `<template data-fict-suspense="${escapeAttribute(id)}">${html}</template>`;
1008
+ if (resolveStreamPatchMode(options) === "observer") {
1009
+ return template;
1010
+ }
1011
+ return `${template}<script${renderNonceAttribute(options)}>__FICT_STREAM.apply("${escapeScriptString(id)}")</script>`;
1012
+ }
1013
+ function resolveStreamPatchMode(options) {
1014
+ if (options.streamPatchMode) return options.streamPatchMode;
1015
+ return options.streamRuntime === "external" ? "observer" : "inline";
707
1016
  }
708
1017
  function serializeBetween(document, start, end) {
709
1018
  const wrapper = document.createElement("div");
@@ -722,11 +1031,13 @@ function splitDocumentHtml(html) {
722
1031
  }
723
1032
  function buildIncrementalSnapshotChunk(state, options) {
724
1033
  const json = serializeSnapshotForScript(state);
1034
+ const nonce = renderNonceAttribute(options);
725
1035
  if (options.snapshotTarget === "head") {
726
1036
  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>`;
1037
+ const setNonce = options.scriptNonce !== void 0 ? `s.setAttribute('nonce',${serializeScriptStringLiteral(options.scriptNonce)});` : "";
1038
+ 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
1039
  }
729
- return `<script type="application/json" data-fict-snapshot>${json}</script>`;
1040
+ return `<script${nonce} type="application/json" data-fict-snapshot>${json}</script>`;
730
1041
  }
731
1042
  function serializeOutput(document, container, options) {
732
1043
  if (options.fullDocument) {
@@ -743,6 +1054,9 @@ function injectSnapshot(document, container, state, options) {
743
1054
  const script = document.createElement("script");
744
1055
  script.type = "application/json";
745
1056
  script.id = options.snapshotScriptId ?? "__FICT_SNAPSHOT__";
1057
+ if (options.scriptNonce !== void 0) {
1058
+ script.setAttribute("nonce", options.scriptNonce);
1059
+ }
746
1060
  script.textContent = serializeSnapshotForScript(state);
747
1061
  if (options.fullDocument) {
748
1062
  if (options.snapshotTarget === "head" && document.head) {
@@ -768,6 +1082,18 @@ function injectSnapshot(document, container, state, options) {
768
1082
  function serializeSnapshotForScript(state) {
769
1083
  return JSON.stringify(state).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
770
1084
  }
1085
+ function serializeScriptStringLiteral(value) {
1086
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1087
+ }
1088
+ function renderNonceAttribute(options) {
1089
+ return options.scriptNonce === void 0 ? "" : ` nonce="${escapeAttribute(options.scriptNonce)}"`;
1090
+ }
1091
+ function escapeAttribute(value) {
1092
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1093
+ }
1094
+ function escapeScriptString(value) {
1095
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1096
+ }
771
1097
  function serializeDoctype(document, override) {
772
1098
  if (override === null) return "";
773
1099
  if (override !== void 0) return override;