@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 +619 -0
- package/dist/index.cjs +281 -0
- package/dist/index.d.cts +88 -1
- package/dist/index.d.ts +88 -1
- package/dist/index.js +258 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
29
|
+
"linkedom": "^0.18.12",
|
|
30
|
+
"@fictjs/runtime": "0.5.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"tsup": "^8.5.1"
|