@fictjs/ssr 0.4.0 → 0.5.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/README.md ADDED
@@ -0,0 +1,619 @@
1
+ # @fictjs/ssr
2
+
3
+ Fict's Server-Side Rendering (SSR) package, providing high-performance server-side rendering and client-side resumability capabilities.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [Installation](#installation)
9
+ - [Quick Start](#quick-start)
10
+ - [Core Concepts](#core-concepts)
11
+ - [API Reference](#api-reference)
12
+ - [Architecture Design](#architecture-design)
13
+ - [Integration with Vite](#integration-with-vite)
14
+ - [Advanced Usage](#advanced-usage)
15
+ - [Performance Optimization](#performance-optimization)
16
+ - [Troubleshooting](#troubleshooting)
17
+
18
+ ## Overview
19
+
20
+ Fict SSR adopts a **Resumability** architecture, which is fundamentally different from traditional Hydration:
21
+
22
+ | Feature | Traditional Hydration | Fict Resumability |
23
+ | ------------------------- | --------------------------------- | --------------------------------- |
24
+ | Client JS Execution | Re-executes entire component tree | Only executes on interaction |
25
+ | Time to Interactive (TTI) | High (waits for hydration) | Low (Zero JS execution) |
26
+ | Handler Loading | All preloaded | Lazy loaded on demand |
27
+ | State Restoration | Re-calculated | Restored from serialized snapshot |
28
+
29
+ ### How it Works
30
+
31
+ ```
32
+ ┌─────────────────────────────────────────────────────────────────┐
33
+ │ Server-Side Rendering │
34
+ ├─────────────────────────────────────────────────────────────────┤
35
+ │ 1. Execute component code │
36
+ │ 2. Generate HTML + Serialize state snapshot │
37
+ │ 3. Inject QRL (Qualified Resource Locator) references │
38
+ └─────────────────────────────────────────────────────────────────┘
39
+
40
+
41
+ ┌─────────────────────────────────────────────────────────────────┐
42
+ │ Client-Side Resumability │
43
+ ├─────────────────────────────────────────────────────────────────┤
44
+ │ 1. Parse snapshot, store in memory │
45
+ │ 2. Install event delegation listeners │
46
+ │ 3. On user interaction: │
47
+ │ a. Lazy load handler chunk │
48
+ │ b. Restore component state (from snapshot) │
49
+ │ c. Establish reactive bindings │
50
+ │ d. Execute handler │
51
+ └─────────────────────────────────────────────────────────────────┘
52
+ ```
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ pnpm add @fictjs/ssr
58
+ ```
59
+
60
+ ## Quick Start
61
+
62
+ ### Basic SSR
63
+
64
+ ```typescript
65
+ import { renderToString } from '@fictjs/ssr'
66
+ import { App } from './App'
67
+
68
+ // Server-side
69
+ const html = renderToString(() => <App />)
70
+ ```
71
+
72
+ ### SSR with Resumability
73
+
74
+ ```typescript
75
+ import { renderToString } from '@fictjs/ssr'
76
+ import { App } from './App'
77
+
78
+ const html = renderToString(() => <App />, {
79
+ includeSnapshot: true, // Include state snapshot (default true)
80
+ containerId: 'app',
81
+ manifest: './dist/client/fict.manifest.json',
82
+ })
83
+ ```
84
+
85
+ ### Client-Side Resumability
86
+
87
+ ```typescript
88
+ // entry-client.tsx
89
+ import { installResumableLoader } from '@fictjs/runtime/loader'
90
+
91
+ // Load manifest (production)
92
+ async function loadManifest() {
93
+ const res = await fetch('/fict.manifest.json')
94
+ if (res.ok) {
95
+ globalThis.__FICT_MANIFEST__ = await res.json()
96
+ }
97
+ }
98
+
99
+ async function init() {
100
+ await loadManifest()
101
+
102
+ installResumableLoader({
103
+ events: ['click', 'input', 'change', 'submit'],
104
+ prefetch: {
105
+ visibility: true,
106
+ visibilityMargin: '200px',
107
+ hover: true,
108
+ hoverDelay: 50,
109
+ },
110
+ })
111
+ }
112
+
113
+ init()
114
+ ```
115
+
116
+ ## Core Concepts
117
+
118
+ ### 1. QRL (Qualified Resource Locator)
119
+
120
+ QRL is the URL format Fict uses for lazy loading handlers:
121
+
122
+ ```
123
+ virtual:fict-handler:/path/to/file.tsx$$__fict_e0#default
124
+ │ │ │ │
125
+ │ │ │ └─ Export Name
126
+ │ │ └─ Handler ID
127
+ │ └─ Source File Path
128
+ └─ Virtual Module Prefix
129
+ ```
130
+
131
+ **Representation in HTML:**
132
+
133
+ ```html
134
+ <button on:click="virtual:fict-handler:/src/App.tsx$$__fict_e0#default">Click me</button>
135
+ ```
136
+
137
+ ### 2. State Snapshot
138
+
139
+ During server-side rendering, component state is serialized into JSON and injected into HTML:
140
+
141
+ ```html
142
+ <script id="__FICT_SNAPSHOT__" type="application/json">
143
+ {
144
+ "scopes": {
145
+ "s1": {
146
+ "id": "s1",
147
+ "slots": [
148
+ [0, "sig", 10], // Index 0: signal, value 10
149
+ [1, "store", {...}], // Index 1: store
150
+ [2, "raw", null] // Index 2: raw value
151
+ ],
152
+ "vars": { "count": 0 }
153
+ }
154
+ }
155
+ }
156
+ </script>
157
+ ```
158
+
159
+ **Supported Serialization Types:**
160
+
161
+ | Type | Tag | Description |
162
+ | --------- | ------------------------- | -------------------------- |
163
+ | Date | `__t: 'd'` | Stored as timestamp |
164
+ | Map | `__t: 'm'` | Stored as entries array |
165
+ | Set | `__t: 's'` | Stored as array |
166
+ | RegExp | `__t: 'r'` | Stored as source + flags |
167
+ | undefined | `__t: 'u'` | Special tag |
168
+ | NaN | `__t: 'n'` | Special tag |
169
+ | Infinity | `__t: '+i'` / `__t: '-i'` | Positive/Negative Infinity |
170
+ | BigInt | `__t: 'b'` | Stored as string |
171
+
172
+ ### 3. Scope Registration
173
+
174
+ Each resumable component instance has a unique scope ID:
175
+
176
+ ```html
177
+ <fict-host
178
+ data-fict-s="s1" <!-- scope ID -->
179
+ data-fict-h="/assets/index.js#__fict_r0" <!-- resume handler -->
180
+ data-fict-t="Counter@file:///src/App.tsx" <!-- Component Type -->
181
+ >
182
+ ...
183
+ </fict-host>
184
+ ```
185
+
186
+ ### 4. Automatic Handler Extraction
187
+
188
+ The Fict compiler supports two ways to extract handlers:
189
+
190
+ **Explicit Extraction (using `$` suffix):**
191
+
192
+ ```tsx
193
+ <button onClick$={() => count++}> // Always extracted
194
+ ```
195
+
196
+ **Automatic Extraction (enable `autoExtractHandlers`):**
197
+
198
+ ```tsx
199
+ <button onClick={() => count++}> // Simple, might not extract
200
+ <button onClick={() => fetchData()}> // Complex, auto-extracted
201
+ <button onClick={handleSubmit}> // External reference, auto-extracted
202
+ ```
203
+
204
+ **Heuristic Rules for Auto-Extraction:**
205
+
206
+ | Condition | Extracted? |
207
+ | --------------------------- | ---------- |
208
+ | External function reference | ✅ |
209
+ | Contains external calls | ✅ |
210
+ | Contains async/await | ✅ |
211
+ | AST node count ≥ threshold | ✅ |
212
+ | Simple expression | ❌ |
213
+
214
+ ## API Reference
215
+
216
+ ### renderToString
217
+
218
+ ```typescript
219
+ function renderToString(view: () => FictNode, options?: RenderToStringOptions): string
220
+ ```
221
+
222
+ **Options:**
223
+
224
+ ```typescript
225
+ interface RenderToStringOptions {
226
+ // DOM Configuration
227
+ dom?: SSRDom
228
+ document?: Document
229
+ window?: Window
230
+ html?: string
231
+
232
+ // Container Configuration
233
+ container?: HTMLElement
234
+ containerTag?: string // Default: 'div'
235
+ containerId?: string
236
+ containerAttributes?: Record<string, string | number | boolean>
237
+
238
+ // Output Configuration
239
+ includeContainer?: boolean // Include container tag
240
+ fullDocument?: boolean // Output full HTML document
241
+ doctype?: string | null
242
+
243
+ // Resumability Configuration
244
+ includeSnapshot?: boolean // Default: true
245
+ snapshotScriptId?: string // Default: '__FICT_SNAPSHOT__'
246
+ snapshotTarget?: 'container' | 'body' | 'head'
247
+
248
+ // Runtime Configuration
249
+ exposeGlobals?: boolean // Default: true
250
+ manifest?: Record<string, string> | string
251
+ }
252
+ ```
253
+
254
+ ### renderToDocument
255
+
256
+ ```typescript
257
+ function renderToDocument(
258
+ view: () => FictNode,
259
+ options?: RenderToStringOptions,
260
+ ): RenderToDocumentResult
261
+
262
+ interface RenderToDocumentResult extends SSRDom {
263
+ html: string
264
+ container: HTMLElement
265
+ dispose: () => void
266
+ }
267
+ ```
268
+
269
+ Returns a DOM object for further manipulation or streaming rendering.
270
+
271
+ ### createSSRDocument
272
+
273
+ ```typescript
274
+ function createSSRDocument(html?: string): SSRDom
275
+
276
+ interface SSRDom {
277
+ window: Window
278
+ document: Document
279
+ }
280
+ ```
281
+
282
+ Creates a virtual DOM environment for SSR.
283
+
284
+ ### installResumableLoader
285
+
286
+ ```typescript
287
+ function installResumableLoader(options?: ResumableLoaderOptions): void
288
+
289
+ interface ResumableLoaderOptions {
290
+ document?: Document
291
+ snapshotScriptId?: string
292
+ events?: string[] // Default: DelegatedEvents
293
+ prefetch?: PrefetchStrategy | false
294
+ }
295
+
296
+ interface PrefetchStrategy {
297
+ visibility?: boolean // Default: true
298
+ visibilityMargin?: string // Default: '200px'
299
+ hover?: boolean // Default: true
300
+ hoverDelay?: number // Default: 50
301
+ }
302
+ ```
303
+
304
+ ## Architecture Design
305
+
306
+ ### Build Time
307
+
308
+ ```
309
+ ┌──────────────────────────────────────────────────────────────┐
310
+ │ Fict Compiler │
311
+ ├──────────────────────────────────────────────────────────────┤
312
+ │ │
313
+ │ Source Code Build Output │
314
+ │ ─────────── ──────────── │
315
+ │ onClick$={() => count++} 1. Main bundle (incl resume fn) │
316
+ │ 2. Handler chunk (lazy loaded) │
317
+ │ 3. QRL Ref (HTML attribute) │
318
+ │ │
319
+ └──────────────────────────────────────────────────────────────┘
320
+ ```
321
+
322
+ **Generated Code Structure:**
323
+
324
+ ```javascript
325
+ // Main bundle
326
+ const __fict_r0 = (scopeId, host) => {
327
+ // Resume Function: Restore state + Bind reactivity
328
+ const scope = __fictGetSSRScope(scopeId)
329
+ let count = __fictRestoreSignal(scope, 0)
330
+
331
+ $effect(() => {
332
+ /* Bind DOM update */
333
+ })
334
+ }
335
+
336
+ __fictRegisterResume('__fict_r0', __fict_r0)
337
+
338
+ // Handler chunk (separate file)
339
+ export default (scopeId, event, el) => {
340
+ const [count] = __fictUseLexicalScope(scopeId, ['count'])
341
+ count++ // Trigger signal update
342
+ }
343
+ ```
344
+
345
+ ### Runtime
346
+
347
+ ```
348
+ ┌─────────────────────────────────────────────────────────────────┐
349
+ │ Resumable Loader │
350
+ ├─────────────────────────────────────────────────────────────────┤
351
+ │ │
352
+ │ 1. Parse snapshot ──────────► Store in snapshotState │
353
+ │ │
354
+ │ 2. Register event delegation ──────► doc.addEventListener │
355
+ │ │
356
+ │ 3. On Event Trigger: │
357
+ │ ┌─────────────────────────────────────────────────────┐ │
358
+ │ │ a. Look up on:* attribute to get QRL │ │
359
+ │ │ b. Check if hydrated │ │
360
+ │ │ c. If not hydrated: │ │
361
+ │ │ - Load resume module │ │
362
+ │ │ - Get resume fn from registry │ │
363
+ │ │ - Execute resume (restore state + bind) │ │
364
+ │ │ d. Load handler chunk │ │
365
+ │ │ e. Execute handler │ │
366
+ │ └─────────────────────────────────────────────────────┘ │
367
+ │ │
368
+ └─────────────────────────────────────────────────────────────────┘
369
+ ```
370
+
371
+ ### Manifest File
372
+
373
+ Generated detailed `fict.manifest.json` during production build, mapping virtual modules to actual chunks:
374
+
375
+ ```json
376
+ {
377
+ "virtual:fict-handler:/src/App.tsx$$__fict_e0": "/assets/handler-e0-abc123.js",
378
+ "virtual:fict-handler:/src/App.tsx$$__fict_e1": "/assets/handler-e1-def456.js",
379
+ "file:///src/App.tsx": "/assets/index-xyz789.js"
380
+ }
381
+ ```
382
+
383
+ ## Integration with Vite
384
+
385
+ ### vite.config.ts
386
+
387
+ ```typescript
388
+ import { defineConfig } from 'vite'
389
+ import fict from '@fictjs/vite-plugin'
390
+
391
+ export default defineConfig({
392
+ plugins: [
393
+ fict({
394
+ resumable: true,
395
+ autoExtractHandlers: true,
396
+ autoExtractThreshold: 3,
397
+ }),
398
+ ],
399
+ })
400
+ ```
401
+
402
+ ### Configuration Options
403
+
404
+ ```typescript
405
+ interface FictPluginOptions {
406
+ // Resumability
407
+ resumable?: boolean // Enable resumable mode
408
+ autoExtractHandlers?: boolean // Auto extract handlers
409
+ autoExtractThreshold?: number // Auto extract threshold (default: 3)
410
+
411
+ // Build Options
412
+ fineGrainedDom?: boolean // Fine-grained DOM updates
413
+ optimize?: boolean // HIR Optimization
414
+ // ...
415
+ }
416
+ ```
417
+
418
+ ### Build Output
419
+
420
+ ```
421
+ dist/
422
+ ├── client/
423
+ │ ├── index.html
424
+ │ ├── fict.manifest.json
425
+ │ └── assets/
426
+ │ ├── index-abc123.js # Main bundle
427
+ │ ├── chunk-xyz789.js # Shared chunk (runtime)
428
+ │ ├── handler-__fict_e0-*.js # Handler chunk
429
+ │ ├── handler-__fict_e1-*.js
430
+ │ └── handler-__fict_e2-*.js
431
+ └── server/
432
+ └── entry-server.js # SSR bundle
433
+ ```
434
+
435
+ ## Advanced Usage
436
+
437
+ ### Custom SSR Server
438
+
439
+ ```typescript
440
+ // server.js
441
+ import express from 'express'
442
+ import { renderToString } from '@fictjs/ssr'
443
+ import { App } from './dist/server/entry-server.js'
444
+
445
+ const app = express()
446
+
447
+ // Static assets
448
+ app.use('/assets', express.static('dist/client/assets'))
449
+
450
+ // SSR Route
451
+ app.get('*', async (req, res) => {
452
+ const manifest = JSON.parse(
453
+ fs.readFileSync('dist/client/fict.manifest.json', 'utf-8')
454
+ )
455
+
456
+ const appHtml = renderToString(() => <App url={req.url} />, {
457
+ manifest,
458
+ containerId: 'app',
459
+ })
460
+
461
+ const html = template.replace('<!--app-html-->', appHtml)
462
+ res.send(html)
463
+ })
464
+ ```
465
+
466
+ ### Streaming Rendering (Experimental)
467
+
468
+ ```typescript
469
+ import { renderToDocument } from '@fictjs/ssr'
470
+
471
+ app.get('*', async (req, res) => {
472
+ const result = renderToDocument(() => <App />, {
473
+ includeSnapshot: true,
474
+ })
475
+
476
+ // Can send in chunks
477
+ res.write('<!DOCTYPE html><html><head>...</head><body>')
478
+ res.write(result.html)
479
+ res.write('</body></html>')
480
+ res.end()
481
+
482
+ result.dispose()
483
+ })
484
+ ```
485
+
486
+ ### Prefetch Strategy
487
+
488
+ ```typescript
489
+ installResumableLoader({
490
+ prefetch: {
491
+ // Prefetch when element enters viewport
492
+ visibility: true,
493
+ visibilityMargin: '500px', // Start prefetch 500px early
494
+
495
+ // Prefetch on hover
496
+ hover: true,
497
+ hoverDelay: 100, // 100ms debounce
498
+ },
499
+ })
500
+ ```
501
+
502
+ ### Disable Extraction for Specific Handlers
503
+
504
+ ```tsx
505
+ // Use normal onClick and set autoExtractHandlers: false
506
+ // Or ensure handler is simple enough not to trigger auto-extraction
507
+
508
+ <button onClick={() => count++}> // Simple, not extracted
509
+ ```
510
+
511
+ ## Performance Optimization
512
+
513
+ ### 1. Handler Chunk Size Optimization
514
+
515
+ ```typescript
516
+ // ❌ Not Recommended: Large dependency in handler
517
+ <button onClick$={async () => {
518
+ const { parse } = await import('large-library')
519
+ parse(data)
520
+ }}>
521
+
522
+ // ✅ Recommended: Import at component level
523
+ import { parse } from 'large-library'
524
+ <button onClick$={() => parse(data)}>
525
+ ```
526
+
527
+ ### 2. Prefetch Tuning
528
+
529
+ ```typescript
530
+ // For critical interactions, use more aggressive preloading
531
+ installResumableLoader({
532
+ prefetch: {
533
+ visibility: true,
534
+ visibilityMargin: '1000px', // Prefetch even earlier
535
+ },
536
+ })
537
+ ```
538
+
539
+ ### 3. Reduce Serialization Overhead
540
+
541
+ ```tsx
542
+ // ❌ Avoid serializing large objects
543
+ let largeData = $state(hugeArray)
544
+
545
+ // ✅ Recommended: Serialize only necessary data
546
+ let dataId = $state(id) // Store ID only, fetch on client
547
+ ```
548
+
549
+ ## Troubleshooting
550
+
551
+ ### Common Issues
552
+
553
+ #### 1. "Failed to fetch dynamically imported module"
554
+
555
+ **Cause:** Manifest not loaded correctly or QRL path mismatch.
556
+
557
+ **Solution:**
558
+
559
+ ```typescript
560
+ // Ensure manifest is loaded before installResumableLoader
561
+ await loadManifest()
562
+ installResumableLoader(...)
563
+ ```
564
+
565
+ #### 2. Handler called but DOM not updating
566
+
567
+ **Cause:** Resume function not executed or not correctly registered.
568
+
569
+ **Check:**
570
+
571
+ ```typescript
572
+ // Ensure resume function is registered
573
+ import { __fictGetResume } from '@fictjs/runtime/internal'
574
+ console.log(__fictGetResume('__fict_r0')) // Should return function
575
+ ```
576
+
577
+ #### 3. "ReferenceError: xxx is not defined"
578
+
579
+ **Cause:** Handler chunk references uncaptured variable.
580
+
581
+ **Solution:** Ensure all required variables are in closure scope.
582
+
583
+ #### 4. Snapshot too large
584
+
585
+ **Cause:** Serializing large amount of data.
586
+
587
+ **Solution:**
588
+
589
+ ```typescript
590
+ // Use lazy initialization
591
+ let data = $state(null) // Initial null
592
+ onMount(async () => {
593
+ data = await fetchData() // Fetch on client
594
+ })
595
+ ```
596
+
597
+ ### Debugging Tips
598
+
599
+ ```typescript
600
+ // Enable loader logs (during dev)
601
+ // Console.log statements in loader.ts can help debug
602
+
603
+ // Check manifest content
604
+ console.log(globalThis.__FICT_MANIFEST__)
605
+
606
+ // Check snapshot content
607
+ const snapshot = document.getElementById('__FICT_SNAPSHOT__')
608
+ console.log(JSON.parse(snapshot.textContent))
609
+ ```
610
+
611
+ ## Related Packages
612
+
613
+ - `@fictjs/runtime` - Core runtime, containing signal/effect system
614
+ - `@fictjs/compiler` - Babel plugin, handling JSX transform and handler extraction
615
+ - `@fictjs/vite-plugin` - Vite integration, handling build and code splitting
616
+
617
+ ## License
618
+
619
+ MIT
package/dist/index.cjs CHANGED
@@ -1 +1,282 @@
1
1
  "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createSSRDocument: () => createSSRDocument,
24
+ renderToDocument: () => renderToDocument,
25
+ renderToString: () => renderToString,
26
+ renderToStringAsync: () => renderToStringAsync
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+ var import_node_fs = require("fs");
30
+ var import_runtime = require("@fictjs/runtime");
31
+ var import_internal = require("@fictjs/runtime/internal");
32
+ var import_linkedom = require("linkedom");
33
+ var DEFAULT_HTML = "<!doctype html><html><head></head><body></body></html>";
34
+ function createSSRDocument(html = DEFAULT_HTML) {
35
+ const window = (0, import_linkedom.parseHTML)(html);
36
+ const document = window.document;
37
+ if (!window || !document) {
38
+ throw new Error("[fict/ssr] Failed to create DOM. Missing window or document.");
39
+ }
40
+ return { window, document };
41
+ }
42
+ function renderToDocument(view, options = {}) {
43
+ const includeSnapshot = options.includeSnapshot !== false;
44
+ (0, import_internal.__fictEnableSSR)();
45
+ let dom;
46
+ let restoreGlobals2 = () => {
47
+ };
48
+ let restoreManifest = () => {
49
+ };
50
+ let container;
51
+ let teardown = () => {
52
+ };
53
+ try {
54
+ dom = resolveDom(options);
55
+ const { document, window } = dom;
56
+ const shouldExpose = options.exposeGlobals !== false;
57
+ restoreGlobals2 = shouldExpose ? installGlobals(window, document) : () => {
58
+ };
59
+ restoreManifest = installManifest(options.manifest);
60
+ container = resolveContainer(document, options);
61
+ teardown = (0, import_runtime.render)(view, container);
62
+ if (includeSnapshot) {
63
+ const state = (0, import_internal.__fictSerializeSSRState)();
64
+ injectSnapshot(document, container, state, options);
65
+ }
66
+ } catch (error) {
67
+ (0, import_internal.__fictDisableSSR)();
68
+ restoreGlobals2();
69
+ restoreManifest();
70
+ throw error;
71
+ }
72
+ (0, import_internal.__fictDisableSSR)();
73
+ const html = serializeOutput(dom.document, container, options);
74
+ const dispose = () => {
75
+ try {
76
+ teardown();
77
+ } finally {
78
+ restoreGlobals2();
79
+ restoreManifest();
80
+ }
81
+ };
82
+ return { html, document: dom.document, window: dom.window, container, dispose };
83
+ }
84
+ function renderToString(view, options = {}) {
85
+ const result = renderToDocument(view, options);
86
+ const html = result.html;
87
+ result.dispose();
88
+ return html;
89
+ }
90
+ async function renderToStringAsync(view, options = {}) {
91
+ return renderToString(view, options);
92
+ }
93
+ function resolveDom(options) {
94
+ if (options.dom) {
95
+ return options.dom;
96
+ }
97
+ if (options.document && options.window) {
98
+ return { document: options.document, window: options.window };
99
+ }
100
+ if (options.document) {
101
+ const window = options.window ?? options.document.defaultView ?? options.document.defaultView ?? void 0;
102
+ if (!window) {
103
+ throw new Error(
104
+ "[fict/ssr] A window is required when providing a document without defaultView."
105
+ );
106
+ }
107
+ return { document: options.document, window };
108
+ }
109
+ if (options.window) {
110
+ return { document: options.window.document, window: options.window };
111
+ }
112
+ return createSSRDocument(options.html);
113
+ }
114
+ function resolveContainer(document, options) {
115
+ if (options.container) {
116
+ if (options.container.ownerDocument && options.container.ownerDocument !== document) {
117
+ throw new Error("[fict/ssr] Provided container belongs to a different document.");
118
+ }
119
+ return options.container;
120
+ }
121
+ const tag = options.containerTag ?? "div";
122
+ const container = document.createElement(tag);
123
+ if (options.containerId) {
124
+ container.setAttribute("id", options.containerId);
125
+ }
126
+ if (options.containerAttributes) {
127
+ for (const [name, value] of Object.entries(options.containerAttributes)) {
128
+ if (value === null || value === void 0 || value === false) continue;
129
+ container.setAttribute(name, value === true ? "" : String(value));
130
+ }
131
+ }
132
+ if (document.body) {
133
+ document.body.appendChild(container);
134
+ }
135
+ return container;
136
+ }
137
+ function serializeOutput(document, container, options) {
138
+ if (options.fullDocument) {
139
+ const doctype = serializeDoctype(document, options.doctype);
140
+ const html = document.documentElement ? document.documentElement.outerHTML : container.outerHTML;
141
+ return doctype ? `${doctype}${html}` : html;
142
+ }
143
+ if (options.includeContainer) {
144
+ return container.outerHTML;
145
+ }
146
+ return container.innerHTML;
147
+ }
148
+ function injectSnapshot(document, container, state, options) {
149
+ const script = document.createElement("script");
150
+ script.type = "application/json";
151
+ script.id = options.snapshotScriptId ?? "__FICT_SNAPSHOT__";
152
+ script.textContent = JSON.stringify(state);
153
+ if (options.fullDocument) {
154
+ if (options.snapshotTarget === "head" && document.head) {
155
+ document.head.appendChild(script);
156
+ return;
157
+ }
158
+ if (document.body) {
159
+ document.body.appendChild(script);
160
+ return;
161
+ }
162
+ }
163
+ const target = options.snapshotTarget ?? "container";
164
+ if (target === "body" && document.body) {
165
+ document.body.appendChild(script);
166
+ return;
167
+ }
168
+ if (target === "head" && document.head) {
169
+ document.head.appendChild(script);
170
+ return;
171
+ }
172
+ container.appendChild(script);
173
+ }
174
+ function serializeDoctype(document, override) {
175
+ if (override === null) return "";
176
+ if (override !== void 0) return override;
177
+ const doctype = document.doctype;
178
+ if (!doctype) return "";
179
+ const name = doctype.name || "html";
180
+ const publicId = doctype.publicId;
181
+ const systemId = doctype.systemId;
182
+ let id = "";
183
+ if (publicId) {
184
+ id = ` PUBLIC "${publicId}"`;
185
+ if (systemId) {
186
+ id += ` "${systemId}"`;
187
+ }
188
+ } else if (systemId) {
189
+ id = ` SYSTEM "${systemId}"`;
190
+ }
191
+ return `<!DOCTYPE ${name}${id}>`;
192
+ }
193
+ function installGlobals(window, document) {
194
+ const win = window;
195
+ const required = {
196
+ window: win,
197
+ document,
198
+ self: win,
199
+ Node: win.Node,
200
+ Element: win.Element,
201
+ HTMLElement: win.HTMLElement,
202
+ SVGElement: win.SVGElement,
203
+ Document: win.Document,
204
+ DocumentFragment: win.DocumentFragment,
205
+ Text: win.Text,
206
+ Comment: win.Comment
207
+ };
208
+ const optional = {
209
+ Range: win.Range,
210
+ Event: win.Event,
211
+ CustomEvent: win.CustomEvent,
212
+ MutationObserver: win.MutationObserver,
213
+ DOMParser: win.DOMParser,
214
+ getComputedStyle: win.getComputedStyle?.bind(win)
215
+ };
216
+ const missing = Object.entries(required).filter(([, value]) => value === void 0).map(([key]) => key);
217
+ if (missing.length) {
218
+ throw new Error(`[fict/ssr] Missing DOM globals: ${missing.join(", ")}`);
219
+ }
220
+ const globals = { ...required, ...optional };
221
+ const keys = Object.keys(globals);
222
+ const snapshot = captureGlobals(keys);
223
+ for (const key of keys) {
224
+ const value = globals[key];
225
+ if (value !== void 0) {
226
+ ;
227
+ globalThis[key] = value;
228
+ }
229
+ }
230
+ return () => restoreGlobals(snapshot);
231
+ }
232
+ function captureGlobals(keys) {
233
+ const snapshot = [];
234
+ for (const key of keys) {
235
+ const exists = Object.prototype.hasOwnProperty.call(globalThis, key);
236
+ const value = globalThis[key];
237
+ snapshot.push({ key, exists, value });
238
+ }
239
+ return snapshot;
240
+ }
241
+ function restoreGlobals(snapshot) {
242
+ for (const entry of snapshot) {
243
+ if (entry.exists) {
244
+ ;
245
+ globalThis[entry.key] = entry.value;
246
+ } else {
247
+ delete globalThis[entry.key];
248
+ }
249
+ }
250
+ }
251
+ function installManifest(manifest) {
252
+ if (!manifest) return () => {
253
+ };
254
+ let resolved;
255
+ if (typeof manifest === "string") {
256
+ const raw = (0, import_node_fs.readFileSync)(manifest, "utf8");
257
+ resolved = JSON.parse(raw);
258
+ } else {
259
+ resolved = manifest;
260
+ }
261
+ const key = "__FICT_MANIFEST__";
262
+ const snapshot = {
263
+ exists: Object.prototype.hasOwnProperty.call(globalThis, key),
264
+ value: globalThis[key]
265
+ };
266
+ globalThis[key] = resolved;
267
+ return () => {
268
+ if (snapshot.exists) {
269
+ ;
270
+ globalThis[key] = snapshot.value;
271
+ } else {
272
+ delete globalThis[key];
273
+ }
274
+ };
275
+ }
276
+ // Annotate the CommonJS export names for ESM import in node:
277
+ 0 && (module.exports = {
278
+ createSSRDocument,
279
+ renderToDocument,
280
+ renderToString,
281
+ renderToStringAsync
282
+ });
package/dist/index.d.cts CHANGED
@@ -1,2 +1,89 @@
1
+ import { FictNode } from '@fictjs/runtime';
1
2
 
2
- export { }
3
+ interface SSRDom {
4
+ window: Window;
5
+ document: Document;
6
+ }
7
+ interface RenderToStringOptions {
8
+ /**
9
+ * Provide a pre-created DOM (document + window). If omitted, a new DOM is
10
+ * created per render using `html`.
11
+ */
12
+ dom?: SSRDom;
13
+ /**
14
+ * Provide a document directly. If `window` is omitted, `document.defaultView`
15
+ * will be used when available.
16
+ */
17
+ document?: Document;
18
+ /**
19
+ * Provide a window directly. If `document` is omitted, `window.document` is used.
20
+ */
21
+ window?: Window;
22
+ /**
23
+ * HTML template used when creating a new DOM.
24
+ */
25
+ html?: string;
26
+ /**
27
+ * Provide a container element to render into.
28
+ */
29
+ container?: HTMLElement;
30
+ /**
31
+ * Tag name for the auto-created container.
32
+ */
33
+ containerTag?: string;
34
+ /**
35
+ * id applied to the auto-created container.
36
+ */
37
+ containerId?: string;
38
+ /**
39
+ * Additional attributes applied to the auto-created container.
40
+ */
41
+ containerAttributes?: Record<string, string | number | boolean | null | undefined>;
42
+ /**
43
+ * Return the container element including its outer tag.
44
+ */
45
+ includeContainer?: boolean;
46
+ /**
47
+ * Return a full HTML document string (doctype + documentElement.outerHTML).
48
+ */
49
+ fullDocument?: boolean;
50
+ /**
51
+ * Override doctype when `fullDocument` is true. Use `null` to omit.
52
+ */
53
+ doctype?: string | null;
54
+ /**
55
+ * Expose DOM globals (window/document/Node/Element/etc) during render.
56
+ * Defaults to true.
57
+ */
58
+ exposeGlobals?: boolean;
59
+ /**
60
+ * Manifest mapping module URLs to built client chunk URLs.
61
+ * Can be an object or a path to a JSON file.
62
+ */
63
+ manifest?: Record<string, string> | string;
64
+ /**
65
+ * Include the SSR snapshot script for resumability.
66
+ * Defaults to true.
67
+ */
68
+ includeSnapshot?: boolean;
69
+ /**
70
+ * Script element id for the snapshot.
71
+ */
72
+ snapshotScriptId?: string;
73
+ /**
74
+ * Where to append the snapshot script when not returning full document.
75
+ * Defaults to 'container'.
76
+ */
77
+ snapshotTarget?: 'container' | 'body' | 'head';
78
+ }
79
+ interface RenderToDocumentResult extends SSRDom {
80
+ html: string;
81
+ container: HTMLElement;
82
+ dispose: () => void;
83
+ }
84
+ declare function createSSRDocument(html?: string): SSRDom;
85
+ declare function renderToDocument(view: () => FictNode, options?: RenderToStringOptions): RenderToDocumentResult;
86
+ declare function renderToString(view: () => FictNode, options?: RenderToStringOptions): string;
87
+ declare function renderToStringAsync(view: () => FictNode, options?: RenderToStringOptions): Promise<string>;
88
+
89
+ export { type RenderToDocumentResult, type RenderToStringOptions, type SSRDom, createSSRDocument, renderToDocument, renderToString, renderToStringAsync };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,89 @@
1
+ import { FictNode } from '@fictjs/runtime';
1
2
 
2
- export { }
3
+ interface SSRDom {
4
+ window: Window;
5
+ document: Document;
6
+ }
7
+ interface RenderToStringOptions {
8
+ /**
9
+ * Provide a pre-created DOM (document + window). If omitted, a new DOM is
10
+ * created per render using `html`.
11
+ */
12
+ dom?: SSRDom;
13
+ /**
14
+ * Provide a document directly. If `window` is omitted, `document.defaultView`
15
+ * will be used when available.
16
+ */
17
+ document?: Document;
18
+ /**
19
+ * Provide a window directly. If `document` is omitted, `window.document` is used.
20
+ */
21
+ window?: Window;
22
+ /**
23
+ * HTML template used when creating a new DOM.
24
+ */
25
+ html?: string;
26
+ /**
27
+ * Provide a container element to render into.
28
+ */
29
+ container?: HTMLElement;
30
+ /**
31
+ * Tag name for the auto-created container.
32
+ */
33
+ containerTag?: string;
34
+ /**
35
+ * id applied to the auto-created container.
36
+ */
37
+ containerId?: string;
38
+ /**
39
+ * Additional attributes applied to the auto-created container.
40
+ */
41
+ containerAttributes?: Record<string, string | number | boolean | null | undefined>;
42
+ /**
43
+ * Return the container element including its outer tag.
44
+ */
45
+ includeContainer?: boolean;
46
+ /**
47
+ * Return a full HTML document string (doctype + documentElement.outerHTML).
48
+ */
49
+ fullDocument?: boolean;
50
+ /**
51
+ * Override doctype when `fullDocument` is true. Use `null` to omit.
52
+ */
53
+ doctype?: string | null;
54
+ /**
55
+ * Expose DOM globals (window/document/Node/Element/etc) during render.
56
+ * Defaults to true.
57
+ */
58
+ exposeGlobals?: boolean;
59
+ /**
60
+ * Manifest mapping module URLs to built client chunk URLs.
61
+ * Can be an object or a path to a JSON file.
62
+ */
63
+ manifest?: Record<string, string> | string;
64
+ /**
65
+ * Include the SSR snapshot script for resumability.
66
+ * Defaults to true.
67
+ */
68
+ includeSnapshot?: boolean;
69
+ /**
70
+ * Script element id for the snapshot.
71
+ */
72
+ snapshotScriptId?: string;
73
+ /**
74
+ * Where to append the snapshot script when not returning full document.
75
+ * Defaults to 'container'.
76
+ */
77
+ snapshotTarget?: 'container' | 'body' | 'head';
78
+ }
79
+ interface RenderToDocumentResult extends SSRDom {
80
+ html: string;
81
+ container: HTMLElement;
82
+ dispose: () => void;
83
+ }
84
+ declare function createSSRDocument(html?: string): SSRDom;
85
+ declare function renderToDocument(view: () => FictNode, options?: RenderToStringOptions): RenderToDocumentResult;
86
+ declare function renderToString(view: () => FictNode, options?: RenderToStringOptions): string;
87
+ declare function renderToStringAsync(view: () => FictNode, options?: RenderToStringOptions): Promise<string>;
88
+
89
+ export { type RenderToDocumentResult, type RenderToStringOptions, type SSRDom, createSSRDocument, renderToDocument, renderToString, renderToStringAsync };
package/dist/index.js CHANGED
@@ -0,0 +1,258 @@
1
+ // src/index.ts
2
+ import { readFileSync } from "fs";
3
+ import { render } from "@fictjs/runtime";
4
+ import {
5
+ __fictDisableSSR,
6
+ __fictEnableSSR,
7
+ __fictSerializeSSRState
8
+ } from "@fictjs/runtime/internal";
9
+ import { parseHTML } from "linkedom";
10
+ var DEFAULT_HTML = "<!doctype html><html><head></head><body></body></html>";
11
+ function createSSRDocument(html = DEFAULT_HTML) {
12
+ const window = parseHTML(html);
13
+ const document = window.document;
14
+ if (!window || !document) {
15
+ throw new Error("[fict/ssr] Failed to create DOM. Missing window or document.");
16
+ }
17
+ return { window, document };
18
+ }
19
+ function renderToDocument(view, options = {}) {
20
+ const includeSnapshot = options.includeSnapshot !== false;
21
+ __fictEnableSSR();
22
+ let dom;
23
+ let restoreGlobals2 = () => {
24
+ };
25
+ let restoreManifest = () => {
26
+ };
27
+ let container;
28
+ let teardown = () => {
29
+ };
30
+ try {
31
+ dom = resolveDom(options);
32
+ const { document, window } = dom;
33
+ const shouldExpose = options.exposeGlobals !== false;
34
+ restoreGlobals2 = shouldExpose ? installGlobals(window, document) : () => {
35
+ };
36
+ restoreManifest = installManifest(options.manifest);
37
+ container = resolveContainer(document, options);
38
+ teardown = render(view, container);
39
+ if (includeSnapshot) {
40
+ const state = __fictSerializeSSRState();
41
+ injectSnapshot(document, container, state, options);
42
+ }
43
+ } catch (error) {
44
+ __fictDisableSSR();
45
+ restoreGlobals2();
46
+ restoreManifest();
47
+ throw error;
48
+ }
49
+ __fictDisableSSR();
50
+ const html = serializeOutput(dom.document, container, options);
51
+ const dispose = () => {
52
+ try {
53
+ teardown();
54
+ } finally {
55
+ restoreGlobals2();
56
+ restoreManifest();
57
+ }
58
+ };
59
+ return { html, document: dom.document, window: dom.window, container, dispose };
60
+ }
61
+ function renderToString(view, options = {}) {
62
+ const result = renderToDocument(view, options);
63
+ const html = result.html;
64
+ result.dispose();
65
+ return html;
66
+ }
67
+ async function renderToStringAsync(view, options = {}) {
68
+ return renderToString(view, options);
69
+ }
70
+ function resolveDom(options) {
71
+ if (options.dom) {
72
+ return options.dom;
73
+ }
74
+ if (options.document && options.window) {
75
+ return { document: options.document, window: options.window };
76
+ }
77
+ if (options.document) {
78
+ const window = options.window ?? options.document.defaultView ?? options.document.defaultView ?? void 0;
79
+ if (!window) {
80
+ throw new Error(
81
+ "[fict/ssr] A window is required when providing a document without defaultView."
82
+ );
83
+ }
84
+ return { document: options.document, window };
85
+ }
86
+ if (options.window) {
87
+ return { document: options.window.document, window: options.window };
88
+ }
89
+ return createSSRDocument(options.html);
90
+ }
91
+ function resolveContainer(document, options) {
92
+ if (options.container) {
93
+ if (options.container.ownerDocument && options.container.ownerDocument !== document) {
94
+ throw new Error("[fict/ssr] Provided container belongs to a different document.");
95
+ }
96
+ return options.container;
97
+ }
98
+ const tag = options.containerTag ?? "div";
99
+ const container = document.createElement(tag);
100
+ if (options.containerId) {
101
+ container.setAttribute("id", options.containerId);
102
+ }
103
+ if (options.containerAttributes) {
104
+ for (const [name, value] of Object.entries(options.containerAttributes)) {
105
+ if (value === null || value === void 0 || value === false) continue;
106
+ container.setAttribute(name, value === true ? "" : String(value));
107
+ }
108
+ }
109
+ if (document.body) {
110
+ document.body.appendChild(container);
111
+ }
112
+ return container;
113
+ }
114
+ function serializeOutput(document, container, options) {
115
+ if (options.fullDocument) {
116
+ const doctype = serializeDoctype(document, options.doctype);
117
+ const html = document.documentElement ? document.documentElement.outerHTML : container.outerHTML;
118
+ return doctype ? `${doctype}${html}` : html;
119
+ }
120
+ if (options.includeContainer) {
121
+ return container.outerHTML;
122
+ }
123
+ return container.innerHTML;
124
+ }
125
+ function injectSnapshot(document, container, state, options) {
126
+ const script = document.createElement("script");
127
+ script.type = "application/json";
128
+ script.id = options.snapshotScriptId ?? "__FICT_SNAPSHOT__";
129
+ script.textContent = JSON.stringify(state);
130
+ if (options.fullDocument) {
131
+ if (options.snapshotTarget === "head" && document.head) {
132
+ document.head.appendChild(script);
133
+ return;
134
+ }
135
+ if (document.body) {
136
+ document.body.appendChild(script);
137
+ return;
138
+ }
139
+ }
140
+ const target = options.snapshotTarget ?? "container";
141
+ if (target === "body" && document.body) {
142
+ document.body.appendChild(script);
143
+ return;
144
+ }
145
+ if (target === "head" && document.head) {
146
+ document.head.appendChild(script);
147
+ return;
148
+ }
149
+ container.appendChild(script);
150
+ }
151
+ function serializeDoctype(document, override) {
152
+ if (override === null) return "";
153
+ if (override !== void 0) return override;
154
+ const doctype = document.doctype;
155
+ if (!doctype) return "";
156
+ const name = doctype.name || "html";
157
+ const publicId = doctype.publicId;
158
+ const systemId = doctype.systemId;
159
+ let id = "";
160
+ if (publicId) {
161
+ id = ` PUBLIC "${publicId}"`;
162
+ if (systemId) {
163
+ id += ` "${systemId}"`;
164
+ }
165
+ } else if (systemId) {
166
+ id = ` SYSTEM "${systemId}"`;
167
+ }
168
+ return `<!DOCTYPE ${name}${id}>`;
169
+ }
170
+ function installGlobals(window, document) {
171
+ const win = window;
172
+ const required = {
173
+ window: win,
174
+ document,
175
+ self: win,
176
+ Node: win.Node,
177
+ Element: win.Element,
178
+ HTMLElement: win.HTMLElement,
179
+ SVGElement: win.SVGElement,
180
+ Document: win.Document,
181
+ DocumentFragment: win.DocumentFragment,
182
+ Text: win.Text,
183
+ Comment: win.Comment
184
+ };
185
+ const optional = {
186
+ Range: win.Range,
187
+ Event: win.Event,
188
+ CustomEvent: win.CustomEvent,
189
+ MutationObserver: win.MutationObserver,
190
+ DOMParser: win.DOMParser,
191
+ getComputedStyle: win.getComputedStyle?.bind(win)
192
+ };
193
+ const missing = Object.entries(required).filter(([, value]) => value === void 0).map(([key]) => key);
194
+ if (missing.length) {
195
+ throw new Error(`[fict/ssr] Missing DOM globals: ${missing.join(", ")}`);
196
+ }
197
+ const globals = { ...required, ...optional };
198
+ const keys = Object.keys(globals);
199
+ const snapshot = captureGlobals(keys);
200
+ for (const key of keys) {
201
+ const value = globals[key];
202
+ if (value !== void 0) {
203
+ ;
204
+ globalThis[key] = value;
205
+ }
206
+ }
207
+ return () => restoreGlobals(snapshot);
208
+ }
209
+ function captureGlobals(keys) {
210
+ const snapshot = [];
211
+ for (const key of keys) {
212
+ const exists = Object.prototype.hasOwnProperty.call(globalThis, key);
213
+ const value = globalThis[key];
214
+ snapshot.push({ key, exists, value });
215
+ }
216
+ return snapshot;
217
+ }
218
+ function restoreGlobals(snapshot) {
219
+ for (const entry of snapshot) {
220
+ if (entry.exists) {
221
+ ;
222
+ globalThis[entry.key] = entry.value;
223
+ } else {
224
+ delete globalThis[entry.key];
225
+ }
226
+ }
227
+ }
228
+ function installManifest(manifest) {
229
+ if (!manifest) return () => {
230
+ };
231
+ let resolved;
232
+ if (typeof manifest === "string") {
233
+ const raw = readFileSync(manifest, "utf8");
234
+ resolved = JSON.parse(raw);
235
+ } else {
236
+ resolved = manifest;
237
+ }
238
+ const key = "__FICT_MANIFEST__";
239
+ const snapshot = {
240
+ exists: Object.prototype.hasOwnProperty.call(globalThis, key),
241
+ value: globalThis[key]
242
+ };
243
+ globalThis[key] = resolved;
244
+ return () => {
245
+ if (snapshot.exists) {
246
+ ;
247
+ globalThis[key] = snapshot.value;
248
+ } else {
249
+ delete globalThis[key];
250
+ }
251
+ };
252
+ }
253
+ export {
254
+ createSSRDocument,
255
+ renderToDocument,
256
+ renderToString,
257
+ renderToStringAsync
258
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/ssr",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Fict server-side rendering",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -26,7 +26,8 @@
26
26
  "dist"
27
27
  ],
28
28
  "dependencies": {
29
- "@fictjs/runtime": "0.4.0"
29
+ "linkedom": "^0.18.12",
30
+ "@fictjs/runtime": "0.5.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "tsup": "^8.5.1"