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