what-server 0.5.3 → 0.5.5
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 +136 -0
- package/package.json +5 -5
- package/src/actions.js +11 -4
- package/src/index.js +56 -5
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# what-server
|
|
2
|
+
|
|
3
|
+
Server-side rendering, streaming, islands architecture, and static site generation for [What Framework](https://whatfw.com). Zero JavaScript shipped by default -- islands opt in to client interactivity.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install what-server what-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use via the main package:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { renderToString, renderToStream } from 'what-framework/server';
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Render to String
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { renderToString } from 'what-server';
|
|
21
|
+
import { h } from 'what-core';
|
|
22
|
+
|
|
23
|
+
function App() {
|
|
24
|
+
return h('div', null,
|
|
25
|
+
h('h1', null, 'Hello from the server'),
|
|
26
|
+
h('p', null, 'This page ships zero JavaScript.')
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const html = renderToString(h(App));
|
|
31
|
+
// <div><h1>Hello from the server</h1><p>This page ships zero JavaScript.</p></div>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Streaming SSR
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { renderToStream } from 'what-server';
|
|
38
|
+
|
|
39
|
+
for await (const chunk of renderToStream(h(App))) {
|
|
40
|
+
response.write(chunk);
|
|
41
|
+
}
|
|
42
|
+
response.end();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Page Modes
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { definePage } from 'what-server';
|
|
49
|
+
|
|
50
|
+
export const page = definePage({
|
|
51
|
+
mode: 'static', // Pre-render at build time (default)
|
|
52
|
+
// mode: 'server' // Render on each request
|
|
53
|
+
// mode: 'client' // Render in browser (SPA)
|
|
54
|
+
// mode: 'hybrid' // Static shell + interactive islands
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Static Generation
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { generateStaticPage } from 'what-server';
|
|
62
|
+
|
|
63
|
+
const html = generateStaticPage({
|
|
64
|
+
component: App,
|
|
65
|
+
title: 'My Page',
|
|
66
|
+
meta: { description: 'A statically generated page' },
|
|
67
|
+
mode: 'static',
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Server Components
|
|
72
|
+
|
|
73
|
+
Mark components as server-only. They render on the server and ship no JavaScript to the client.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { server } from 'what-server';
|
|
77
|
+
|
|
78
|
+
const Header = server(({ title }) => h('header', null, title));
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Server Actions
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import { action, useAction, formAction, useFormAction } from 'what-server/actions';
|
|
85
|
+
|
|
86
|
+
// Define a server action
|
|
87
|
+
const addTodo = action(async (text) => {
|
|
88
|
+
await db.todos.create({ text });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Use in a component
|
|
92
|
+
function TodoForm() {
|
|
93
|
+
const { execute, isPending } = useAction(addTodo);
|
|
94
|
+
return h('button', { onclick: () => execute('New todo') }, 'Add');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Form action
|
|
98
|
+
const submitForm = formAction(async (formData) => {
|
|
99
|
+
const email = formData.get('email');
|
|
100
|
+
await subscribe(email);
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Sub-path Exports
|
|
105
|
+
|
|
106
|
+
| Path | Contents |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `what-server` | `renderToString`, `renderToStream`, `definePage`, `generateStaticPage`, `server` |
|
|
109
|
+
| `what-server/islands` | Islands hydration runtime |
|
|
110
|
+
| `what-server/actions` | Server actions and mutations |
|
|
111
|
+
|
|
112
|
+
## API
|
|
113
|
+
|
|
114
|
+
| Export | Description |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `renderToString(vnode)` | Render a component tree to an HTML string |
|
|
117
|
+
| `renderToStream(vnode)` | Render as an async iterator for streaming |
|
|
118
|
+
| `definePage(config)` | Define page rendering mode and metadata |
|
|
119
|
+
| `generateStaticPage(page, data?)` | Generate a full HTML document |
|
|
120
|
+
| `server(Component)` | Mark a component as server-only |
|
|
121
|
+
| `action(fn)` | Define a server action |
|
|
122
|
+
| `formAction(fn)` | Define a form-based server action |
|
|
123
|
+
| `useAction(action)` | Hook to call a server action |
|
|
124
|
+
| `useFormAction(action)` | Hook for form server actions |
|
|
125
|
+
| `useOptimistic(state)` | Optimistic UI updates |
|
|
126
|
+
| `useMutation(fn)` | Mutation with loading/error states |
|
|
127
|
+
| `invalidatePath(path)` | Revalidate a page path |
|
|
128
|
+
|
|
129
|
+
## Links
|
|
130
|
+
|
|
131
|
+
- [Documentation](https://whatfw.com)
|
|
132
|
+
- [GitHub](https://github.com/CelsianJs/whatfw)
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-server",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "What Framework - SSR, islands architecture, static generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -29,17 +29,17 @@
|
|
|
29
29
|
"server-rendering",
|
|
30
30
|
"what-framework"
|
|
31
31
|
],
|
|
32
|
-
"author": "",
|
|
32
|
+
"author": "ZVN DEV (https://zvndev.com)",
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"what-core": "^0.5.3"
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/
|
|
39
|
+
"url": "https://github.com/CelsianJs/whatfw"
|
|
40
40
|
},
|
|
41
41
|
"bugs": {
|
|
42
|
-
"url": "https://github.com/
|
|
42
|
+
"url": "https://github.com/CelsianJs/whatfw/issues"
|
|
43
43
|
},
|
|
44
|
-
"homepage": "https://
|
|
44
|
+
"homepage": "https://whatfw.com"
|
|
45
45
|
}
|
package/src/actions.js
CHANGED
|
@@ -17,7 +17,6 @@ import { signal, batch } from 'what-core';
|
|
|
17
17
|
|
|
18
18
|
// Registry of server actions
|
|
19
19
|
const actionRegistry = new Map();
|
|
20
|
-
let actionIdCounter = 0;
|
|
21
20
|
|
|
22
21
|
// --- CSRF Protection ---
|
|
23
22
|
// Server generates a token per session; client sends it with every action request.
|
|
@@ -170,11 +169,15 @@ export function formAction(actionFn, options = {}) {
|
|
|
170
169
|
formData = formDataOrEvent;
|
|
171
170
|
}
|
|
172
171
|
|
|
173
|
-
// Convert FormData to plain object
|
|
172
|
+
// Convert FormData to plain object, preserving File instances
|
|
174
173
|
const data = {};
|
|
174
|
+
let hasFiles = false;
|
|
175
175
|
for (const [key, value] of formData.entries()) {
|
|
176
|
+
if (typeof File !== 'undefined' && value instanceof File) {
|
|
177
|
+
hasFiles = true;
|
|
178
|
+
}
|
|
176
179
|
if (data[key]) {
|
|
177
|
-
// Handle multiple values (e.g., checkboxes)
|
|
180
|
+
// Handle multiple values (e.g., checkboxes, multi-file inputs)
|
|
178
181
|
if (Array.isArray(data[key])) {
|
|
179
182
|
data[key].push(value);
|
|
180
183
|
} else {
|
|
@@ -186,7 +189,11 @@ export function formAction(actionFn, options = {}) {
|
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
try {
|
|
189
|
-
|
|
192
|
+
// If form contains files, pass the raw FormData as second arg
|
|
193
|
+
// so the action handler can access files directly
|
|
194
|
+
const result = hasFiles
|
|
195
|
+
? await actionFn(data, formData)
|
|
196
|
+
: await actionFn(data);
|
|
190
197
|
if (onSuccess) onSuccess(result, form);
|
|
191
198
|
if (resetOnSuccess && form) form.reset();
|
|
192
199
|
return result;
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,23 @@ export function renderToString(vnode) {
|
|
|
15
15
|
return escapeHtml(String(vnode));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Signal — unwrap by calling it
|
|
19
|
+
if (typeof vnode === 'function' && vnode._signal) {
|
|
20
|
+
return renderToString(vnode());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Reactive function child — call to get value
|
|
24
|
+
if (typeof vnode === 'function') {
|
|
25
|
+
try {
|
|
26
|
+
return renderToString(vnode());
|
|
27
|
+
} catch (e) {
|
|
28
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
29
|
+
console.warn('[what-server] Error rendering reactive function in SSR:', e.message);
|
|
30
|
+
}
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
// Array
|
|
19
36
|
if (Array.isArray(vnode)) {
|
|
20
37
|
return vnode.map(renderToString).join('');
|
|
@@ -52,6 +69,24 @@ export async function* renderToStream(vnode) {
|
|
|
52
69
|
return;
|
|
53
70
|
}
|
|
54
71
|
|
|
72
|
+
// Signal — unwrap by calling it
|
|
73
|
+
if (typeof vnode === 'function' && vnode._signal) {
|
|
74
|
+
yield* renderToStream(vnode());
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Reactive function child — call to get value
|
|
79
|
+
if (typeof vnode === 'function') {
|
|
80
|
+
try {
|
|
81
|
+
yield* renderToStream(vnode());
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
84
|
+
console.warn('[what-server] Error rendering reactive function in stream SSR:', e.message);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
if (Array.isArray(vnode)) {
|
|
56
91
|
for (const child of vnode) {
|
|
57
92
|
yield* renderToStream(child);
|
|
@@ -60,10 +95,17 @@ export async function* renderToStream(vnode) {
|
|
|
60
95
|
}
|
|
61
96
|
|
|
62
97
|
if (typeof vnode.tag === 'function') {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
98
|
+
try {
|
|
99
|
+
const result = vnode.tag({ ...vnode.props, children: vnode.children });
|
|
100
|
+
// Support async components
|
|
101
|
+
const resolved = result instanceof Promise ? await result : result;
|
|
102
|
+
yield* renderToStream(resolved);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
105
|
+
console.warn('[what-server] Error rendering component in stream SSR:', e.message);
|
|
106
|
+
}
|
|
107
|
+
yield `<!-- SSR Error: ${escapeHtml(e.message || 'Component error')} -->`;
|
|
108
|
+
}
|
|
67
109
|
return;
|
|
68
110
|
}
|
|
69
111
|
|
|
@@ -181,7 +223,12 @@ function renderAttrs(props) {
|
|
|
181
223
|
.join(';');
|
|
182
224
|
out += ` style="${escapeHtml(css)}"`;
|
|
183
225
|
} else if (val === true) {
|
|
184
|
-
|
|
226
|
+
// ARIA attributes require explicit ="true", HTML boolean attrs can be bare
|
|
227
|
+
if (key.startsWith('aria-') || key === 'role') {
|
|
228
|
+
out += ` ${key}="true"`;
|
|
229
|
+
} else {
|
|
230
|
+
out += ` ${key}`;
|
|
231
|
+
}
|
|
185
232
|
} else {
|
|
186
233
|
out += ` ${key}="${escapeHtml(String(val))}"`;
|
|
187
234
|
}
|
|
@@ -199,6 +246,7 @@ function escapeHtml(str) {
|
|
|
199
246
|
}
|
|
200
247
|
|
|
201
248
|
function camelToKebab(str) {
|
|
249
|
+
if (str.startsWith('--')) return str; // CSS custom properties (variables) — leave unchanged
|
|
202
250
|
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
203
251
|
}
|
|
204
252
|
|
|
@@ -219,4 +267,7 @@ export {
|
|
|
219
267
|
invalidatePath,
|
|
220
268
|
handleActionRequest,
|
|
221
269
|
getRegisteredActions,
|
|
270
|
+
generateCsrfToken,
|
|
271
|
+
validateCsrfToken,
|
|
272
|
+
csrfMetaTag,
|
|
222
273
|
} from './actions.js';
|